/** * Changes and resolves the slugs of dumps and playlists. A slug changes only * when someone edits it (the dump and playlist services call * {@link changeSlug} with the slug their edit form sent), and the slug it * leaves becomes an alias of the item, so that old links still reach it; the * routes that read an item by its page path resolve the slug with * {@link resolveSlug}, aliases included, and answer the item, whose current * slug the client then puts in its URL. * * @module */ import { APIException } from "../lib/api-exception.ts"; import { db, transaction } from "../db/connection.ts"; import { SLUG_TABLES, slugHolder, type SlugKind, uniqueSlug, validateSlug, } from "../lib/slugify.ts"; import { slugify } from "../../shared/slug.ts"; import { RENAME_LIMITS } from "../../shared/validation.ts"; /** * Returns a free slug for a new item of `userId`'s titled `title`, for the * dump and playlist services to insert it with, inside their transaction. */ export function newSlug(kind: SlugKind, userId: string, title: string): string { return uniqueSlug(db, kind, userId, slugify(title)); } /** * Returns the id of `userId`'s item of `kind` whose slug is or was `slug`, or * undefined when none is. */ export function resolveSlug( kind: SlugKind, userId: string, slug: string, ): string | undefined { return slugHolder(db, kind, userId, slug); } /** * Gives `item` the slug `slug`, keeping its current one as an alias. An alias * always names the item itself, so redirects never chain; taking back one of * the item's own aliases swaps it with the current slug. Unless `exempt` (a * moderator), an item's slug changes at most `RENAME_LIMITS.SLUG.count` times * in its window, counted from its aliases. A slug equal to the current one * changes nothing. * * @throws {APIException} 400 when `slug` isn't valid, 409 when another item * of the owner's holds it now or held it before, 429 when the item's slug * changed too often lately. */ export function changeSlug( kind: SlugKind, item: { id: string; userId: string; slug: string }, slug: string, exempt: boolean, ): void { if (slug === item.slug) return; validateSlug(slug); const { items, aliases, itemId } = SLUG_TABLES[kind]; const now = new Date(); transaction(() => { const holder = slugHolder(db, kind, item.userId, slug); if (holder && holder !== item.id) { throw new APIException(409, `The slug "${slug}" is already taken`); } const { count, windowMs } = RENAME_LIMITS.SLUG; const recent = (db.prepare( `SELECT COUNT(*) AS n FROM ${aliases} WHERE ${itemId} = ? AND created_at > ?;`, ).get(item.id, new Date(now.getTime() - windowMs).toISOString()) as { n: number; }).n; if (!exempt && recent >= count) { throw new APIException( 429, `A slug can only be changed ${count} times a day`, ); } db.prepare(`DELETE FROM ${aliases} WHERE user_id = ? AND slug = ?;`) .run(item.userId, slug); db.prepare( `INSERT INTO ${aliases} (user_id, slug, ${itemId}, created_at) VALUES (?, ?, ?, ?);`, ).run(item.userId, item.slug, item.id, now.toISOString()); db.prepare(`UPDATE ${items} SET slug = ? WHERE id = ?;`).run(slug, item.id); }); }