Error statuses follow one rule: APIException takes a status and derives the code from it (BAD_REQUEST 400, UNAUTHORIZED 401, FORBIDDEN 403, NOT_FOUND 404, CONFLICT 409, RATE_LIMITED 429, SERVER_ERROR 500, BAD_GATEWAY 502), so a code can't contradict its status. That fixed real bugs: a wrong current password on change-password answered 401, on which authFetch signs the user out; editing or deleting someone else's comment or chat message answered 401 rather than 403; one invalid dump edit answered 422. A taken username is 409, like a taken email. Along the way: the database is created from schema.sql by connection.ts when the file doesn't exist, so every task works on a fresh checkout (dev and serve never ran db/init.ts, which is gone); the startup purge compared ISO dates with SQLite's datetime() as text and kept anything from the current UTC day, and now compares ISO cutoffs; the seeded admin's hash comes from lib/password.ts (split out of jwt.ts, which needs the JWT secret) rather than a copy of its format; avatar, thumbnail and playlist-cover uploads go through replaceUploadedFile, which writes beside the old file and renames over it before the record announces it (a failed avatar write lost the old avatar; a thumbnail was announced before it existed); upload.ts derives its size message from MAX_IMAGE_SIZE_BYTES and stops re-exporting the upload folders; ALLOWED_IMAGE_MIMES, which duplicated detectImageMime, is gone; the SPA fallback no longer calls next() after answering; a reply must answer a comment on the same dump (a foreign or unknown parent was a 500 or a cross-dump thread). 211 backend and 628 frontend tests pass. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
88 lines
2.8 KiB
TypeScript
88 lines
2.8 KiB
TypeScript
/**
|
|
* Makes invites, and checks and claims them at sign-up. An invite works once,
|
|
* for `UNUSED_INVITES_RETENTION_DAYS` days. `routes/invites.ts` makes and
|
|
* checks them, and `POST /api/users/register` claims one.
|
|
*
|
|
* @module
|
|
*/
|
|
|
|
import { randomBytes } from "node:crypto";
|
|
import { APIException } from "../lib/api-exception.ts";
|
|
import { db } from "../db/connection.ts";
|
|
import { isInviteRow } from "../model/invite.ts";
|
|
import { UNUSED_INVITES_RETENTION_DAYS } from "../config.ts";
|
|
|
|
/** Returns 128 random bits as 22 URL-safe characters. */
|
|
function generateInviteToken(): string {
|
|
return randomBytes(16).toString("base64url");
|
|
}
|
|
|
|
/**
|
|
* Makes an invite from `inviterId` and returns its token, which the client
|
|
* puts in the invite link.
|
|
*/
|
|
export function createInvite(inviterId: string): string {
|
|
const token = generateInviteToken();
|
|
db.prepare(
|
|
`INSERT INTO invites (token, inviter_id, created_at) VALUES (?, ?, ?);`,
|
|
).run(token, inviterId, new Date().toISOString());
|
|
return token;
|
|
}
|
|
|
|
/**
|
|
* Returns the id of the inviter when invite `token` is unused and still in its
|
|
* window, which is the one after which `db/startup.ts` purges it. It doesn't
|
|
* claim the invite: sign-up goes through {@link claimInvite}.
|
|
*
|
|
* @throws {APIException} 404 when the invite doesn't exist or has expired,
|
|
* 409 when it has been used.
|
|
*/
|
|
export function validateInvite(token: string): string {
|
|
const row = db.prepare(
|
|
`SELECT token, inviter_id, used_at, created_at FROM invites WHERE token = ?;`,
|
|
).get(token);
|
|
|
|
if (!row || !isInviteRow(row)) {
|
|
throw new APIException(404, "Invalid or expired invite");
|
|
}
|
|
|
|
if (row.used_at !== null) {
|
|
throw new APIException(409, "Invite already used");
|
|
}
|
|
|
|
const expiresAt = new Date(row.created_at).getTime() +
|
|
UNUSED_INVITES_RETENTION_DAYS * 24 * 60 * 60 * 1000;
|
|
if (Date.now() > expiresAt) {
|
|
throw new APIException(404, "Invalid or expired invite");
|
|
}
|
|
|
|
return row.inviter_id;
|
|
}
|
|
|
|
/**
|
|
* Marks invite `token` used and returns the inviter's id. Call it before the
|
|
* async account creation, and {@link releaseInvite} if that fails: checking
|
|
* first and marking after would let parallel sign-ups share one invite.
|
|
*
|
|
* @throws {APIException} 404 when the invite doesn't exist or has expired,
|
|
* 409 when it has been used.
|
|
*/
|
|
export function claimInvite(token: string): string {
|
|
const inviterId = validateInvite(token);
|
|
const result = db.prepare(
|
|
`UPDATE invites SET used_at = ? WHERE token = ? AND used_at IS NULL;`,
|
|
).run(new Date().toISOString(), token);
|
|
if (result.changes === 0) {
|
|
throw new APIException(409, "Invite already used");
|
|
}
|
|
return inviterId;
|
|
}
|
|
|
|
/**
|
|
* Makes a claimed invite usable again, when the sign-up that claimed it
|
|
* failed.
|
|
*/
|
|
export function releaseInvite(token: string): void {
|
|
db.prepare(`UPDATE invites SET used_at = NULL WHERE token = ?;`).run(token);
|
|
}
|