The image wrapper used w-auto inside a flex container, which is
ambiguous for flex items — it could stretch to fill the available
width instead of shrinking to the image's actual rendered size. Not
visible for landscape images (little leftover space either way), but
portrait images left a wide gap and sat flush left instead of centered.
Switch to w-fit/h-fit (fit-content), which is unambiguous regardless of
flex context. Same wrapperClassName pattern in both files, so applied
to both.
Desktop sidebar already lists /unterstuetzen as a primary nav item, but
mobile had no way to reach it at all. The 5-slot bottom tab bar has no
room for a 6th destination, so it goes in the pet-switcher dropdown
next to the existing secondary actions (add pet, theme toggle) instead
of a second floating header icon.
router.push to the post owner's pet profile page (with a ?post= query
param picked up there) silently swapped out the notifications page in
the background. Closing the dialog then stranded the user on that
profile page instead of back on their notification list. Fetch the
post via posts.getById and open PostDetailDialog directly on the
notifications page instead — no navigation, no background page swap.
Drops the now-unused ?post= deep-link handling from ProfileTabs.
MessageThread's wrapper reserved its own pb-32 for the fixed mobile nav
on top of the h-[100dvh] sizing, while the app shell's <main> already
reserves the same space via its own pb-32. The combined mismatch made
<main> itself scroll as a rigid block instead of just the message list.
Switch to h-full so the thread fills main's actual available height.
Also nudge the floating mail shortcut up (top-3 -> top-1.5) so it's
vertically centered against the transparent feed header instead of
sitting visibly lower than it.
Notification rows for REACTION/COMMENT/MENTION/PAW_BACK linked to the post
owner's profile page instead of the specific post — there was no way to
land on the post itself. Add posts.getById for direct post lookups and
have the pet profile page pick up a ?post= query param to auto-open the
post detail dialog, falling back to a fetch when the post isn't among the
most recently loaded ones. Follow notifications already linked correctly
to the actor's profile.
aside now uses h-screen sticky top-0 overflow-y-auto -- same pattern as
the main app Sidebar.tsx -- so it stays in view while scrolling long
admin pages instead of scrolling away with the content.
- Sidebar: drop the /invite nav entry (invite-gate module is off,
INVITE_REQUIRED=false since the 2026-08-10 public launch)
- Admin layout: drop the same for the /invites management page
- Admin layout: add a "Zurueck" footer link back to /feed
Routes themselves are untouched -- only the nav entries pointing at them.
Post-launch fixes from live testing (Gitea #22):
- Feed/explore postInclude never selected Pet.isVerified, so PostCard only
ever rendered the new Sponsor badge, silently dropping Verified next to
it. Both now render side by side, matching the pet profile page.
- /unterstuetzen had no navigation entry anywhere -- added to the desktop
Sidebar (mobile bottom nav is a fixed 5-tab bar, left alone per scope).
- Admin users list showed no sponsor status at all -- SPONSOR badge added
next to the user_XXXX id, same pattern as the existing BANNED/
SHADOWBANNED badges.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Replaces the (never-launched) ad business direction with a freiwilliger
Beitrag/Community-Abo model: owners can support PawFeed via Stripe with
a one-time payment or a monthly SEPA/card subscription (2 EUR minimum),
declared as Sponsoring/Community-Abo, not a tax-deductible donation. The
Sponsor badge (only perk, no feature unlocks) is Owner-scoped and shows
on every post of every one of their pets, plus their profile.
- Schema: Owner.stripeCustomerId/sponsorSince/sponsorPeriodEnd/
sponsorSubscriptionId/sponsorSubscriptionStatus, new
SponsorContribution model (payment history)
- src/lib/stripe.ts (singleton client, mux.ts pattern) + src/lib/sponsor.ts
(isActiveSponsor: one-time givers keep the badge permanently, subscribers
through sponsorPeriodEnd even after cancelling -- no mid-period cliff)
- src/trpc/routers/sponsor.ts: getStatus, createCheckoutSession (SEPA +
card for subscriptions, card for one-time, price_data since amounts are
user-chosen), cancelSubscription (cancel_at_period_end, no hard stop)
- src/app/api/webhooks/stripe: checkout.session.completed, invoice.paid,
customer.subscription.updated/deleted (mux webhook route pattern)
- src/app/(app)/unterstuetzen: amount chips (3/5/10 EUR) prefilling an
editable field, one-time/subscription toggle, status + contribution
history, in-app cancel button (no Stripe-hosted portal, per requirement)
- Badge surfaced in feed/explore postInclude + PostCard, and on the pet
profile page next to the existing isVerified badge
- Nutzungsbedingungen Sec. 12 (draft, needs sign-off before launch) +
Stripe added to the Datenschutzerklaerung's Auftragsverarbeiter list
- src/__tests__/sponsor.test.ts (7 tests), Stripe client fully mocked
STRIPE_WEBHOOK_SECRET intentionally not yet configured -- the webhook
endpoint can only be registered in the Stripe dashboard once this is
deployed and reachable at https://pawfeed.org/api/webhooks/stripe.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Three of the five remaining Phase 4 admin-dashboard items from Gitea #10:
- Maintenance mode: Redis-backed global flag, gate in proxy.ts (exempts
/p/*, /api/*, legal pages, and sign-in/Clerk routes so a locked-out
admin can always get back in), SUPER_ADMIN-only dashboard toggle,
fails open on Redis errors.
- Registration IP: Owner.registrationIp captured defensively at every
owner.upsert create branch (consume-invite, legal.acknowledge,
legal.acknowledgeUploadConsent, pets.create, owner.setThemePreference)
since no single onboarding step is a code-guaranteed first touchpoint
across all invite/legal-doc states. Datenschutzerklärung updated and
user-approved.
- Trends & Promotion: Post.pinnedAt field, admin.pinPost/unpinPost
(mirrors the existing hidePost/unhidePost pattern), explore.getTrending
now surfaces pinned posts first regardless of the 48h window (with
explicit nulls:"last" ordering -- Postgres sorts NULL first on DESC by
default), trending-hashtags panel + pin/unpin button on the admin
posts page.
Password/2FA-reset admin trigger (item 5) was dropped -- Clerk's own
self-service reset already covers it. Broadcast/system messaging (item 2)
stays deferred pending a shared design session.
Reviewed via /code-review; findings addressed: the registration-IP capture
point, Postgres NULL ordering, and the maintenance-mode admin lockout gap
listed above were all fixes applied after that review, not part of the
original draft.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Confirmed via extensive isolated reproduction (docker run, plain BuildKit
RUN step, and Node child_process.spawn -- all three succeed) that TLS and
the sentry-cli invocation itself are fine; GlitchTip's releases API
rejects the authenticated request with "CSRF check Failed" (403) even
with clean token auth and no cookies. That's a server-side GlitchTip
issue (separate Docker stack), not something fixable from this repo.
Keeping ca-certificates/SSL_CERT_FILE since they're correct regardless.
Diagnostic only -- narrowing down whether the sourcemap upload's SSL
error is real or a red herring next to the CSRF 403 seen in manual
reproduction. Will be removed once the root cause is confirmed.
ca-certificates alone didn't fully resolve sentry-cli's TLS handshake
against GlitchTip in the Docker builder stage -- explicitly pointing its
vendored OpenSSL at the installed CA bundle location is the documented
workaround for this class of Rust-binary-on-Alpine cert issue.
sentry-cli (Rust binary) failed the sourcemap upload with "unable to get
local issuer certificate" when talking to GlitchTip over TLS -- the
node:22-alpine builder image ships without a CA bundle. Confirmed via
verbose build log after the SENTRY_RELEASE fix resolved the prior
--release undefined failure.
.git is excluded from the Docker build context (.dockerignore), so
@sentry/nextjs can't auto-detect a release via git and falls back to the
literal string "undefined" for --release, which made the sourcemaps
upload command fail with exit code 1 (confirmed via verbose build log).
start.sh now resolves the short SHA on the host and passes it through
as a SENTRY_RELEASE build arg, same pattern as the other Sentry vars.
- connect-src: allow *.fastly.mux.com (Mux HLS chunks/manifests via Fastly
edge, dynamic regional subdomains) and *.litix.io (Mux Data beacon uses
inferred.litix.io, not covered by the exact litix.io entry)
- media-src: allow image.mux.com (storyboard.vtt thumbnail track)
- next.config.ts: silent:true -> silent:false + debug:true so the next
Docker build surfaces what the Sentry/GlitchTip sourcemap upload plugin
actually does (still minified stacktraces in GlitchTip despite the
build-arg fix in b469389)
Tech stack table was describing the original MVP plan (Vercel, Upstash
Redis, middleware.ts) rather than what's actually running: self-hosted
Docker deployment, self-hosted Redis, next-intl i18n, dark mode,
GlitchTip/Sentry monitoring, CSP, and the admin/moderation panel built
since. Also refreshes the project structure tree, Prisma schema
overview, and setup instructions to match the current codebase.
- connect-src: allow the GlitchTip domain itself so the SDK can report events
- media-src: allow Mux's edge CDN (*.edgemv.mux.com) for HLS manifests
- img-src: allow blob: for client-side upload previews
- pass SENTRY_ORG/SENTRY_PROJECT/SENTRY_AUTH_TOKEN as Docker build args so
withSentryConfig can upload readable source maps during `npm run build`
The 🐾 emoji rendered inconsistently across mail clients/OS fonts and
looked off-brand. Replaces it with the exact lucide-react "paw-print"
mark (same path data as the landing page's filled paw icon) inlined
as raw SVG in the email header.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
GDPR export emails were unstyled HTML with a bare "Hallo," greeting.
Adds a landing-page-branded email shell (orange gradient header, paw
wordmark) and pulls the owner's first name from their Clerk profile
for the salutation, falling back to "Hallo," when unavailable.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
MAIL_FROM is a bare address with no display name, so mail clients fell
back to showing the local-part as the sender. Wrap it in a proper
display name in code instead of depending on env var formatting.
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