Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 2 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 14 additions & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:**
Expand Down
2 changes: 1 addition & 1 deletion src/hooks.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'"
Expand Down
27 changes: 24 additions & 3 deletions src/lib/components/ActionSidebar.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
originalUrl,
muted = true,
uiHidden = false,
isOwn = false,
onsave,
oncomment,
onreactionhold,
Expand All @@ -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<typeof setTimeout> | null = null;
let holdFired = false;
Expand Down Expand Up @@ -106,10 +112,12 @@
<button
class="sidebar-btn"
class:active={favorited}
class:disabled={isOwn}
bind:this={saveBtnEl}
onpointerdown={handleSaveDown}
onpointerup={handleSaveUp}
aria-label={favorited ? 'Unsave' : 'Save'}
onpointerdown={isOwn ? stop : handleSaveDown}
onpointerup={isOwn ? stop : handleSaveUp}
aria-label={saveLabel}
disabled={isOwn}
>
<span class="icon-circle" class:pop={justSaved}>
{#if reactedEmoji && reactedEmoji !== '❤️' && REACTION_MAP.has(reactedEmoji)}
Expand Down Expand Up @@ -221,6 +229,19 @@
color: var(--accent-magenta);
}

.sidebar-btn.disabled {
opacity: 0.3;
cursor: not-allowed;
}

.sidebar-btn.disabled .icon-circle {
background: var(--reel-icon-circle-bg);
}

.sidebar-btn.disabled:active .icon-circle {
transform: none;
}

.unread-badge {
position: absolute;
top: -2px;
Expand Down
105 changes: 29 additions & 76 deletions src/lib/components/AvatarCropModal.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,18 @@
import Cropper from 'svelte-easy-crop';

const {
imageUrl,
ondismiss,
onuploaded
}: {
imageUrl: string;
ondismiss: () => void;
onuploaded: (avatarPath: string) => void;
} = $props();

let visible = $state(false);
let imageUrl = $state<string | null>(null);
let uploading = $state(false);
let closedViaBack = false;
let fileInput = $state<HTMLInputElement | null>(null);

let crop = $state({ x: 0, y: 0 });
let zoom = $state(1);
Expand All @@ -26,9 +26,6 @@
});
document.body.style.overflow = 'hidden';

// Open file picker immediately
setTimeout(() => fileInput?.click(), 100);

pushState('', { sheet: 'avatarCrop' });
const handlePopState = () => {
closedViaBack = true;
Expand All @@ -40,21 +37,9 @@
document.body.style.overflow = '';
window.removeEventListener('popstate', handlePopState);
if (!closedViaBack) history.back();
if (imageUrl) URL.revokeObjectURL(imageUrl);
};
});

function handleFileSelect(e: Event) {
const input = e.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) {
// User cancelled file picker
ondismiss();
return;
}
imageUrl = URL.createObjectURL(file);
}

function handleCropComplete(e: {
pixels: { x: number; y: number; width: number; height: number };
}) {
Expand Down Expand Up @@ -90,7 +75,7 @@
}

async function handleSave() {
if (!imageUrl || !croppedPixels || uploading) return;
if (!croppedPixels || uploading) return;
uploading = true;

try {
Expand All @@ -116,58 +101,38 @@
}
</script>

<input
bind:this={fileInput}
type="file"
accept="image/*"
class="file-input"
onchange={handleFileSelect}
/>

<div class="overlay" class:visible onclick={dismiss} role="presentation"></div>

<div class="modal" class:visible>
{#if imageUrl}
<div class="crop-header">
<button class="header-btn cancel" onclick={dismiss}>Cancel</button>
<span class="header-title">Move and scale</span>
<button class="header-btn save" onclick={handleSave} disabled={uploading}>
{uploading ? 'Saving...' : 'Save'}
</button>
</div>

<div class="crop-area">
<Cropper
image={imageUrl}
bind:crop
bind:zoom
aspect={1}
cropShape="round"
showGrid={false}
minZoom={1}
maxZoom={5}
restrictPosition={true}
oncropcomplete={handleCropComplete}
/>
</div>

<div class="zoom-controls">
<input type="range" min={1} max={5} step={0.01} bind:value={zoom} class="zoom-slider" />
</div>
{:else}
<div class="loading-state">
<span class="loading-text">Select a photo...</span>
</div>
{/if}
<div class="crop-header">
<button class="header-btn cancel" onclick={dismiss}>Cancel</button>
<span class="header-title">Move and scale</span>
<button class="header-btn save" onclick={handleSave} disabled={uploading}>
{uploading ? 'Saving...' : 'Save'}
</button>
</div>

<div class="crop-area">
<Cropper
image={imageUrl}
bind:crop
bind:zoom
aspect={1}
cropShape="round"
showGrid={false}
minZoom={1}
maxZoom={5}
restrictPosition={true}
oncropcomplete={handleCropComplete}
/>
</div>

<div class="zoom-controls">
<input type="range" min={1} max={5} step={0.01} bind:value={zoom} class="zoom-slider" />
</div>
</div>

<style>
.file-input {
position: absolute;
opacity: 0;
pointer-events: none;
}

.overlay {
position: fixed;
inset: 0;
Expand Down Expand Up @@ -253,16 +218,4 @@
accent-color: var(--accent-primary);
height: 4px;
}

.loading-state {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
}

.loading-text {
color: var(--text-muted);
font-size: 0.875rem;
}
</style>
7 changes: 4 additions & 3 deletions src/lib/components/CommentInput.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);
}
</script>

Expand Down Expand Up @@ -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);
}}
/>
<button type="submit" disabled={!canSubmit || submitting}>Send</button>
Expand Down
13 changes: 8 additions & 5 deletions src/lib/components/CommentsSheet.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
id: string;
url: string;
stillUrl: string;
shareUrl: string;
width: number;
height: number;
} | null>(null);
Expand Down Expand Up @@ -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 {
Expand Down
Loading
Loading