From 4687a6b515a4453bf7254c2b3edb91c4efe1db1d Mon Sep 17 00:00:00 2001 From: khannurien Date: Sun, 21 Jun 2026 20:30:03 +0000 Subject: [PATCH] v3: add backlinks between dumps (via either dump url or dump target url mentioned through dump description and dump comments) --- api/db/migrate.ts | 2 + api/db/migrations/0003_dump_backlinks.ts | 26 +++++ api/db/schema.sql | 14 +++ api/routes/dumps.ts | 9 ++ api/services/backlink-service.ts | 116 +++++++++++++++++++++++ api/services/comment-service.ts | 4 + api/services/dump-service.ts | 6 ++ src/App.css | 15 +++ src/pages/Dump.tsx | 42 ++++++++ 9 files changed, 234 insertions(+) create mode 100644 api/db/migrations/0003_dump_backlinks.ts create mode 100644 api/services/backlink-service.ts diff --git a/api/db/migrate.ts b/api/db/migrate.ts index e701616..3cd35ba 100644 --- a/api/db/migrate.ts +++ b/api/db/migrate.ts @@ -1,6 +1,7 @@ import type { DatabaseSync } from "node:sqlite"; import { up as up0001CommentLikes } from "./migrations/0001_comment_likes.ts"; import { up as up0002DumpThumbnail } from "./migrations/0002_dump_thumbnail.ts"; +import { up as up0003DumpBacklinks } from "./migrations/0003_dump_backlinks.ts"; interface Migration { name: string; @@ -13,6 +14,7 @@ interface Migration { const MIGRATIONS: Migration[] = [ { name: "0001_comment_likes", up: up0001CommentLikes }, { name: "0002_dump_thumbnail", up: up0002DumpThumbnail }, + { name: "0003_dump_backlinks", up: up0003DumpBacklinks }, ]; export function runMigrations(db: DatabaseSync): void { diff --git a/api/db/migrations/0003_dump_backlinks.ts b/api/db/migrations/0003_dump_backlinks.ts new file mode 100644 index 0000000..affa848 --- /dev/null +++ b/api/db/migrations/0003_dump_backlinks.ts @@ -0,0 +1,26 @@ +import type { DatabaseSync } from "node:sqlite"; + +// Idempotent: safe to run against a fresh db (already created from schema.sql +// with these) or an existing one that predates them. +export function up(db: DatabaseSync): void { + db.exec(`CREATE TABLE IF NOT EXISTS dump_backlinks ( + source_type TEXT NOT NULL, + source_id TEXT NOT NULL, + from_dump_id TEXT NOT NULL, + to_dump_id TEXT NOT NULL, + created_at TEXT NOT NULL, + PRIMARY KEY (source_type, source_id, to_dump_id), + FOREIGN KEY (from_dump_id) REFERENCES dumps(id) ON DELETE CASCADE, + FOREIGN KEY (to_dump_id) REFERENCES dumps(id) ON DELETE CASCADE + );`); + + db.exec( + `CREATE INDEX IF NOT EXISTS idx_dump_backlinks_to ON dump_backlinks(to_dump_id);`, + ); + db.exec( + `CREATE INDEX IF NOT EXISTS idx_dump_backlinks_from ON dump_backlinks(from_dump_id);`, + ); + db.exec( + `CREATE INDEX IF NOT EXISTS idx_dumps_url ON dumps(url);`, + ); +} diff --git a/api/db/schema.sql b/api/db/schema.sql index 8d07061..cfe6896 100644 --- a/api/db/schema.sql +++ b/api/db/schema.sql @@ -87,13 +87,27 @@ CREATE TABLE comment_likes ( FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ); +CREATE TABLE dump_backlinks ( + source_type TEXT NOT NULL, + source_id TEXT NOT NULL, + from_dump_id TEXT NOT NULL, + to_dump_id TEXT NOT NULL, + created_at TEXT NOT NULL, + PRIMARY KEY (source_type, source_id, to_dump_id), + FOREIGN KEY (from_dump_id) REFERENCES dumps(id) ON DELETE CASCADE, + FOREIGN KEY (to_dump_id) REFERENCES dumps(id) ON DELETE CASCADE +); + CREATE INDEX idx_dumps_user ON dumps(user_id); +CREATE INDEX idx_dumps_url ON dumps(url); CREATE INDEX idx_votes_user ON votes(user_id); CREATE INDEX idx_playlists_user ON playlists(user_id); CREATE INDEX idx_playlist_dumps_order ON playlist_dumps(playlist_id, position); CREATE INDEX idx_playlist_dumps_dump ON playlist_dumps(dump_id); CREATE INDEX idx_comments_dump ON comments(dump_id, created_at); CREATE INDEX idx_comment_likes_user ON comment_likes(user_id); +CREATE INDEX idx_dump_backlinks_to ON dump_backlinks(to_dump_id); +CREATE INDEX idx_dump_backlinks_from ON dump_backlinks(from_dump_id); CREATE TABLE follows ( id TEXT PRIMARY KEY, diff --git a/api/routes/dumps.ts b/api/routes/dumps.ts index 8238859..264d041 100644 --- a/api/routes/dumps.ts +++ b/api/routes/dumps.ts @@ -28,6 +28,7 @@ import { updateDump, } from "../services/dump-service.ts"; import { getDumpVoters } from "../services/vote-service.ts"; +import { getRelatedDumps } from "../services/backlink-service.ts"; const router = new Router({ prefix: "/api/dumps" }); @@ -104,6 +105,14 @@ router.get("/:dumpId/voters", async (ctx) => { }; }); +router.get("/:dumpId/related", async (ctx) => { + const requestingUserId = await parseOptionalAuth(ctx) ?? undefined; + const dump = getDump(ctx.params.dumpId, requestingUserId); + const related = getRelatedDumps(dump.id, requestingUserId); + const responseBody: APIResponse = { success: true, data: related }; + ctx.response.body = responseBody; +}); + router.get("/", async (ctx) => { const requestingUserId = await parseOptionalAuth(ctx) ?? undefined; const { page, limit } = parsePagination(ctx.request.url.searchParams); diff --git a/api/services/backlink-service.ts b/api/services/backlink-service.ts new file mode 100644 index 0000000..9c63c20 --- /dev/null +++ b/api/services/backlink-service.ts @@ -0,0 +1,116 @@ +import { type Dump } from "../model/interfaces.ts"; +import { db, dumpRowToApi, isDumpRow } from "../model/db.ts"; +import { UUID_RE } from "../lib/slugify.ts"; + +// Mirrors dump-service SELECT_COLS — kept local to avoid a circular import +// (dump-service.ts must import relinkBacklinks from this file). +const SELECT_COLS = + "id, kind, title, slug, comment, user_id, created_at, updated_at, url, rich_content, file_name, file_mime, file_size, vote_count, is_private, custom_thumbnail_mime," + + " (SELECT COUNT(*) FROM comments WHERE dump_id = dumps.id AND deleted = 0) as comment_count"; + +// Matches http(s) URLs in free text — stops at whitespace or markdown-link +// delimiters (so `[text](url)` and `` don't swallow the closing char). +const URL_RE = /https?:\/\/[^\s)\]<>"']+/g; + +// A link to another dump's page on this same site (e.g. pasted from the +// address bar) — host-agnostic, since the configured public URL can differ +// between dev/prod/reverse-proxy setups. Matches `/dumps/{slug-or-id}`, +// ignoring any trailing path/query/hash (so `/edit` or `#comment-x` links +// still resolve to the same target dump). +const DUMP_PATH_RE = /^\/dumps\/([^/?#]+)/; + +function canonicalize(url: string): string | null { + try { + return new URL(url).toString(); + } catch { + return null; + } +} + +/** Resolves a same-site `/dumps/{slug-or-id}` path segment to a dump id. */ +function resolveDumpPathId(idOrSlug: string): string | null { + const row = UUID_RE.test(idOrSlug) + ? db.prepare(`SELECT id FROM dumps WHERE id = ?;`).get(idOrSlug) + : db.prepare(`SELECT id FROM dumps WHERE slug = ?;`).get(idOrSlug); + return (row as { id: string } | undefined)?.id ?? null; +} + +/** + * Re-derive the backlinks contributed by one text source (a dump's own + * `comment` field, or a single comment's `body`) and persist them. Always + * deletes the source's previous contribution first, so editing/clearing the + * text correctly removes stale links — unlike `linkAttachments`/`notifyMentions`, + * which are purely additive. + */ +export function relinkBacklinks( + sourceType: "dump_comment" | "comment", + sourceId: string, + fromDumpId: string, + text: string, +): void { + const targetIds = new Set(); + const externalUrls = new Set(); + + for (const match of text.matchAll(URL_RE)) { + const canonical = canonicalize(match[0]); + if (!canonical) continue; + let parsed: URL; + try { + parsed = new URL(canonical); + } catch { + continue; + } + const pathMatch = DUMP_PATH_RE.exec(parsed.pathname); + if (pathMatch) { + const resolved = resolveDumpPathId(decodeURIComponent(pathMatch[1])); + if (resolved) targetIds.add(resolved); + } else { + externalUrls.add(canonical); + } + } + + if (externalUrls.size > 0) { + const placeholders = [...externalUrls].map(() => "?").join(", "); + const rows = db.prepare( + `SELECT id FROM dumps WHERE url IN (${placeholders});`, + ).all(...externalUrls) as { id: string }[]; + for (const row of rows) targetIds.add(row.id); + } + + targetIds.delete(fromDumpId); // no self-links + + db.exec("BEGIN;"); + try { + db.prepare( + `DELETE FROM dump_backlinks WHERE source_type = ? AND source_id = ?;`, + ).run(sourceType, sourceId); + const createdAt = new Date().toISOString(); + for (const toDumpId of targetIds) { + db.prepare( + `INSERT INTO dump_backlinks (source_type, source_id, from_dump_id, to_dump_id, created_at) + VALUES (?, ?, ?, ?, ?);`, + ).run(sourceType, sourceId, fromDumpId, toDumpId, createdAt); + } + db.exec("COMMIT;"); + } catch (err) { + db.exec("ROLLBACK;"); + throw err; + } +} + +/** Bidirectional, privacy-filtered, capped at 20 — backlinks are a small, bounded list. */ +export function getRelatedDumps( + dumpId: string, + requestingUserId?: string, +): Dump[] { + const rows = db.prepare( + `SELECT ${SELECT_COLS} FROM dumps WHERE id IN ( + SELECT to_dump_id FROM dump_backlinks WHERE from_dump_id = ? + UNION + SELECT from_dump_id FROM dump_backlinks WHERE to_dump_id = ? + ) AND (is_private = 0 OR user_id = ?) + ORDER BY created_at DESC LIMIT 20;`, + ).all(dumpId, dumpId, requestingUserId ?? ""); + + return rows.filter(isDumpRow).map(dumpRowToApi); +} diff --git a/api/services/comment-service.ts b/api/services/comment-service.ts index 998f1bd..ef75d74 100644 --- a/api/services/comment-service.ts +++ b/api/services/comment-service.ts @@ -10,6 +10,7 @@ import { notifyMentions, } from "./notification-service.ts"; import { linkAttachments } from "./attachment-service.ts"; +import { relinkBacklinks } from "./backlink-service.ts"; const SELECT_COLS = `c.id, c.dump_id, c.user_id, c.parent_id, c.body, c.created_at, c.updated_at, c.deleted, c.like_count, @@ -82,6 +83,7 @@ export function createComment( notifyDumpOwnerNewComment(userId, id, dumpId); notifyMentions(userId, body, "comment", id, dumpRow?.title ?? "", dumpId); linkAttachments(body, id); + relinkBacklinks("comment", id, dumpId, body); return comment; } @@ -130,6 +132,7 @@ export function updateComment( row.dump_id, ); linkAttachments(body, commentId); + relinkBacklinks("comment", commentId, row.dump_id, body); return { comment: fetchComment(commentId), dumpId: row.dump_id, @@ -159,5 +162,6 @@ export function deleteComment( db.prepare(`UPDATE comments SET deleted = 1, body = '' WHERE id = ?;`).run( commentId, ); + relinkBacklinks("comment", commentId, row.dump_id, ""); return { dumpId: row.dump_id, isPrivate: Boolean(row.is_private) }; } diff --git a/api/services/dump-service.ts b/api/services/dump-service.ts index 3b6a58c..4b94c2c 100644 --- a/api/services/dump-service.ts +++ b/api/services/dump-service.ts @@ -25,6 +25,7 @@ import { THUMBNAILS_DIR, } from "../config.ts"; import { linkAttachments } from "./attachment-service.ts"; +import { relinkBacklinks } from "./backlink-service.ts"; function isAllowedMime(mime: string): boolean { return DUMP_ALLOWED_MIME_PREFIXES.some((p) => mime.startsWith(p)) || @@ -102,6 +103,7 @@ export async function createUrlDump( notifyMentions(userId, request.comment, "dump", dumpId, title); linkAttachments(request.comment, dumpId); } + relinkBacklinks("dump_comment", dumpId, dumpId, request.comment ?? ""); return dump; } @@ -183,6 +185,7 @@ export async function createFileDump( notifyMentions(userId, comment, "dump", dumpId, finalTitle); linkAttachments(comment, dumpId); } + relinkBacklinks("dump_comment", dumpId, dumpId, comment ?? ""); return dump; } @@ -326,6 +329,7 @@ export async function updateDump( ); linkAttachments(updatedDump.comment, dumpId); } + relinkBacklinks("dump_comment", dumpId, dumpId, updatedDump.comment ?? ""); return updatedDump; } @@ -393,6 +397,7 @@ export async function updateDump( ); linkAttachments(updatedDump.comment, dumpId); } + relinkBacklinks("dump_comment", dumpId, dumpId, updatedDump.comment ?? ""); return updatedDump; } @@ -457,6 +462,7 @@ export async function replaceFileDump( notifyMentions(dump.userId, comment, "dump", dumpId, finalTitle); linkAttachments(comment, dumpId); } + relinkBacklinks("dump_comment", dumpId, dumpId, comment ?? ""); return { ...dump, title: finalTitle, diff --git a/src/App.css b/src/App.css index 1b52107..6cca193 100644 --- a/src/App.css +++ b/src/App.css @@ -3509,6 +3509,21 @@ body.has-player .fab-new { letter-spacing: 0.08em; } +.related-section { + margin-top: 2.5rem; + padding-top: 1.75rem; + border-top: 2px solid var(--color-border); +} + +.related-section-title { + font-size: 0.8rem; + font-weight: 700; + color: var(--color-text-muted); + margin: 0 0 1.25rem; + text-transform: uppercase; + letter-spacing: 0.08em; +} + .comment-list { list-style: none; margin: 0; diff --git a/src/pages/Dump.tsx b/src/pages/Dump.tsx index 8a69af4..aca8c17 100644 --- a/src/pages/Dump.tsx +++ b/src/pages/Dump.tsx @@ -29,6 +29,7 @@ import { Avatar } from "../components/Avatar.tsx"; import RichContentCard from "../components/RichContentCard.tsx"; import FilePreview from "../components/FilePreview.tsx"; import { VoteButton } from "../components/VoteButton.tsx"; +import { DumpCard } from "../components/DumpCard.tsx"; import { PageShell } from "../components/PageShell.tsx"; import { PageError } from "../components/PageError.tsx"; import { Markdown } from "../components/Markdown.tsx"; @@ -54,6 +55,7 @@ export function Dump() { const [playlistModalOpen, setPlaylistModalOpen] = useState(false); const [comments, setComments] = useState([]); + const [relatedDumps, setRelatedDumps] = useState([]); const [titleEditing, setTitleEditing] = useState(false); const [titleDraft, setTitleDraft] = useState(""); @@ -145,6 +147,24 @@ export function Dump() { return () => controller.abort(); }, [selectedDump, token]); + // Fetch related dumps (backlinks) when dump loads + useEffect(() => { + if (!selectedDump) return; + const controller = new AbortController(); + fetch(`${API_URL}/api/dumps/${selectedDump}/related`, { + signal: controller.signal, + headers: token ? { Authorization: `Bearer ${token}` } : {}, + }) + .then((r) => r.json()) + .then((body) => { + if (body.success) { + setRelatedDumps((body.data as RawDump[]).map(deserializeDump)); + } + }) + .catch(() => {}); + return () => controller.abort(); + }, [selectedDump, token]); + // Scroll to and highlight a comment when navigating to #comment-{id} useEffect(() => { if (!location.hash.startsWith("#comment-")) return; @@ -440,6 +460,28 @@ export function Dump() { + {/* Related (backlinks) */} + {relatedDumps.length > 0 && ( +
+

+ Related +

+
    + {relatedDumps.map((related) => ( + + ))} +
+
+ )} + {/* Comments */}