v3: add backlinks between dumps (via either dump url or dump target url mentioned through dump description and dump comments)
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 43s

This commit is contained in:
khannurien
2026-06-21 20:30:03 +00:00
parent d038116de5
commit 4687a6b515
9 changed files with 234 additions and 0 deletions

View File

@@ -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 `<url>` 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<string>();
const externalUrls = new Set<string>();
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);
}

View File

@@ -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) };
}

View File

@@ -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,