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:
@@ -1,6 +1,7 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import { up as up0001CommentLikes } from "./migrations/0001_comment_likes.ts";
|
||||
import { up as up0002DumpThumbnail } from "./migrations/0002_dump_thumbnail.ts";
|
||||
import { up as up0003DumpBacklinks } from "./migrations/0003_dump_backlinks.ts";
|
||||
|
||||
interface Migration {
|
||||
name: string;
|
||||
@@ -13,6 +14,7 @@ interface Migration {
|
||||
const MIGRATIONS: Migration[] = [
|
||||
{ name: "0001_comment_likes", up: up0001CommentLikes },
|
||||
{ name: "0002_dump_thumbnail", up: up0002DumpThumbnail },
|
||||
{ name: "0003_dump_backlinks", up: up0003DumpBacklinks },
|
||||
];
|
||||
|
||||
export function runMigrations(db: DatabaseSync): void {
|
||||
|
||||
26
api/db/migrations/0003_dump_backlinks.ts
Normal file
26
api/db/migrations/0003_dump_backlinks.ts
Normal 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);`,
|
||||
);
|
||||
}
|
||||
@@ -87,13 +87,27 @@ CREATE TABLE comment_likes (
|
||||
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_url ON dumps(url);
|
||||
CREATE INDEX idx_votes_user ON votes(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_dump ON playlist_dumps(dump_id);
|
||||
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_dump_backlinks_to ON dump_backlinks(to_dump_id);
|
||||
CREATE INDEX idx_dump_backlinks_from ON dump_backlinks(from_dump_id);
|
||||
|
||||
CREATE TABLE follows (
|
||||
id TEXT PRIMARY KEY,
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
updateDump,
|
||||
} from "../services/dump-service.ts";
|
||||
import { getDumpVoters } from "../services/vote-service.ts";
|
||||
import { getRelatedDumps } from "../services/backlink-service.ts";
|
||||
|
||||
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) => {
|
||||
const requestingUserId = await parseOptionalAuth(ctx) ?? undefined;
|
||||
const { page, limit } = parsePagination(ctx.request.url.searchParams);
|
||||
|
||||
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,
|
||||
|
||||
15
src/App.css
15
src/App.css
@@ -3509,6 +3509,21 @@ body.has-player .fab-new {
|
||||
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 {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
|
||||
@@ -29,6 +29,7 @@ import { Avatar } from "../components/Avatar.tsx";
|
||||
import RichContentCard from "../components/RichContentCard.tsx";
|
||||
import FilePreview from "../components/FilePreview.tsx";
|
||||
import { VoteButton } from "../components/VoteButton.tsx";
|
||||
import { DumpCard } from "../components/DumpCard.tsx";
|
||||
import { PageShell } from "../components/PageShell.tsx";
|
||||
import { PageError } from "../components/PageError.tsx";
|
||||
import { Markdown } from "../components/Markdown.tsx";
|
||||
@@ -54,6 +55,7 @@ export function Dump() {
|
||||
const [playlistModalOpen, setPlaylistModalOpen] = useState(false);
|
||||
|
||||
const [comments, setComments] = useState<Comment[]>([]);
|
||||
const [relatedDumps, setRelatedDumps] = useState<Dump[]>([]);
|
||||
|
||||
const [titleEditing, setTitleEditing] = useState(false);
|
||||
const [titleDraft, setTitleDraft] = useState("");
|
||||
@@ -145,6 +147,24 @@ export function Dump() {
|
||||
return () => controller.abort();
|
||||
}, [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}
|
||||
useEffect(() => {
|
||||
if (!location.hash.startsWith("#comment-")) return;
|
||||
@@ -440,6 +460,28 @@ export function Dump() {
|
||||
</Link>
|
||||
</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 */}
|
||||
<CommentThread
|
||||
dumpId={dump.id}
|
||||
|
||||
Reference in New Issue
Block a user