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
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 43s
This commit is contained in:
116
api/services/backlink-service.ts
Normal file
116
api/services/backlink-service.ts
Normal 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);
|
||||
}
|
||||
@@ -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) };
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user