All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 42s
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
73 lines
2.1 KiB
TypeScript
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;
|