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

@@ -1,6 +1,7 @@
import type { DatabaseSync } from "node:sqlite"; import type { DatabaseSync } from "node:sqlite";
import { up as up0001CommentLikes } from "./migrations/0001_comment_likes.ts"; import { up as up0001CommentLikes } from "./migrations/0001_comment_likes.ts";
import { up as up0002DumpThumbnail } from "./migrations/0002_dump_thumbnail.ts"; import { up as up0002DumpThumbnail } from "./migrations/0002_dump_thumbnail.ts";
import { up as up0003DumpBacklinks } from "./migrations/0003_dump_backlinks.ts";
interface Migration { interface Migration {
name: string; name: string;
@@ -13,6 +14,7 @@ interface Migration {
const MIGRATIONS: Migration[] = [ const MIGRATIONS: Migration[] = [
{ name: "0001_comment_likes", up: up0001CommentLikes }, { name: "0001_comment_likes", up: up0001CommentLikes },
{ name: "0002_dump_thumbnail", up: up0002DumpThumbnail }, { name: "0002_dump_thumbnail", up: up0002DumpThumbnail },
{ name: "0003_dump_backlinks", up: up0003DumpBacklinks },
]; ];
export function runMigrations(db: DatabaseSync): void { export function runMigrations(db: DatabaseSync): void {

View File

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

View File

@@ -87,13 +87,27 @@ CREATE TABLE comment_likes (
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE 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_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_votes_user ON votes(user_id);
CREATE INDEX idx_playlists_user ON playlists(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_order ON playlist_dumps(playlist_id, position);
CREATE INDEX idx_playlist_dumps_dump ON playlist_dumps(dump_id); 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_comments_dump ON comments(dump_id, created_at);
CREATE INDEX idx_comment_likes_user ON comment_likes(user_id); 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 ( CREATE TABLE follows (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,

View File

@@ -28,6 +28,7 @@ import {
updateDump, updateDump,
} from "../services/dump-service.ts"; } from "../services/dump-service.ts";
import { getDumpVoters } from "../services/vote-service.ts"; import { getDumpVoters } from "../services/vote-service.ts";
import { getRelatedDumps } from "../services/backlink-service.ts";
const router = new Router({ prefix: "/api/dumps" }); 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<Dump[]> = { success: true, data: related };
ctx.response.body = responseBody;
});
router.get("/", async (ctx) => { router.get("/", async (ctx) => {
const requestingUserId = await parseOptionalAuth(ctx) ?? undefined; const requestingUserId = await parseOptionalAuth(ctx) ?? undefined;
const { page, limit } = parsePagination(ctx.request.url.searchParams); const { page, limit } = parsePagination(ctx.request.url.searchParams);

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, notifyMentions,
} from "./notification-service.ts"; } from "./notification-service.ts";
import { linkAttachments } from "./attachment-service.ts"; import { linkAttachments } from "./attachment-service.ts";
import { relinkBacklinks } from "./backlink-service.ts";
const SELECT_COLS = 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, `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); notifyDumpOwnerNewComment(userId, id, dumpId);
notifyMentions(userId, body, "comment", id, dumpRow?.title ?? "", dumpId); notifyMentions(userId, body, "comment", id, dumpRow?.title ?? "", dumpId);
linkAttachments(body, id); linkAttachments(body, id);
relinkBacklinks("comment", id, dumpId, body);
return comment; return comment;
} }
@@ -130,6 +132,7 @@ export function updateComment(
row.dump_id, row.dump_id,
); );
linkAttachments(body, commentId); linkAttachments(body, commentId);
relinkBacklinks("comment", commentId, row.dump_id, body);
return { return {
comment: fetchComment(commentId), comment: fetchComment(commentId),
dumpId: row.dump_id, dumpId: row.dump_id,
@@ -159,5 +162,6 @@ export function deleteComment(
db.prepare(`UPDATE comments SET deleted = 1, body = '' WHERE id = ?;`).run( db.prepare(`UPDATE comments SET deleted = 1, body = '' WHERE id = ?;`).run(
commentId, commentId,
); );
relinkBacklinks("comment", commentId, row.dump_id, "");
return { dumpId: row.dump_id, isPrivate: Boolean(row.is_private) }; return { dumpId: row.dump_id, isPrivate: Boolean(row.is_private) };
} }

View File

@@ -25,6 +25,7 @@ import {
THUMBNAILS_DIR, THUMBNAILS_DIR,
} from "../config.ts"; } from "../config.ts";
import { linkAttachments } from "./attachment-service.ts"; import { linkAttachments } from "./attachment-service.ts";
import { relinkBacklinks } from "./backlink-service.ts";
function isAllowedMime(mime: string): boolean { function isAllowedMime(mime: string): boolean {
return DUMP_ALLOWED_MIME_PREFIXES.some((p) => mime.startsWith(p)) || 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); notifyMentions(userId, request.comment, "dump", dumpId, title);
linkAttachments(request.comment, dumpId); linkAttachments(request.comment, dumpId);
} }
relinkBacklinks("dump_comment", dumpId, dumpId, request.comment ?? "");
return dump; return dump;
} }
@@ -183,6 +185,7 @@ export async function createFileDump(
notifyMentions(userId, comment, "dump", dumpId, finalTitle); notifyMentions(userId, comment, "dump", dumpId, finalTitle);
linkAttachments(comment, dumpId); linkAttachments(comment, dumpId);
} }
relinkBacklinks("dump_comment", dumpId, dumpId, comment ?? "");
return dump; return dump;
} }
@@ -326,6 +329,7 @@ export async function updateDump(
); );
linkAttachments(updatedDump.comment, dumpId); linkAttachments(updatedDump.comment, dumpId);
} }
relinkBacklinks("dump_comment", dumpId, dumpId, updatedDump.comment ?? "");
return updatedDump; return updatedDump;
} }
@@ -393,6 +397,7 @@ export async function updateDump(
); );
linkAttachments(updatedDump.comment, dumpId); linkAttachments(updatedDump.comment, dumpId);
} }
relinkBacklinks("dump_comment", dumpId, dumpId, updatedDump.comment ?? "");
return updatedDump; return updatedDump;
} }
@@ -457,6 +462,7 @@ export async function replaceFileDump(
notifyMentions(dump.userId, comment, "dump", dumpId, finalTitle); notifyMentions(dump.userId, comment, "dump", dumpId, finalTitle);
linkAttachments(comment, dumpId); linkAttachments(comment, dumpId);
} }
relinkBacklinks("dump_comment", dumpId, dumpId, comment ?? "");
return { return {
...dump, ...dump,
title: finalTitle, title: finalTitle,

View File

@@ -3509,6 +3509,21 @@ body.has-player .fab-new {
letter-spacing: 0.08em; 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 { .comment-list {
list-style: none; list-style: none;
margin: 0; margin: 0;

View File

@@ -29,6 +29,7 @@ import { Avatar } from "../components/Avatar.tsx";
import RichContentCard from "../components/RichContentCard.tsx"; import RichContentCard from "../components/RichContentCard.tsx";
import FilePreview from "../components/FilePreview.tsx"; import FilePreview from "../components/FilePreview.tsx";
import { VoteButton } from "../components/VoteButton.tsx"; import { VoteButton } from "../components/VoteButton.tsx";
import { DumpCard } from "../components/DumpCard.tsx";
import { PageShell } from "../components/PageShell.tsx"; import { PageShell } from "../components/PageShell.tsx";
import { PageError } from "../components/PageError.tsx"; import { PageError } from "../components/PageError.tsx";
import { Markdown } from "../components/Markdown.tsx"; import { Markdown } from "../components/Markdown.tsx";
@@ -54,6 +55,7 @@ export function Dump() {
const [playlistModalOpen, setPlaylistModalOpen] = useState(false); const [playlistModalOpen, setPlaylistModalOpen] = useState(false);
const [comments, setComments] = useState<Comment[]>([]); const [comments, setComments] = useState<Comment[]>([]);
const [relatedDumps, setRelatedDumps] = useState<Dump[]>([]);
const [titleEditing, setTitleEditing] = useState(false); const [titleEditing, setTitleEditing] = useState(false);
const [titleDraft, setTitleDraft] = useState(""); const [titleDraft, setTitleDraft] = useState("");
@@ -145,6 +147,24 @@ export function Dump() {
return () => controller.abort(); return () => controller.abort();
}, [selectedDump, token]); }, [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} // Scroll to and highlight a comment when navigating to #comment-{id}
useEffect(() => { useEffect(() => {
if (!location.hash.startsWith("#comment-")) return; if (!location.hash.startsWith("#comment-")) return;
@@ -440,6 +460,28 @@ export function Dump() {
</Link> </Link>
</div> </div>
{/* Related (backlinks) */}
{relatedDumps.length > 0 && (
<section className="related-section">
<h2 className="related-section-title">
<Trans>Related</Trans>
</h2>
<ul className="dump-feed">
{relatedDumps.map((related) => (
<DumpCard
key={related.id}
dump={related}
voteCount={voteCounts[related.id] ?? related.voteCount}
voted={myVotes.has(related.id)}
canVote={!!user}
castVote={castVote}
removeVote={removeVote}
/>
))}
</ul>
</section>
)}
{/* Comments */} {/* Comments */}
<CommentThread <CommentThread
dumpId={dump.id} dumpId={dump.id}