Files
gerbeur/api/services/password-reset-service.ts
khannurien d62e4a9883
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 47s
Build and deploy docs / build-and-push (push) Successful in 26s
v3: the backend is documented for deno doc, one way in every file, and the docs build and publish themselves — comments were in three shapes: file headers as // lines, as an untagged /** */ (which deno doc drops, only an @module block counts) or missing; 43 exports described by a // deno doc can't see, two JSDoc blocks sitting on the wrong declaration and one duplicated; endpoints commented in 5 of 18 route files, three ways, on router.get() calls no doc tool reads. The rule, applied to every non-test module of api/, migrations included: each opens with a /** … @module */ block; every export has JSDoc, and so does every field of an exported interface; a comment describing a declaration is /** */, // only notes code; functions open with a verb, everything else with a noun phrase; parameters are named in the prose, {@link} only for symbols in scope; whatever throws to its caller says so with @throws (for an APIException, the statuses and when); a router's JSDoc lists its endpoints, method and path, who may call it, what it does. deno doc --lint goes from 528 errors to none in api/ (shared/ keeps its 385; it is next). The lint also wanted explicit types on config.ts's settings and a few return types, JsonLdResult exported, and the routers typed Router. deno task doc builds the HTML reference into docs/ from every module of api/ and shared/ but the tests, deno task doc:lint lints it; the index page is the module doc of a root mod.ts (deno.json's exports), which introduces the backend, shared/ and the frontend to come and links every module, and mod.test.ts fails when one is missing. The docs workflow builds it on every push to main that touches them and publishes it to the gh-pages branch, like publish-assistant's. Two defects found along the way are fixed: a private playlist's cover was served to anyone who had its id or slug, and now needs its owner's media token, as private dump files do (playlistImageUrl adds it for private playlists only, so public cover URLs stay the same for everyone); og.ts and static.ts each filled index.html's placeholders, and now share lib/index-html.ts, so the OG middleware picks up a rebuilt index.html instead of keeping the first it read. The README states the documentation rule. 205 backend and 628 frontend tests pass.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 21:07:40 +00:00

135 lines
4.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Password resets by email: sending a reset link, and setting the new
* password with it.
*
* @module
*/
import { APIErrorCode } from "../../shared/api.ts";
import { APIException } from "../lib/api-exception.ts";
import { db } from "../db/connection.ts";
import { isPasswordResetTokenRow } from "../model/password-reset.ts";
import {
createPasswordResetToken,
verifyPasswordResetToken,
} from "../lib/jwt.ts";
import { getUserByEmail, updateUser } from "./user-service.ts";
import { isEmailEnabled, sendEmail } from "./email-service.ts";
import { FROM_EMAIL, FRONTEND_URL, OG_SITE_NAME } from "../config.ts";
import { VALIDATION } from "../../shared/validation.ts";
import { marked } from "marked";
import { type RateLimit, takeToken } from "../lib/rate-limit.ts";
/** How long a reset link works. */
const RESET_TOKEN_TTL_HOURS = 1;
/** Per address: enough to retry a lost email, not enough to flood an inbox. */
const RESET_REQUEST_LIMIT: RateLimit = { capacity: 3, refill: 1 / 600 };
/**
* Looks up the user by email, creates a signed reset token, persists it, and
* sends the reset link. Always resolves without throwing so callers can return
* 200 unconditionally (no user enumeration).
*/
export async function requestPasswordReset(email: string): Promise<void> {
if (!takeToken(`reset:${email.toLowerCase()}`, RESET_REQUEST_LIMIT)) return;
const user = getUserByEmail(email);
if (!user) return;
if (!isEmailEnabled()) return;
const token = await createPasswordResetToken(user.id);
const expiresAt = new Date(
Date.now() + RESET_TOKEN_TTL_HOURS * 60 * 60 * 1000,
);
db.prepare(
`INSERT INTO password_reset_tokens (token, user_id, expires_at) VALUES (?, ?, ?);`,
).run(token, user.id, expiresAt.toISOString());
const resetUrl = `${FRONTEND_URL}/reset-password?token=${
encodeURIComponent(token)
}`;
const body =
`# Reset your ${OG_SITE_NAME} password\n\nHi **${user.username}**,\n\nClick the link below to set a new password. It expires in ${RESET_TOKEN_TTL_HOURS} hour.\n\n[Reset password](${resetUrl})\n\nIf you did not request this, ignore this email.`;
sendEmail({
from: FROM_EMAIL,
to: user.email,
subject: `Reset your ${OG_SITE_NAME} password`,
text: body,
html: await marked(body),
}).catch((err) =>
console.error("[password-reset] failed to send email:", err)
);
}
/**
* Verifies the token (JWT signature + expiry + DB record + not used), updates
* the password, and marks the token consumed.
*
* @throws {APIException} 400 when the password's length is out of bounds or
* the token is invalid or expired, 404 when the token isn't on record or its
* user is gone, 409 when it has been used.
*/
export async function resetPassword(
token: string,
newPassword: string,
): Promise<void> {
if (
newPassword.length < VALIDATION.PASSWORD_MIN ||
newPassword.length > VALIDATION.PASSWORD_MAX
) {
throw new APIException(
APIErrorCode.VALIDATION_ERROR,
400,
`Password must be ${VALIDATION.PASSWORD_MIN}–${VALIDATION.PASSWORD_MAX} characters`,
);
}
const payload = await verifyPasswordResetToken(token);
if (!payload) {
throw new APIException(
APIErrorCode.VALIDATION_ERROR,
400,
"Invalid or expired reset link",
);
}
const row = db.prepare(
`SELECT token, user_id, expires_at, used_at FROM password_reset_tokens WHERE token = ?;`,
).get(token);
if (!row || !isPasswordResetTokenRow(row)) {
throw new APIException(
APIErrorCode.NOT_FOUND,
404,
"Reset token not found",
);
}
// Consume the link before the (async) password hash: checking used_at and
// marking it later would let parallel submissions all pass the check.
const claimed = db.prepare(
`UPDATE password_reset_tokens SET used_at = ? WHERE token = ? AND used_at IS NULL;`,
).run(new Date().toISOString(), token);
if (claimed.changes === 0) {
throw new APIException(
APIErrorCode.VALIDATION_ERROR,
409,
"Reset link has already been used",
);
}
try {
await updateUser(payload.userId, { password: newPassword });
} catch (err) {
db.prepare(
`UPDATE password_reset_tokens SET used_at = NULL WHERE token = ?;`,
)
.run(token);
throw err;
}
}