Files
gerbeur/api/routes/preview.ts
khannurien fb8364e24d
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 42s
v3: reworked posting — three panels, drafts, duplicate detection, upload progress
Posting a dump was a single form where the important choices were the easiest
to miss. It is now three panels: link or file, why & where, playlists.

Composition:
- No more URL/File toggle. An empty panel offers both ways in at once and the
  kind follows what you actually did; a file dropped anywhere in the modal is
  accepted, not just on the zone.
- Categories and visibility get their own panel instead of a disclosure that
  read as optional, and the primary button stays "Next" until they've been
  seen. Visibility carries a real label now.
- The draft (link, title, why, categories, visibility) is mirrored to
  localStorage on every change and restored on reopen, so Escape or a stray
  backdrop click costs nothing. Only an attached file can't be restored, so
  that is the one case that asks before closing.
- URL dumps can carry a poster-supplied title instead of being stuck with
  whatever the page scraped, editable right under the preview.
- Multipart uploads go through XHR so there is a real progress bar and a
  percentage on the button, rather than 50 MB of silence.

Duplicates:
- New dumps.url_canonical column (+ index, backfilled by 0013) holding a lossy
  key that ignores scheme, www., trailing slashes, tracking parameters and
  YouTube share shapes. GET /api/dumps/by-url reads it, and the create form
  warns "already dumped by X" while it fetches the preview. Never blocking.

Fixes:
- /api/preview now reports whether the page was actually reached: a failed
  fetch still yields a hostname-only stub, so a dead link and a page without
  metadata used to render identically.
- The Web Share Target never worked. The manifest posts to "/", but the index
  redirect dropped the query string, so every Android share landed on the feed
  with nothing pre-filled.
- File dumps no longer take the extension into their title.
- The link field no longer autofocuses on touch, where it raised a keyboard
  over the modal.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TiAPtJZeCYYk8rKehtLUQU
2026-09-08 14:40:37 +00:00

73 lines
2.1 KiB
TypeScript

import { Router } from "@oak/oak";
import {
fetchWithTimeout,
isValidHttpUrl,
tryFetchRichContent,
} from "../services/rich-content-service.ts";
import { APIErrorCode } from "../model/interfaces.ts";
const previewRouter = new Router();
previewRouter.get("/api/preview", async (ctx) => {
const url = ctx.request.url.searchParams.get("url") ?? "";
if (!isValidHttpUrl(url)) {
ctx.response.status = 400;
ctx.response.body = {
success: false,
error: { code: APIErrorCode.VALIDATION_ERROR, message: "Invalid URL" },
};
return;
}
// `reached` is reported separately because a failed fetch still yields a
// usable stub (hostname only). Without the flag the create form cannot tell
// "this page has no preview" from "this link is dead", and shows the same
// bare card for both.
const { ok, content } = await tryFetchRichContent(url);
ctx.response.body = {
success: true,
data: { reached: ok, richContent: content ?? null },
};
});
/**
* Proxy an external image through the server so HTTP thumbnail URLs don't
* trigger mixed-content blocks when the frontend is served over HTTPS.
*/
previewRouter.get("/api/proxy-image", async (ctx) => {
const url = ctx.request.url.searchParams.get("url") ?? "";
if (!isValidHttpUrl(url)) {
ctx.response.status = 400;
return;
}
try {
const res = await fetchWithTimeout(url, 8000);
const contentType = res.headers.get("content-type") ?? "";
if (!contentType.startsWith("image/")) {
ctx.response.status = 400;
return;
}
const MAX_SIZE = 5 * 1024 * 1024; // 5 MB
const contentLength = Number(res.headers.get("content-length") ?? "0");
if (contentLength > MAX_SIZE) {
ctx.response.status = 400;
return;
}
const bytes = new Uint8Array(await res.arrayBuffer());
if (bytes.length > MAX_SIZE) {
ctx.response.status = 400;
return;
}
ctx.response.headers.set("Content-Type", contentType);
ctx.response.headers.set("Cache-Control", "public, max-age=86400");
ctx.response.body = bytes;
} catch {
ctx.response.status = 502;
}
});
export default previewRouter;