From 76de798fafb8edc90590ea5a81b225def01123d3 Mon Sep 17 00:00:00 2001 From: khannurien Date: Sat, 27 Jun 2026 10:40:07 +0000 Subject: [PATCH] v3: fixed authorization bug with stale tokens, fixed private dump access bug, various small fixes across the app --- .devcontainer/Dockerfile | 2 +- Dockerfile | 4 +- api/lib/auth.ts | 24 +++++++-- api/lib/jwt.ts | 2 +- api/routes/files.ts | 8 ++- api/routes/thumbnails.ts | 9 +++- deno.json | 6 +-- scripts/lingui-compile.ts | 11 ----- scripts/lingui-extract.ts | 11 ----- src/App.css | 35 ++++++++++++- src/components/DumpCard.tsx | 7 +-- src/components/FilePreview.tsx | 10 ++-- src/components/JournalCard.tsx | 6 ++- src/components/form/TextField.tsx | 20 ++++++-- src/contexts/AuthProvider.tsx | 40 ++++++++++++++- src/hooks/useAuth.ts | 4 +- src/locales/en.js | 2 +- src/locales/en.po | 82 +++++++------------------------ src/locales/fr.js | 2 +- src/locales/fr.po | 82 +++++++------------------------ src/pages/Dump.tsx | 6 +-- src/pages/DumpEdit.tsx | 25 ++++++++-- src/themes/brutalist.css | 7 ++- src/utils/urls.ts | 25 ++++++++++ 24 files changed, 236 insertions(+), 194 deletions(-) delete mode 100644 scripts/lingui-compile.ts delete mode 100644 scripts/lingui-extract.ts diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index a97252b..2ab16ff 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -1,3 +1,3 @@ -FROM denoland/deno:bin-2.7.5 AS deno +FROM denoland/deno:bin-2.9.0 AS deno FROM mcr.microsoft.com/devcontainers/typescript-node:20 COPY --from=deno /deno /usr/local/bin/deno diff --git a/Dockerfile b/Dockerfile index cb073ec..cced885 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # ── Stage 1: build Vite frontend ───────────────────────────────────────────── -FROM denoland/deno:2.8.3 AS builder +FROM denoland/deno:2.9.0 AS builder WORKDIR /app @@ -23,7 +23,7 @@ ENV VITE_API_PROTOCOL=$VITE_API_PROTOCOL \ RUN deno task build # ── Stage 2: runtime ────────────────────────────────────────────────────────── -FROM denoland/deno:alpine-2.8.3 +FROM denoland/deno:alpine-2.9.0 RUN apk add --no-cache ffmpeg curl diff --git a/api/lib/auth.ts b/api/lib/auth.ts index 85db43a..95f0871 100644 --- a/api/lib/auth.ts +++ b/api/lib/auth.ts @@ -1,10 +1,28 @@ import type { Context } from "@oak/oak"; import { verifyJWT } from "./jwt.ts"; +import { APIErrorCode, APIException } from "../model/interfaces.ts"; -/** Extracts the userId from an optional Bearer token. Returns null if absent or invalid. */ +/** + * Extracts the userId from an optional Bearer token. + * + * Distinguishes *missing* credentials from *invalid* ones: + * - No `Authorization: Bearer …` header (or a placeholder value such as the + * `Bearer undefined` that the client emits when logged out) → anonymous (null). + * - A real token that fails verification (expired / bad signature) → throws 401, + * so a stale client session surfaces as an auth error it can react to, instead + * of being silently downgraded to anonymous (which yields a misleading 404 on + * private resources). + */ export async function parseOptionalAuth(ctx: Context): Promise { const authHeader = ctx.request.headers.get("Authorization"); if (!authHeader?.startsWith("Bearer ")) return null; - const payload = await verifyJWT(authHeader.substring(7)); - return payload?.userId ?? null; + + const token = authHeader.substring(7).trim(); + if (!token || token === "undefined" || token === "null") return null; + + const payload = await verifyJWT(token); + if (!payload) { + throw new APIException(APIErrorCode.UNAUTHORIZED, 401, "Invalid token"); + } + return payload.userId; } diff --git a/api/lib/jwt.ts b/api/lib/jwt.ts index aa24e0e..af40ef3 100644 --- a/api/lib/jwt.ts +++ b/api/lib/jwt.ts @@ -71,7 +71,7 @@ export async function createJWT( ): Promise { return await new SignJWT(payload) .setProtectedHeader({ alg: "HS256" }) - .setExpirationTime("24h") + .setExpirationTime("7d") .sign(JWT_KEY); } diff --git a/api/routes/files.ts b/api/routes/files.ts index 16f5f55..c8a3012 100644 --- a/api/routes/files.ts +++ b/api/routes/files.ts @@ -2,6 +2,7 @@ import { Router } from "@oak/oak"; import { APIErrorCode, APIException } from "../model/interfaces.ts"; import { getDump } from "../services/dump-service.ts"; import { DUMPS_DIR } from "../lib/upload.ts"; +import { verifyJWT } from "../lib/jwt.ts"; const router = new Router({ prefix: "/api/files" }); @@ -30,7 +31,12 @@ router.get("/:dumpId", async (ctx) => { throw new APIException(APIErrorCode.BAD_REQUEST, 400, "Invalid dump ID"); } - const dump = getDump(dumpId); + // Media elements can't send an Authorization header, so the viewer's token + // arrives as a query param (same as the WebSocket route). Needed to authorize + // access to private dumps the requester owns. + const token = ctx.request.url.searchParams.get("token"); + const payload = token ? await verifyJWT(token) : null; + const dump = getDump(dumpId, payload?.userId); if (dump.kind !== "file" || !dump.fileMime || !dump.fileName) { throw new APIException( diff --git a/api/routes/thumbnails.ts b/api/routes/thumbnails.ts index 5e84c99..c40be33 100644 --- a/api/routes/thumbnails.ts +++ b/api/routes/thumbnails.ts @@ -3,6 +3,7 @@ import { APIErrorCode, APIException } from "../model/interfaces.ts"; import { getDump, getDumpThumbnailInfo } from "../services/dump-service.ts"; import { serveUploadedFile } from "../lib/upload.ts"; import { DUMPS_DIR, THUMBNAILS_DIR } from "../config.ts"; +import { verifyJWT } from "../lib/jwt.ts"; const router = new Router({ prefix: "/api/thumbnails" }); @@ -54,6 +55,12 @@ router.get("/:dumpId", async (ctx) => { throw new APIException(APIErrorCode.BAD_REQUEST, 400, "Invalid dump ID"); } + // Token via query param (media elements can't send headers) — authorizes the + // owner's private dumps and gates the custom-thumbnail branch below too. + const token = ctx.request.url.searchParams.get("token"); + const payload = token ? await verifyJWT(token) : null; + const dump = getDump(dumpId, payload?.userId); + const customThumb = getDumpThumbnailInfo(dumpId); if (customThumb) { const served = await serveUploadedFile( @@ -64,8 +71,6 @@ router.get("/:dumpId", async (ctx) => { if (served) return; } - const dump = getDump(dumpId); - if (dump.kind !== "file" || !dump.fileMime?.startsWith("video/")) { throw new APIException( APIErrorCode.NOT_FOUND, diff --git a/deno.json b/deno.json index 5b4bf28..f883690 100644 --- a/deno.json +++ b/deno.json @@ -4,9 +4,9 @@ "build": "deno task i18n:extract && deno task i18n:compile && deno run --env-file -A npm:vite build", "server:start": "deno run --env-file -A --watch api/main.ts", "serve": "deno task build && deno task server:start", - "i18n:extract": "deno run -A scripts/lingui-extract.ts", - "i18n:compile": "deno run -A scripts/lingui-compile.ts", - "start": "deno run -A api/db/init.ts && deno run -A api/main.ts" + "i18n:extract": "deno run -A npm:@lingui/cli extract --clean --overwrite --workers 1", + "i18n:compile": "deno run -A npm:@lingui/cli compile --workers 1", + "start": "deno run --env-file -A api/db/init.ts && deno run --env-file -A api/main.ts" }, "nodeModulesDir": "auto", "compilerOptions": { diff --git a/scripts/lingui-compile.ts b/scripts/lingui-compile.ts deleted file mode 100644 index f99f6f9..0000000 --- a/scripts/lingui-compile.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { command as compile } from "../node_modules/@lingui/cli/dist/lingui-compile.js"; -import { getConfig } from "../node_modules/@lingui/conf/dist/index.mjs"; - -const config = getConfig({ cwd: Deno.cwd() }); -await compile(config, { - watch: false, - namespace: undefined, - typescript: false, - allowEmpty: true, - workersOptions: { poolSize: 0 }, -}); diff --git a/scripts/lingui-extract.ts b/scripts/lingui-extract.ts deleted file mode 100644 index 9cb0ccd..0000000 --- a/scripts/lingui-extract.ts +++ /dev/null @@ -1,11 +0,0 @@ -import extract from "../node_modules/@lingui/cli/dist/lingui-extract.js"; -import { getConfig } from "../node_modules/@lingui/conf/dist/index.mjs"; - -const config = getConfig({ cwd: Deno.cwd() }); -await extract(config, { - verbose: false, - watch: false, - files: [], - clean: true, - workersOptions: { poolSize: 0 }, -}); diff --git a/src/App.css b/src/App.css index 16827a9..41a67ac 100644 --- a/src/App.css +++ b/src/App.css @@ -256,6 +256,34 @@ opacity: 0.6; } +/* Label paired with an inline action (e.g. a "reset to default" button). */ +.form-label-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; +} + +.form-label-action { + background: none; + border: none; + padding: 0; + cursor: pointer; + font-size: 0.8rem; + font-weight: 600; + color: var(--color-text); + opacity: 0.6; +} + +.form-label-action:hover:not(:disabled) { + opacity: 1; +} + +.form-label-action:disabled { + opacity: 0.3; + cursor: default; +} + .form input, .form textarea { width: 100%; @@ -654,8 +682,11 @@ border-radius: 0 0 12px 12px; } -.audio-file-preview--active .waveform-bar { - fill: var(--color-accent); +/* When active, brighten only the *un-played* bars. Excluding played bars keeps + this rule from out-specifying `.waveform-bar--played` (which would paint every + bar the played colour and make the waveform look stuck at 100%). */ +.audio-file-preview--active .waveform-bar:not(.waveform-bar--played) { + fill: color-mix(in srgb, var(--color-accent) 45%, var(--color-border) 55%); } .audio-file-preview--active .audio-player-btn { diff --git a/src/components/DumpCard.tsx b/src/components/DumpCard.tsx index 4fe6809..a69d3b1 100644 --- a/src/components/DumpCard.tsx +++ b/src/components/DumpCard.tsx @@ -1,9 +1,9 @@ import { Link, useNavigate } from "react-router"; import { Plural, Trans } from "@lingui/react/macro"; import type { Dump } from "../model.ts"; -import { API_URL } from "../config/api.ts"; import { relativeTime } from "../utils/relativeTime.ts"; -import { dumpUrl } from "../utils/urls.ts"; +import { dumpThumbnailUrl, dumpUrl } from "../utils/urls.ts"; +import { useAuth } from "../hooks/useAuth.ts"; import { isDumpVisited, isRecent, markDumpVisited } from "../utils/visited.ts"; import FilePreview from "./FilePreview.tsx"; import RichContentCard from "./RichContentCard.tsx"; @@ -27,6 +27,7 @@ export function DumpCard( DumpCardProps, ) { const navigate = useNavigate(); + const { token } = useAuth(); const unread = !isOwner && isRecent(dump.createdAt) && !isDumpVisited(dump.id); @@ -54,7 +55,7 @@ export function DumpCard( richContent={dump.richContent} compact thumbnailOverrideUrl={dump.thumbnailMime - ? `${API_URL}/api/thumbnails/${dump.id}` + ? dumpThumbnailUrl(dump, token) : undefined} /> ) diff --git a/src/components/FilePreview.tsx b/src/components/FilePreview.tsx index 792001d..dea83fb 100644 --- a/src/components/FilePreview.tsx +++ b/src/components/FilePreview.tsx @@ -1,7 +1,8 @@ import { useContext, useEffect, useState } from "react"; -import { API_URL } from "../config/api.ts"; import type { Dump } from "../model.ts"; import { formatBytes } from "../utils/format.ts"; +import { dumpFileUrl, dumpThumbnailUrl } from "../utils/urls.ts"; +import { useAuth } from "../hooks/useAuth.ts"; import { IconPause, IconPlay, MediaPlayer } from "./MediaPlayer.tsx"; import { PlayerContext } from "../contexts/PlayerContext.ts"; import { @@ -145,11 +146,12 @@ export default function FilePreview( { dump, compact = false, global: useGlobal = false }: FilePreviewProps, ) { const { current, playing, play, togglePlay } = useContext(PlayerContext); - const fileUrl = `${API_URL}/api/files/${dump.id}?v=${dump.fileSize ?? 0}`; + const { token } = useAuth(); + const fileUrl = dumpFileUrl(dump, token); const mime = dump.fileMime ?? ""; const isPlaying = current?.kind === "file" && current.fileUrl === fileUrl; const thumbOverride = dump.thumbnailMime - ? `${API_URL}/api/thumbnails/${dump.id}` + ? dumpThumbnailUrl(dump, token) : null; if (compact) { @@ -166,7 +168,7 @@ export default function FilePreview( ); } if (mime.startsWith("video/")) { - const thumbUrl = `${API_URL}/api/thumbnails/${dump.id}`; + const thumbUrl = dumpThumbnailUrl(dump, token); return ( + ) + : undefined} maxLength={VALIDATION.DUMP_TITLE_MAX} rules={{ required: true }} /> diff --git a/src/themes/brutalist.css b/src/themes/brutalist.css index 603576f..db3feed 100644 --- a/src/themes/brutalist.css +++ b/src/themes/brutalist.css @@ -177,7 +177,8 @@ [data-style="brutalist"] .user-menu-trigger, [data-style="brutalist"] .nav-search-btn, [data-style="brutalist"] .auth-link-btn, -[data-style="brutalist"] .form-cancel { +[data-style="brutalist"] .form-cancel, +[data-style="brutalist"] .form-label-action { border: none; box-shadow: none; } @@ -189,7 +190,9 @@ [data-style="brutalist"] .auth-link-btn:hover:not(:disabled), [data-style="brutalist"] .auth-link-btn:active, [data-style="brutalist"] .form-cancel:hover:not(:disabled), -[data-style="brutalist"] .form-cancel:active { +[data-style="brutalist"] .form-cancel:active, +[data-style="brutalist"] .form-label-action:hover:not(:disabled), +[data-style="brutalist"] .form-label-action:active { transform: none; box-shadow: none; } diff --git a/src/utils/urls.ts b/src/utils/urls.ts index 6c4d395..7bd06c7 100644 --- a/src/utils/urls.ts +++ b/src/utils/urls.ts @@ -1,3 +1,28 @@ +import { API_URL } from "../config/api.ts"; + +/** + * Direct URL to a dump's uploaded file. The viewer's token is passed as a query + * param — media elements (`