92 lines
3.2 KiB
TypeScript
92 lines
3.2 KiB
TypeScript
/**
|
|
* 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);
|
|
});
|
|
}
|