/** * Moves the site to v4's page URLs, where dumps and playlists live under their * owner (`/@bob/dumps/`) and are named by an editable slug alone. A * slug was `slugify(title)-<8 hex of the id>`, unique site-wide; it is now the * bare `slugify(title)`, unique in its owner's space for its kind, and every * slug an item or a user leaves behind stays an alias that redirects * (`dump_slug_aliases`, `playlist_slug_aliases`, `user_aliases`). * * v3 URLs aren't redirected. Instead this rewrites the links to this site's * pages in what users wrote: dump notes, comments, chat messages, and * playlist and profile descriptions. * * SQLite can't make `slug` NOT NULL without rebuilding `dumps` and * `playlists`, which other tables reference, so the column stays nullable * and every writer sets it; the unique index is what the services rely on. * * @module */ import type { DatabaseSync } from "node:sqlite"; import { slugify } from "../../../shared/slug.ts"; import { dumpPath, feedPath, playlistPath, type ProfileTab, searchPath, userPath, } from "../../../shared/paths.ts"; import { type SlugKind, uniqueSlug } from "../../lib/slugify.ts"; import { isReservedSlug } from "../../lib/reserved-slugs.ts"; import { isSelfHost } from "../../lib/self-url.ts"; /** An item's new path, by its v3 slug and by its id. */ type PathsByKey = Map; /** * Gives every dump or playlist of `kind` the bare slug of its title, unique * per owner, the oldest first so it keeps the bare one. Returns each one's new * path under both keys a v3 link may have used: its old slug and its id. */ function reslug(db: DatabaseSync, kind: SlugKind): PathsByKey { const table = kind === "dump" ? "dumps" : "playlists"; const rows = db.prepare( `SELECT t.id, t.user_id, t.title, t.slug, u.username FROM ${table} t JOIN users u ON u.id = t.user_id ORDER BY t.created_at, t.id;`, ).all() as { id: string; user_id: string; title: string; slug: string | null; username: string; }[]; // Cleared first, so that an old slug doesn't take a new one's place. db.exec(`UPDATE ${table} SET slug = NULL;`); const set = db.prepare(`UPDATE ${table} SET slug = ? WHERE id = ?;`); const paths: PathsByKey = new Map(); const build = kind === "dump" ? dumpPath : playlistPath; for (const row of rows) { const slug = uniqueSlug(db, kind, row.user_id, slugify(row.title)); set.run(slug, row.id); const path = build({ ownerUsername: row.username, slug }); paths.set(row.id, path); if (row.slug) paths.set(row.slug, path); } return paths; } /** * Renames each category whose slug v4 reserves at the root, such as `hot`, * to the first of `-2`, `-3`… that is free, and logs it. */ function renameReservedCategories(db: DatabaseSync): void { const rows = db.prepare(`SELECT id, slug FROM categories;`).all() as { id: string; slug: string; }[]; const taken = new Set(rows.map((r) => r.slug)); for (const { id, slug } of rows) { if (!isReservedSlug(slug)) continue; let n = 2; while (taken.has(`${slug}-${n}`) || isReservedSlug(`${slug}-${n}`)) n++; const renamed = `${slug}-${n}`; taken.add(renamed); db.prepare(`UPDATE categories SET slug = ? WHERE id = ?;`).run(renamed, id); console.log( `[migrate] category "${slug}" is now "${renamed}": v4 reserves its slug`, ); } } /** Where each v3 profile tab went: a tab, the overview, or a page of its own. */ const PROFILE_TAB_V4: Record = { dumps: "", playlists: "playlists", followed: "follow", invitees: "invitees", settings: "/settings", manage: "manage", }; /** The v3 full lists under a profile, which are v4 tabs. */ const PROFILE_LISTS = new Set(["dumps", "upvoted", "playlists"]); /** * Matches a link to a v3 page, its origin being optional: an absolute one is * this site's only when its host is, a relative one only as a Markdown link's * target. The path stops before a query or a fragment, which stay as they are. */ const V3_LINK_RE = /(https?:\/\/([^/\s)\]<>"']+))?(\/(?:dumps|playlists|users|~|search\/~)\/[^\s)\]<>"'?#]+)/g; /** * Returns the v4 path of the v3 page at `path`, or null when it names nothing * known, which then stays as written: a deleted dump's link is dead either way. */ function v4PathOf( path: string, dumps: PathsByKey, playlists: PathsByKey, username: (name: string) => string | undefined, ): string | null { let segments: string[]; try { segments = path.split("/").slice(1).filter(Boolean).map( decodeURIComponent, ); } catch { return null; } const [root, key, ...rest] = segments; switch (root) { case "dumps": { const target = dumps.get(key); if (!target) return null; if (rest.length === 0) return target; return rest.length === 1 && rest[0] === "edit" ? `${target}/edit` : null; } case "playlists": return rest.length === 0 ? playlists.get(key) ?? null : null; case "users": { const name = username(key) ?? key; if (rest.length === 0) return userPath(name); if (rest.length === 1 && PROFILE_LISTS.has(rest[0])) { return userPath(name, rest[0] as ProfileTab); } if (rest.length === 2 && rest[0] === "~" && rest[1] in PROFILE_TAB_V4) { const tab = PROFILE_TAB_V4[rest[1]]; if (tab === "/settings") return tab; return tab ? userPath(name, tab) : userPath(name); } return null; } case "~": if (rest.length > 0) return null; return key === "followed" ? feedPath(undefined, "follow") : ["hot", "new", "journal"].includes(key) ? feedPath(undefined, key as "hot") : null; case "search": // `/search/~/users`, split as "search", "~", "users". return key === "~" && (rest[0] === "users" || rest[0] === "playlists") && rest.length === 1 ? searchPath(rest[0]) : null; } return null; } /** * Rewrites the links to v3 pages of this site in the text column `column` of * `table`, in every row that has one. */ function rewriteLinks( db: DatabaseSync, table: string, column: string, toV4: (path: string) => string | null, ): void { const rows = db.prepare( `SELECT id, ${column} AS text FROM ${table} WHERE ${column} IS NOT NULL;`, ).all() as { id: string; text: string }[]; const update = db.prepare(`UPDATE ${table} SET ${column} = ? WHERE id = ?;`); for (const { id, text } of rows) { const rewritten = text.replace( V3_LINK_RE, (match, origin: string | undefined, host: string, path: string, at) => { if (origin ? !isSelfHost(host) : text[at - 1] !== "(") return match; const v4 = toV4(path); return v4 ? `${origin ?? ""}${v4}` : match; }, ); if (rewritten !== text) update.run(rewritten, id); } } /** * Run once by `runMigrations`. On a database made from schema.sql the tables * and indexes exist and there is nothing to re-slug or rewrite. */ export function up(db: DatabaseSync): void { db.exec(` CREATE TABLE IF NOT EXISTS dump_slug_aliases ( user_id TEXT NOT NULL, slug TEXT NOT NULL, dump_id TEXT NOT NULL REFERENCES dumps(id) ON DELETE CASCADE, created_at TEXT NOT NULL, PRIMARY KEY (user_id, slug) ); CREATE INDEX IF NOT EXISTS idx_dump_slug_aliases_dump ON dump_slug_aliases(dump_id, created_at); CREATE TABLE IF NOT EXISTS playlist_slug_aliases ( user_id TEXT NOT NULL, slug TEXT NOT NULL, playlist_id TEXT NOT NULL REFERENCES playlists(id) ON DELETE CASCADE, created_at TEXT NOT NULL, PRIMARY KEY (user_id, slug) ); CREATE INDEX IF NOT EXISTS idx_playlist_slug_aliases_playlist ON playlist_slug_aliases(playlist_id, created_at); CREATE TABLE IF NOT EXISTS user_aliases ( username TEXT NOT NULL, user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, created_at TEXT NOT NULL, PRIMARY KEY (username) ); CREATE UNIQUE INDEX IF NOT EXISTS idx_user_aliases_lower ON user_aliases(lower(username)); CREATE INDEX IF NOT EXISTS idx_user_aliases_user ON user_aliases(user_id, created_at); DROP INDEX IF EXISTS idx_dumps_slug; DROP INDEX IF EXISTS idx_playlists_slug; `); const dumps = reslug(db, "dump"); const playlists = reslug(db, "playlist"); db.exec(` CREATE UNIQUE INDEX IF NOT EXISTS idx_dumps_user_slug ON dumps(user_id, slug); CREATE UNIQUE INDEX IF NOT EXISTS idx_playlists_user_slug ON playlists(user_id, slug); `); renameReservedCategories(db); const findUser = db.prepare( `SELECT username FROM users WHERE lower(username) = lower(?);`, ); const username = (name: string) => (findUser.get(name) as { username: string } | undefined)?.username; const toV4 = (path: string) => v4PathOf(path, dumps, playlists, username); for ( const [table, column] of [ ["dumps", "comment"], ["comments", "body"], ["chat_messages", "body"], ["playlists", "description"], ["users", "description"], ] ) rewriteLinks(db, table, column, toV4); }