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>
49 lines
1.4 KiB
TypeScript
49 lines
1.4 KiB
TypeScript
/**
|
|
* Hashes and checks passwords, for sign-up, sign-in, password changes and the
|
|
* admin that `db/startup.ts` seeds. It reads no config, so the startup tasks
|
|
* can use it before the server's settings are checked.
|
|
*
|
|
* @module
|
|
*/
|
|
|
|
import { randomBytes, scrypt, timingSafeEqual } from "node:crypto";
|
|
import { Buffer } from "node:buffer";
|
|
|
|
/**
|
|
* Hashes `password` with scrypt and a random salt, as `hash.salt` in hex, for
|
|
* `users.password_hash`.
|
|
*/
|
|
export function hashPassword(password: string): Promise<string> {
|
|
const salt = randomBytes(16).toString("hex");
|
|
|
|
return new Promise((resolve, reject) => {
|
|
scrypt(password, salt, 64, (err, derivedKey) => {
|
|
if (err) reject(err);
|
|
else resolve(`${derivedKey.toString("hex")}.${salt}`);
|
|
});
|
|
});
|
|
}
|
|
|
|
/** Checks `password` against a hash made by {@link hashPassword}. */
|
|
export function verifyPassword(
|
|
password: string,
|
|
storedHash: string,
|
|
): Promise<boolean> {
|
|
const [hash, salt] = storedHash.split(".");
|
|
// scrypt would throw on a missing salt, and a malformed hash matches nothing.
|
|
if (!hash || !salt) return Promise.resolve(false);
|
|
|
|
return new Promise((resolve, reject) => {
|
|
scrypt(password, salt, 64, (err, derivedKey) => {
|
|
if (err) reject(err);
|
|
else {
|
|
const expected = Buffer.from(hash ?? "", "hex");
|
|
resolve(
|
|
expected.length === derivedKey.length &&
|
|
timingSafeEqual(expected, derivedKey),
|
|
);
|
|
}
|
|
});
|
|
});
|
|
}
|