diff --git a/.env.example b/.env.example index 57a39d8..0eba10f 100644 --- a/.env.example +++ b/.env.example @@ -25,10 +25,18 @@ VAPID_PUBLIC_KEY= VAPID_PRIVATE_KEY= VAPID_SUBJECT=mailto:you@example.com -# App URL (REQUIRED — used for CSRF protection, Twilio webhooks, and invite links) +# App URL (REQUIRED — used for invite links, Twilio webhooks, and internal references) # Must match the public URL users access (e.g. https://scrolly.example.com) PUBLIC_APP_URL=http://localhost:3000 +# SvelteKit origin (REQUIRED behind a reverse proxy) +# SvelteKit checks the Origin header on form submissions for CSRF protection. +# Behind a reverse proxy, SvelteKit can't determine the correct origin on its own +# and will reject requests with a silent 403 (before app-level logging). +# docker-compose.yml sets this automatically from PUBLIC_APP_URL. +# For manual deployments, set this to your public URL. +# ORIGIN=https://scrolly.example.com + # Data directory (optional — defaults to ./data) # DATA_DIR=./data diff --git a/Dockerfile b/Dockerfile index 245a3f4..12e9e5a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -46,6 +46,8 @@ RUN mkdir -p /app/data && chown -R scrolly:scrolly /app ENV APP_VERSION=${APP_VERSION} ENV NODE_ENV=production ENV PORT=3000 +# SvelteKit CSRF protection — must match the public URL when behind a reverse proxy. +# docker-compose.yml overrides this with PUBLIC_APP_URL or DOMAIN. ENV ORIGIN=http://localhost:3000 VOLUME /app/data diff --git a/docs/architecture.md b/docs/architecture.md index be3faf2..0b682f8 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -180,11 +180,24 @@ VPS (Ubuntu, e.g., DigitalOcean or Hetzner) 2. Clone repo, `npm install`, `npm run build` 3. Create `data/videos/` directory 4. Configure environment variables (see `.env` template in repo) -5. Start app: `pm2 start build/index.js --name scrolly` +5. Start app: `ORIGIN=https://your-domain.com pm2 start build/index.js --name scrolly` 6. Generate VAPID keys: `npx web-push generate-vapid-keys` 7. Configure Twilio for SMS verification codes (see deployment docs) 8. Set up a reverse proxy (Caddy, nginx, etc.) for HTTPS +### ORIGIN and CSRF Protection + +SvelteKit has built-in CSRF protection that checks the `Origin` header on form submissions. Behind a reverse proxy, SvelteKit can't determine the correct origin on its own and will reject requests with a **silent 403** — the rejection happens before the request reaches app-level logging, so nothing appears in `docker logs` or application output. + +**Set the `ORIGIN` environment variable** to your public URL (e.g., `ORIGIN=https://scrolly.example.com`): + +- **Docker:** `docker-compose.yml` sets this automatically from `PUBLIC_APP_URL`. The Caddy overlay sets it from `DOMAIN`. +- **Manual:** Set `ORIGIN` in your shell environment or process manager config. + +This only affects form submissions (SvelteKit form actions). API endpoints (`/api/*` routes via `+server.ts`) are not subject to CSRF origin checks. + +**Troubleshooting:** If you see unexplained 403 errors on POST requests that don't appear in your app logs, check that `ORIGIN` matches the URL users access in their browser (protocol + domain, no trailing slash). + ## PWA Configuration **manifest.json:** diff --git a/src/hooks.server.ts b/src/hooks.server.ts index ed70dab..eb0085e 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -57,7 +57,7 @@ function setSecurityHeaders(response: Response): void { "script-src 'self' 'unsafe-inline'", "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com", "font-src 'self' https://fonts.gstatic.com", - "img-src 'self' blob: data: https://i.scdn.co", + "img-src 'self' blob: data: https://i.scdn.co https://*.giphy.com", "media-src 'self' blob:", "connect-src 'self'", "frame-ancestors 'none'" diff --git a/src/lib/components/ActionSidebar.svelte b/src/lib/components/ActionSidebar.svelte index 5341e34..97be29a 100644 --- a/src/lib/components/ActionSidebar.svelte +++ b/src/lib/components/ActionSidebar.svelte @@ -14,6 +14,7 @@ originalUrl, muted = true, uiHidden = false, + isOwn = false, onsave, oncomment, onreactionhold, @@ -26,12 +27,17 @@ originalUrl: string; muted?: boolean; uiHidden?: boolean; + isOwn?: boolean; onsave: () => void; oncomment: () => void; onreactionhold?: (x: number, y: number) => void; onmute?: () => void; } = $props(); + const saveLabel = $derived.by(() => { + if (isOwn) return 'Cannot like own clip'; + return favorited ? 'Unsave' : 'Save'; + }); let saveBtnEl: HTMLButtonElement | null = $state(null); let holdTimer: ReturnType | null = null; let holdFired = false; @@ -106,10 +112,12 @@ - Move and scale - - - -
- -
- -
- -
- {:else} -
- Select a photo... -
- {/if} +
+ + Move and scale + +
+ +
+ +
+ +
+ +
diff --git a/src/lib/components/CommentInput.svelte b/src/lib/components/CommentInput.svelte index 2d3c095..7727a46 100644 --- a/src/lib/components/CommentInput.svelte +++ b/src/lib/components/CommentInput.svelte @@ -17,7 +17,7 @@ replyingTo: { id: string; username: string } | null; submitting: boolean; gifEnabled?: boolean; - attachedGif: { url: string; stillUrl: string } | null; + attachedGif: { url: string; stillUrl: string; shareUrl?: string } | null; members?: GroupMember[]; onsubmit: (text: string, gifUrl?: string) => void; oncancelreply: () => void; @@ -42,7 +42,7 @@ function handleSubmit(e: SubmitEvent) { e.preventDefault(); if (!canSubmit || submitting) return; - onsubmit(text.trim(), attachedGif?.url); + onsubmit(text.trim(), attachedGif?.shareUrl || attachedGif?.url); } @@ -83,7 +83,8 @@ text = t; }} onsubmit={() => { - if (canSubmit && !submitting) onsubmit(text.trim(), attachedGif?.url); + if (canSubmit && !submitting) + onsubmit(text.trim(), attachedGif?.shareUrl || attachedGif?.url); }} /> diff --git a/src/lib/components/CommentsSheet.svelte b/src/lib/components/CommentsSheet.svelte index 706a686..7c39a4d 100644 --- a/src/lib/components/CommentsSheet.svelte +++ b/src/lib/components/CommentsSheet.svelte @@ -45,6 +45,7 @@ id: string; url: string; stillUrl: string; + shareUrl: string; width: number; height: number; } | null>(null); @@ -373,16 +374,18 @@ .comment-gif, .reply-gif { display: block; - border-radius: var(--radius-sm); - margin-top: var(--space-xs); + border-radius: var(--radius-md); + margin-top: var(--space-sm); object-fit: contain; + background: var(--bg-surface); + padding: 2px; } .comment-gif { - max-width: 200px; - max-height: 160px; + max-width: 150px; + max-height: 150px; } .reply-gif { - max-width: 160px; + max-width: 120px; max-height: 120px; } .comment-actions { diff --git a/src/lib/components/GifPicker.svelte b/src/lib/components/GifPicker.svelte index eb5dc27..e44d89a 100644 --- a/src/lib/components/GifPicker.svelte +++ b/src/lib/components/GifPicker.svelte @@ -1,9 +1,12 @@ @@ -84,7 +102,8 @@

Added!

- + + {:else}