All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 42s
116 lines
3.8 KiB
TypeScript
116 lines
3.8 KiB
TypeScript
import { type Dump } from "../model/interfaces.ts";
|
|
import {
|
|
db,
|
|
DUMP_SELECT_COLUMNS as SELECT_COLS,
|
|
dumpRowToApi,
|
|
isDumpRow,
|
|
} from "../model/db.ts";
|
|
import { UUID_RE } from "../lib/slugify.ts";
|
|
|
|
// 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);
|
|
}
|