240 lines
8.3 KiB
TypeScript
240 lines
8.3 KiB
TypeScript
/**
|
|
* Moves the site to v4's page URLs, where dumps and playlists live under their
|
|
* owner (`/@bob/dumps/<slug>`) 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 { renameReservedCategories } 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<string, string>;
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
|
|
/** Where each v3 profile tab went: a tab, the overview, or a page of its own. */
|
|
const PROFILE_TAB_V4: Record<string, ProfileTab | "" | "/settings"> = {
|
|
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<string>(["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);
|
|
}
|