Files
gerbeur/api/services/backlink-service.ts

177 lines
6.4 KiB
TypeScript

/**
* Records which dumps a dump's note and comments link to, and finds the dumps
* related to one through those links. The dump and comment services keep the
* links up to date, and the dump page lists the related dumps, which it
* fetches again when `dump_related_changed` says they changed.
*
* @module
*/
import type { Dump } from "../../shared/dump.ts";
import { db, transaction } from "../db/connection.ts";
import {
DUMP_SELECT_COLUMNS as SELECT_COLS,
dumpRowToApi,
isDumpRow,
} from "../model/dump.ts";
import { parsePagePath } from "../../shared/paths.ts";
import { canonicalizeUrl } from "../lib/canonical-url.ts";
import { parseRows } from "../model/rows.ts";
import { broadcastDumpRelatedChanged } from "./ws-service.ts";
/**
* Matches an http(s) URL in Markdown. It stops at the delimiters of
* `[text](url)` and `<url>`, which would otherwise end up in the URL.
*/
const URL_RE = /https?:\/\/[^\s)\]<>"']+/g;
/**
* Returns the id of the dump whose page, or its editor, `pathname` is, by its
* owner's name and its slug, current or former; null when it is no dump's.
* The host isn't checked, since the site's public URL differs between setups;
* a query or a fragment (`#comment-x`) isn't in a pathname anyway.
*/
function resolveDumpPathId(pathname: string): string | null {
const page = parsePagePath(pathname);
if (page?.kind !== "dump") return null;
const row = db.prepare(
`SELECT d.id FROM dumps d
JOIN users u ON u.id = d.user_id
LEFT JOIN user_aliases ua ON ua.user_id = u.id
WHERE (lower(u.username) = lower(:name) OR lower(ua.username) = lower(:name))
AND (d.slug = :slug OR d.id IN (SELECT dump_id FROM dump_slug_aliases
WHERE user_id = u.id AND slug = :slug))
LIMIT 1;`,
).get({ name: page.username, slug: page.slug }) as
| { id: string }
| undefined;
return row?.id ?? null;
}
/**
* Tells clients that the links between `dumpId` and each of `otherIds`
* changed, as `broadcastDumpRelatedChanged` says, after reading who may see
* each of them. It sends nothing without `otherIds`.
*/
function announceRelatedChange(dumpId: string, otherIds: string[]): void {
if (otherIds.length === 0) return;
const ids = [dumpId, ...otherIds];
const rows = db.prepare(
`SELECT id, user_id, is_private FROM dumps
WHERE id IN (${ids.map(() => "?").join(", ")});`,
).all(...ids) as { id: string; user_id: string; is_private: number }[];
const dumps = rows.map((row) => ({
id: row.id,
userId: row.user_id,
isPrivate: Boolean(row.is_private),
}));
const dump = dumps.find((d) => d.id === dumpId);
if (!dump) return;
broadcastDumpRelatedChanged(dump, dumps.filter((d) => d.id !== dumpId));
}
/**
* Tells clients that dump `dumpId`, which just turned public, is now related
* to the dumps linked to or from it, for the dump service. Its poster saw
* those links already; to everyone else they are new.
*/
export function announceLinksNowPublic(dumpId: string): void {
const rows = db.prepare(
`SELECT to_dump_id AS id FROM dump_backlinks WHERE from_dump_id = ?
UNION
SELECT from_dump_id FROM dump_backlinks WHERE to_dump_id = ?;`,
).all(dumpId, dumpId) as { id: string }[];
announceRelatedChange(dumpId, rows.map((row) => row.id));
}
/**
* Replaces the backlinks from one text, a dump's note (`dump_comment`) or one
* of its comments (`comment`), with the links its current `text` holds. A link
* counts when it is a dump's page on this site (`/@owner/dumps/<slug>`), or
* when it is the URL a dump was posted with, compared by `canonicalizeUrl` as
* reposts are. The dump and
* comment services call it on every save, and with an empty `text` when a
* comment is deleted. Clients are told of the dumps it links or no longer
* links, as `dump_related_changed`.
*/
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)) {
let parsed: URL;
try {
parsed = new URL(match[0]);
} catch {
continue;
}
const resolved = resolveDumpPathId(parsed.pathname);
if (resolved) {
targetIds.add(resolved);
} else if (parsePagePath(parsed.pathname)?.kind !== "dump") {
const canonical = canonicalizeUrl(match[0]);
if (canonical) externalUrls.add(canonical);
}
}
if (externalUrls.size > 0) {
const placeholders = [...externalUrls].map(() => "?").join(", ");
const rows = db.prepare(
`SELECT id FROM dumps WHERE url_canonical IN (${placeholders});`,
).all(...externalUrls) as { id: string }[];
for (const row of rows) targetIds.add(row.id);
}
targetIds.delete(fromDumpId);
const formerIds = transaction(() => {
const former = db.prepare(
`SELECT to_dump_id FROM dump_backlinks
WHERE source_type = ? AND source_id = ?;`,
).all(sourceType, sourceId) as { to_dump_id: string }[];
db.prepare(
`DELETE FROM dump_backlinks WHERE source_type = ? AND source_id = ?;`,
).run(sourceType, sourceId);
const createdAt = new Date().toISOString();
const insert = db.prepare(
`INSERT INTO dump_backlinks (source_type, source_id, from_dump_id, to_dump_id, created_at)
VALUES (?, ?, ?, ?, ?);`,
);
for (const toDumpId of targetIds) {
insert.run(sourceType, sourceId, fromDumpId, toDumpId, createdAt);
}
return new Set(former.map((row) => row.to_dump_id));
});
// Most saves change no link, and send nothing.
announceRelatedChange(fromDumpId, [
...targetIds.symmetricDifference(formerIds),
]);
}
/**
* Returns the dumps linked to or from `dumpId`, newest first and at most 20,
* without the private ones the requesting user can't see. It doesn't check
* `dumpId` itself: `GET /api/dumps/:dumpId/related` does that first.
*/
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 parseRows(rows, isDumpRow, dumpRowToApi);
}