BlurImage's wrapper defaults to w-full/h-full for fill-container usages
(feed thumbnails with object-cover) — but in object-contain/letterboxed
contexts (story viewer, post detail single-image and carousel) that same
wrapper stretched to the full viewing area, defeating the parent's
flex-centering and leaving the actual rendered image pinned to the
top-left. Fixed by wrapping those three usages in an inline-flex
container sized to the actual image (wrapperClassName override), which
also gives the AI-disclosure badge a real image-relative anchor instead
of floating in the surrounding letterbox space. Bumped the badge's base
size (h-5 → h-7, h-8/h-10 in the larger detail/story contexts) per
live-test feedback that it was hard to notice.
Owners can now optionally mark uploaded content as AI_MODIFIED or
AI_GENERATED (mutually exclusive, defaults to NONE) across every upload
surface — photo posts, video posts, milestones, stories, and pet avatars.
Once set, the choice is owner-immutable; only a moderator can correct it
via a new admin.setAiDisclosure mutation (Posts admin page), which
requires a reason and writes to ModerationLog like every other
moderation action.
The disclosure renders as a UI-only overlay badge (the two provided
label assets) wherever the content is shown — feed, post detail, story
viewer, milestone cards, and the pet profile avatar — never baked into
the image pixels.
New shared components: AiDisclosureSelect (the 3-way picker used in all
five upload forms) and AiDisclosureBadge (the overlay renderer).
Step 2/2 (Audit Gitea #17). NPM's proxy host now forwards to pawfeed:3000
over the shared nginx_default Docker network (verified working in the
previous deploy), so the app no longer needs a published host port at
all — it was reachable unencrypted on the LAN, bypassing Cloudflare's
TLS termination and protections entirely.
Step 1/2 of removing the direct, unencrypted LAN exposure on port 4563
(Audit Gitea #17). Attaches pawfeed to Nginx Proxy Manager's Docker
network so NPM can reach it via the container DNS name (pawfeed:3000)
instead of the published host port. Port mapping stays for now — removed
in a follow-up commit once NPM's proxy host forward target is switched
over, to avoid a downtime gap between the two changes.
Redis had no auth, defended only by not being port-mapped to the host.
Defense-in-depth per the audit — REDIS_PASSWORD is generated server-side
in docker/.env (not committed) and threaded through to both the redis
service's --requirepass and the app's REDIS_URL.
Rate-limiting on messages.send, reports.create, reactions.toggle,
follows.create, reposts.create. Unique constraints on Report to stop
report-flooding. Mux webhook now fails hard in production without a
signing secret instead of skipping verification. Admin-panel outline
buttons fixed on 4 more pages (same white-on-dark bug as the Users page).
Timing-safe comparison for ADMIN_SECRET/CRON_SECRET. Fixed duplicate HSTS
header (Cloudflare's stronger one was being shadowed by the app's own).
tRPC now returns clean JSON 401s instead of HTML redirects when
unauthenticated, and no longer blocks public procedures for logged-out
visitors. 14 previously-silent catch blocks now log to GlitchTip.
invite/page.tsx and EmergencyVetSection.tsx fully localized (were 100%
hardcoded German). Stale TODO comments removed, auth.test.ts scaffold
rewritten to match the actual lazy-upsert pattern instead of a Clerk
webhook flow that was never built.
Visitors without an account had no way to switch themes — the toggle so
far only existed in the authenticated Sidebar/MobileNav. This variant
skips the account-bound persistence (no Owner row for guests) and just
flips next-themes' own localStorage-backed state.
The badges (Neuer Beitrag von Bubi / Likes-Follows-Reaktionen / Story ist
aktiv) intentionally keep a hardcoded white chip background, but their text
followed the text-foreground token, which flips to near-white in Dark Mode —
nearly invisible on the still-white chip.
Wires up the already-present next-themes dependency and globals.css
:root/.dark token infrastructure. Persists the choice on Owner.themePreference
so it survives relogin on a new device, not just localStorage. Toggle lives
in the Sidebar (desktop) and the pet-switcher dropdown (mobile); Clerk's own
UI now follows the same theme via a small ClerkThemeProvider wrapper.
The add-entry input took the whole field as one literal value, so
typing "word1, word2" created a single entry matching that exact
phrase instead of two separate words. Splits on comma client-side and
calls the existing single-value createBlacklistEntry mutation once
per value (dedup'd), with one consolidated toast instead of one per
word. No backend change — reuses the existing retroactive-scan
mutation as-is.
Follow-up on the Schrems II addition: the lawyer's note (Gitea #3
comment 258) requested more precise phrasing — "Auftragsverarbeiter"
instead of "Dienstleister", a risk-based framing ("dem Risiko
angemessenes Schutzniveau"), and the review commitment now targets
the effectiveness of the safeguards themselves rather than ongoing
legal-landscape monitoring. Kept the concrete TLS/access-control
specifics since those are still factually accurate.
Root cause: the shadcn outline Button variant uses bg-background,
which resolves to the light-theme CSS variable since the admin panel
doesn't wrap itself in a .dark class — so every outline button here
had a white background regardless of border/text color overrides,
making the colored text glare instead of read cleanly. Explicit
bg-zinc-900/hover:bg-zinc-800 on all five outline buttons on this
page (Message, Export, Warn, Un-shadowban, Unban) fixes it.
Icon-only buttons needed a hover or click before their function was
clear, unlike Ban/Shadowban which already show text. Adds visible
labels to match, and wraps the action row so the wider buttons don't
get clipped on narrower admin-panel windows.
The existing "Message" button on the Users page routed into the
normal /messages UI via assertPetOwnership, which only ever worked
for the literal ADMIN_OWNER_ID account — every other admin/moderator
got a silent FORBIDDEN, since the Team pet has one fixed owner. This
was already flagged as a known limitation in a code comment.
Adds three admin-gated procedures (assertAdmin instead of
assertPetOwnership) — listTeamConversations, getTeamConversationThread,
sendTeamReply — reusing the existing Conversation/Message model and
the sendTeamMessage helper. New /p/[secret]/messages page: a two-pane
inbox (conversation list + thread + reply box) any SUPPORT+ admin can
use, so the whole team sees the same conversation history instead of
only the bootstrap account. The Users-page Message button now deep-links
here instead of the broken consumer route.
Lawyer review flagged that section 4 (third-party processors) named
SCC as the sole basis for transfers to US-based processors (Clerk,
Mux) without mentioning that SCC alone doesn't always guarantee an
adequate protection level per Schrems II — additional technical/
organizational safeguards need to be disclosed too.
Renames the section to explicitly cover Drittlandübermittlungen and
adds a paragraph on the actual safeguards in place (TLS in transit,
role-based admin access controls, ongoing legal-landscape review).
Also fixes a stale "Stand: Juni 2026" footer date that didn't match
the page header's "August 2026".
Note: publishing a new version via /p/[secret]/legal (docType
DATENSCHUTZ) is still needed to trigger the re-acknowledgment banner
for existing owners — that's a deliberate SUPER_ADMIN action, not
done here.
Redis-backed visit tracking (src/lib/visit-tracking.ts): daily SADD
sets keyed "visits:YYYY-MM-DD", same fail-open idiom as the existing
rate-limit.ts. Recorded once per authenticated (app) layout render —
SADD is idempotent so no client-side dedup is needed. DAU = today's
SCARD, MAU = SUNION across the trailing 30 daily sets.
Dashboard additions:
- 6 new stat cards: DAU, MAU, shadowban count, blacklist entry count,
verifications open/closed.
- 3 new time-series charts (reusing the existing TimeSeriesChart/
GranularityToggle components): visits, comments, and reactions
over time. Comments/reactions are plain Postgres bucket queries
cloned from the existing getPostsOverTime pattern; visits reroutes
through the new Redis helpers per bucket.
This closes out Gitea #10 Phase 3 (hide-without-delete, shadowban,
blacklist, and now analytics — all four parts shipped).
Two follow-up requests on the admin dashboard:
- Reports page: the post preview is now clickable, opening a detail
dialog with full-size images, the complete comment list (reusing
the existing admin.listComments query), and a "liked by" list of
pets — previously moderators only saw a caption snippet and raw
counts, with no way to inspect what was actually reported. Backed
by a new admin.getReportedPostDetail query (images + reactions;
comments intentionally reuse the existing procedure instead of
duplicating that fetch).
- Users page: Warning/Export/Message buttons get distinct colors
(amber/blue/sky) matching the existing Ban (red) / Shadowban
(violet) pattern, instead of all three sharing the same neutral
gray outline.
Gitea #11 follow-up: replaces the plain rounded rectangle around the
mobile screenshot with an actual iPhone mockup — dark bezel, Dynamic
Island, side buttons, and a home indicator bar in its own strip below
the screenshot (kept separate from the image content so it doesn't
overlap the app's own tab bar).
Gitea #11 follow-up comment: the phone mockup previously showed the
desktop feed layout squeezed into a phone bezel. Swaps in an actual
mobile-view screenshot (portrait, bottom tab bar) so the mockup reads
as a real app screen. Repositions the three floating badges to clear
the screenshot's own header and tab bar, since the new portrait image
is much taller than the old near-square desktop capture.
Finishes the manual edits from Gitea #16: fixes capitalization on
"Jetzt starten" and grammar on the hero badge text, updates the
banned-page contact address to team@pawfeed.org, and adds the
missing `group` class so the feature-card icon's `group-hover:scale-105`
actually fires. Keeps the RelativeTime strings in their original
compact form ({count}m/h/d) since that format is documented as
intentional for tight UI spots (comment lists, notifications, ad cards).
Adds a BlacklistEntry model (type WORD|LINK|HASHTAG, unique per
type+value) with a new admin module at /p/[secret]/blacklist —
add/list/delete entries, filterable by type.
Enforcement, per explicit spec from this session:
- New posts are checked at creation time (posts.create) against the
current blacklist (whole-word regex match for WORD, domain match
incl. subdomains for LINK, exact match for HASHTAG) and auto-hidden
immediately via the existing hiddenAt mechanism if matched — never
blocks creation, just hides right after, reversible via
admin.unhidePost. Logged as HIDE_POST with moderatorId "SYSTEM".
- Adding a new entry retroactively scans existing non-hidden posts
(capped at 5000 for safety) and hides matches too, logged per-post
under the admin who added the entry.
- Removing an entry does NOT auto-unhide already-hidden posts —
that stays a manual review step via the existing hide/unhide UI.
Matching lives in src/lib/blacklist.ts: word matching uses \b word
boundaries (a blacklisted "ass" won't match "assist"), link matching
extracts URLs and compares hostnames including subdomains.
Scope: post captions/hashtags only, not comment bodies — comments
don't have a hiddenAt field yet, so this is a natural follow-up once
they do, using the same pattern.
Gitea #10
Adds a Shadowban model (mirrors UserBan: reason, issuedBy, expiresAt =
null for permanent) plus MODERATOR+ shadowbanOwner/removeShadowban
mutations logging SHADOWBAN_USER/UNSHADOWBAN_USER to ModerationLog.
Unlike a ban, a shadowbanned owner is never told and keeps using the
app normally — their posts just quietly stop reaching anyone else.
Every reader-facing post query now excludes a shadowbanned owner's
pets for other viewers while still showing that owner their own
content, via a `pet: { OR: [{ ownerId: ctx.userId }, { owner: {
shadowban: null } }] }` filter: feed (both Redis-postId lookup and the
Postgres fallback), profile grid, hashtag search, and the milestones
tab. Explore trending has no reliable per-viewer identity (public
procedure) so it excludes shadowbanned owners unconditionally — a
low-stakes gap since a single post rarely lands there anyway.
Admin dashboard (Gitea #10's "Konten sperren ... Shadowban" spec item):
Users page now shows a violet SHADOWBANNED badge + reason next to the
existing ban badge, and a Ghost-icon shadowban/un-shadowban button
mirroring the ban/unban flow (reason required, unlike ban's optional
one, since the deceptive nature of the action warrants a mandatory
audit trail).
Gitea #10
Adds Post.hiddenAt (nullable, db push applied) plus MODERATOR+ admin
mutations hidePost/unhidePost, each logging HIDE_POST/UNHIDE_POST to
ModerationLog with the standard moderatorRole+ipAddress stamp.
hidePost requires a non-empty reason (visible to nobody but audit
trail); unhidePost is a plain reversal. The admin posts grid shows a
"Hidden" overlay badge and swaps the delete-adjacent action button
between hide/unhide based on hiddenAt.
Every reader-facing post query now filters hiddenAt: null so a hidden
post disappears everywhere without touching its row: feed (discovery
candidates, Postgres fallback, and the Redis-postId lookup — the same
place blocked-pet filtering already happens, so an already-fanned-out
hidden post silently drops out at read time), feed backfill on new
follows, explore trending, profile grid, hashtag search, and the
milestones tab.
Admin-facing queries (listPosts, analytics bucketing) are deliberately
NOT filtered — moderators need to see hidden posts to unhide them, and
volume analytics shouldn't be skewed by moderation actions.
Gitea #10
Rebuilds the public landing page per the structural reference in
Gitea #11 (navbar layout, rounded hero card with phone mockup +
floating badges, catchphrase section, second detail-feature card,
footer repeating the navbar) — colors/typography adapted to PawFeed's
existing orange brand instead of the generic SaaS reference palette.
New:
- Sticky navbar (logo, Home/About-coming-soon/Impressum, language
switcher, Login + Sign-up pill)
- Hero: rounded-4xl gradient card, phone-framed app preview with
three floating badge cards (idle bob animation), scroll-linked
parallax on the background paw watermark via native CSS
animation-timeline: view() (progressive enhancement, no JS)
- Catchphrase section reusing the existing 4 feature translations,
each card staggered into view via a new ScrollReveal client
component (IntersectionObserver, no scroll-handler churn)
- Second rounded detail-feature card with two checklist points
- Footer repeats the navbar links + tagline + "designed by" line
All reveal/parallax/float animations respect prefers-reduced-motion
and are pure CSS — no new animation dependency. Verified in a real
browser at 390px and ~1568px (mobile nav collapse, hero stacking,
floating badges hidden on mobile, scroll reveals firing correctly).
Gitea #11
Replaces the dead hand-rolled buildCsp() draft in next.config.ts (never
wired in — its script-src had no 'unsafe-inline'/nonce, which blocked
Next.js's own hydration script and blanked the whole app when tested
live on 2026-08-10) with Clerk's official contentSecurityPolicy option
on clerkMiddleware(). This is Clerk's documented workaround: its default
directives already include the permissive script-src Next.js App Router
needs, merged with our app-specific img-src/connect-src/media-src/
font-src/frame-ancestors/object-src/base-uri additions (Supabase, Mux,
litix).
Ships as Content-Security-Policy-Report-Only (reportOnly: true) with
both reporting mechanisms wired to a new /api/csp-report route handler:
Clerk's reportTo (Reporting API) and a classic report-uri (for browsers
without Reporting API support). The route normalizes both report shapes,
rate-limits by IP, and forwards violations to Sentry/GlitchTip as
warnings — never blocks the request.
Deliberately NOT enforced yet — move to reportOnly: false only after a
clean observation period with zero unexpected violations.
Gitea #5
npm audit fix --force bumps next 16.2.7 -> 16.3.0 (postcss/sharp come
along as transitive deps), closing the last 3 high-severity findings:
middleware/proxy bypass in Turbopack single-locale apps, SSRF in
Server Actions and rewrites, cache response confusion, unauthenticated
internal Server Function endpoint disclosure, and SVG image-optimization
DoS. npm audit now reports 0 vulnerabilities.
tsc/vitest/build all verified green before deploying.
Resolves 11 of 16 flagged vulnerabilities via npm audit fix (undici,
hono/@hono/node-server, brace-expansion, fast-uri, ip-address, esbuild,
body-parser, valibot, nanoid) — all transitive dev/build tooling deps,
no direct dependency ranges changed. tsc/vitest/build all verified green.
Remaining 3 (next, postcss, sharp) need npm audit fix --force, which
would bump next past the stated package.json range — deferred pending
a dedicated test pass.
Adds an AvatarCropDialog step (react-easy-crop, cropShape="round")
between file selection and the presigned-URL upload in AvatarUpload:
the user crops/zooms a circular region, which is re-encoded via canvas
to the original file's mime type before the existing upload flow runs.
File-size/type validation moved from uploadFile into the new
selectFile gate that opens the dialog.
Gitea #13
Adds a PetAvatar wrapper (hash(name) mod 360 -> HSL background) and
migrates the four most-visible avatar call sites (sidebar, mobile nav,
post card, post detail incl. comments) to use it instead of duplicated
inline initials logic.
Desktop sidebar can now collapse to an icon-only 76px rail, persisted
in localStorage (same client-init pattern as ActivePetContext). All
nav rows, the new-post button, and the owner row adapt; secondary
sections (language switcher, legal footer) hide when collapsed.
Gitea #12, #14
Adds a CSS-only spring-scale + expanding-burst-overlay animation
triggered when a pet paw-reacts to a post, respecting
prefers-reduced-motion. Only plays on the like transition, not unlike.
Gitea #15
The review dialog only showed data on the card; the checklist itself was
just labels+checkboxes. Now each item shows its own evidence right above
the checkbox (bio/species for authenticity, avatar+bio completeness,
ban+warning status for standing, join date/posts/followers for
presence) — double verification so a moderator can't check a box from
memory instead of the actual data.
Three follow-ups from testing the verification flow:
- listVerificationRequests now pulls bio, join date, post/follower
counts, and the owner's ban+warning status onto the review card, so a
moderator can check the checklist without leaving the page
- Edit-pet page was entirely hardcoded English regardless of locale —
converted to next-intl (new EditPet namespace, en+de)
- the pet switcher (Sidebar + MobileNav) didn't show the verified badge,
only the full profile page did — added it to both
Gitea #10 Option B:
- new VerificationRequest model — owner submits a message via the pet
edit page, moderator reviews against a fixed 4-item checklist
(authenticity/impersonation risk, complete profile, good standing,
established presence). All four met -> approved, Pet.isVerified flips
on; any unmet -> auto-rejected with an itemized note of what's missing
- new admin queue /p/[secret]/verification (Moderator+ only, same gate
as the audit log), status filter, review dialog enforces the checklist
- owner is notified automatically either way via a "PawFeed Team" DM —
extracted the team-pet lookup/create logic (previously only inline in
startTeamConversation) into src/lib/team-pet.ts, adding sendTeamMessage
which reuses the normal Conversation/Message + MESSAGE notification
- review decisions log to ModerationLog with role+IP, same as every
other Gitea #10 action this session
Both had no onError handler, so a failed request (e.g. a permission
check) silently did nothing — the verify button looked broken with no
indication why. Now shows a toast on both success and failure.
Gitea #10 Phase 2:
- Warning model (append-only, no delete — Owner history), issueWarning +
listWarnings, surfaced on the Users page with a badge + dialog
- Pet.isVerified checkmark (lives on Pet, not Owner — pets are the social
identity per CLAUDE.md), toggleable from the Users page, shown on the
public profile page
- listReports now derives a HIGH/MEDIUM/LOW severity from ReportReason
and sorts severity-first; Reports page shows the derived badge
- Admin-triggered GDPR Art. 17 erasure: extracted the self-service
/api/account/delete logic into a shared deleteOwnerAccount() helper,
new admin.deleteUserAccount (SUPER_ADMIN only, requires typing the
exact ownerId to confirm, blocks self-deletion) reuses it — Delete
button only renders for SUPER_ADMIN/bootstrap callers
- all four new/changed mutations log to ModerationLog with role+IP
flex-wrap with long IDs/IPs pushed content past the viewport edge instead
of wrapping. Switched to a fixed-column grid with truncation + title
tooltips, wrapped in a horizontal-scroll container so the whole table
scrolls as a unit on narrow screens instead of individual cells clipping.
Gitea #10 follow-up:
- new admin.getMyRole procedure (no minRole gate) so the panel can answer
"what's my own role" without hitting a permission wall
- fixes a regression from the permission-matrix change: layout.tsx was
using listModerators (now MODERATOR+) just to check "does this user have
any admin access at all", which locked SUPPORT accounts out of the
entire panel — swapped to getMyRole
- getModerationLog gains cursor pagination, action/targetType/moderatorId
filters, and resolves each entry's moderator to a pet name
- new /p/[secret]/log page renders the filterable log; the nav link only
shows for MODERATOR/SUPER_ADMIN/bootstrap, and the page itself shows a
clear "no permission" state if a SUPPORT account hits the URL directly
Gitea #10 follow-up:
- assertAdmin now takes a minRole (SUPPORT < MODERATOR < SUPER_ADMIN)
instead of a boolean requireSuperAdmin flag, and returns the caller's
resolved role so mutation sites can log it. Applied per the agreed
matrix: read-only/support tasks stay SUPPORT, destructive content/user
actions require MODERATOR, ads/roles/invites/legal-doc-publishing
require SUPER_ADMIN.
- ModerationLog gains moderatorRole and ipAddress (both nullable —
historical rows predate capture), populated on every write for
login-abuse forensics.
Gitea #10 follow-up, Phase 1:
- AdminRoleType gains SUPPORT (same access as MODERATOR for now, pending
a permission-matrix pass to scope it down)
- listUsers gains search (Clerk query + local pet-name match), ban-status,
and role filters
- ModerationLog now also captures ad create/update/delete and role
grant/revoke, which previously left no audit trail
- new getModeratorThroughput query surfaces per-moderator action counts
on the Moderators page
Post moderation grid rendered milestone and repost posts as an empty
ImageOff placeholder with no way to tell what they were. Now shows the
milestone icon+label, or the original post's thumbnail and pet name
for reposts.
Replaces the sharp-stitched sidebar+feed composite with a cleaner
screenshot the user captured directly, and switches the Image from
fill+aspect-[16/10] (cropping) to its natural 872x926 size since the
new capture has a different aspect ratio and shouldn't be cropped.
The landing page was hardcoded English, so switching languages had no
visible effect on it — added a "Landing" namespace to both message
catalogs and wired src/app/page.tsx up via getTranslations, matching
the pattern already used by other server-rendered pages.
Regional-indicator flag emoji render as bare "GB"/"DE" text on stock
Windows (no color-flag font substitution), which defeated the point
of switching from text labels to flags — swaps in small inline SVG
flag components instead, guaranteed to render everywhere.
Also re-crops the app-preview screenshot: the original had a wide
empty gap between the sidebar and the feed column baked into the
capture (an artifact of the live app's centered-column layout at that
viewport width). Stitches the sidebar and feed regions together
tightly via sharp instead.
The new public landing page (/) was hero-only — missing a language
switcher, legal links, and anything showing what the product actually
looks like. Adds:
- A slim header with the wordmark and a flag-based language switcher
(LanguageSwitcher now shows 🇬🇧/🇩🇪 instead of English/Deutsch text,
used here and everywhere else it's already mounted)
- An app-preview section: a real screenshot of the live feed inside a
browser-chrome mockup card
- A 4-item feature overview (pet-first profiles, posts/stories/video,
health tracking, pet-to-pet messaging)
- A footer with Impressum/Datenschutz/AGB links, matching what the
authenticated sidebar already links to
/ unconditionally redirected to /feed, gated entirely by proxy.ts's
auth.protect() — so logged-out visitors AND social-media crawlers
scraping og:image for link previews got bounced to Clerk's hosted
sign-in before Next.js ever rendered this route's metadata (surfaced
as Facebook Sharing Debugger's "og:image should be explicit" warning,
since the crawler never saw our actual tags).
Adds "/" to proxy.ts's public matcher and moves the
authenticated-redirect into the page itself via a plain auth() read
(not .protect()), so it can render a real hero page — matching the OG
image's warm gradient + paw-mark visual language — for logged-out
visitors and crawlers alike, while still bouncing authenticated users
straight to /feed as before.