Files
gerbeur/api/routes/users.ts
khannurien 2ad8ae73d5 v3: the API stops trusting what it's handed — a review of api/ turned up a server anyone could crash, uploads that ran script on the app's origin, and a server that would fetch its own network on request, and this closes them along with the smaller holes around them. A WebSocket message that wasn't an object (a bare null will do) threw inside the listener, which Deno treats as uncaught and exits on; messages are now shape-checked and the handler can't throw out. Uploaded files kept the MIME type the uploader's browser claimed, so an .html or .svg dump opened from /api/files ran with the session token in reach; only images, audio, video, PDF and plain text are served inline now, the rest download, and every file carries nosniff and a sandboxing CSP (PDFs aside, which Chrome won't render sandboxed). The image proxy passed image/svg+xml straight through the same way and now takes raster formats only. Outbound fetches for previews, the proxy and Bandcamp checked the hostname against a partial list — no 169.254.169.254, no 0.0.0.0, an IPv6 check that never matched a bracketed hostname, nothing for names that resolve inward, and redirects followed blindly; every hop now resolves and is refused if any address is private or reserved, redirects are walked by hand, the curl fallback is pinned to the checked IP, and /api/preview needs a login. PATCH /users/me accepted a new password without the current one and now takes only the description and email; a wrong password at login answered 500 because the handler caught its own 401, and now says 401. Changing or resetting a password now ends every older session through users.tokens_valid_after (migration 0015) and an iat on every token, with change-password handing back fresh tokens the modal keeps, and all token checks go through one authenticate() that also refuses deleted users. Invites and reset links are claimed atomically before the async hash rather than marked after, so one invite no longer opens several accounts, and an email address belongs to one account. File and thumbnail URLs carry a media-only token rather than the session token, so a URL in a log exposes files and not the account; older sessions fetch one on load. Private things stop leaking into broadcasts: private playlist updates go to their owner, votes and likes on someone else's private dump 404 and their counts go to the owner alone, and a private dump can't be slipped into a playlist. Smaller: selfProvider only claims this site's own host, so github.com/users/bob stops rendering our bob; the chat read marker can't be parked in the future on an empty chat; dump mutations use the resolved id so a slug works; multipart dumps get the title and comment limits; logins and reset requests are rate-limited through a shared token bucket; the password compare is constant-time; ffmpeg reads only local files in known video containers.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 20:07:33 +00:00

445 lines
13 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.
import { Router } from "@oak/oak";
import {
APIErrorCode,
APIException,
isLoginUserRequest,
isRole,
isUpdateUserRequest,
type PaginatedData,
type User,
validateRegisterUserRequest,
} from "../model/interfaces.ts";
import { createJWT, createMediaToken, verifyPassword } from "../lib/jwt.ts";
import { type RateLimit, takeToken } from "../lib/rate-limit.ts";
import {
type AuthContext,
authMiddleware,
requirePermission,
} from "../middleware/auth.ts";
import { parseOptionalAuth } from "../lib/auth.ts";
import { parsePagination } from "../lib/pagination.ts";
import {
createUser,
getInviteTree,
getUserById,
getUserByUsername,
searchUsers,
updateUser,
} from "../services/user-service.ts";
import { claimInvite, releaseInvite } from "../services/invite-service.ts";
import {
requestPasswordReset,
resetPassword,
} from "../services/password-reset-service.ts";
import { isEmailEnabled, sendEmail } from "../services/email-service.ts";
import {
FROM_EMAIL,
OG_SITE_NAME,
VALIDATION,
WELCOME_EMAIL_BODY,
} from "../config.ts";
import { marked } from "marked";
import { broadcastUserUpdated } from "../services/ws-service.ts";
import {
getDumpsByUser,
getVotedDumpsByUser,
} from "../services/dump-service.ts";
import { listPlaylistsByUser } from "../services/playlist-service.ts";
import {
getFollowedPlaylistsByUser,
getFollowedUsersByUser,
} from "../services/follow-service.ts";
// Users router
const router = new Router({ prefix: "/api/users" });
// A burst of 10 attempts, then one every 30s.
const LOGIN_LIMIT: RateLimit = { capacity: 10, refill: 1 / 30 };
// The session token, plus the media-only token that goes in file and
// thumbnail URLs (see createMediaToken).
async function issueTokens(user: User) {
return {
token: await createJWT({
userId: user.id,
username: user.username,
role: user.role,
}),
mediaToken: await createMediaToken(user.id),
};
}
// Register a new user (requires a valid invite token)
router.post("/register", async (ctx) => {
const body = await ctx.request.body.json();
const registerError = validateRegisterUserRequest(body);
if (registerError) {
throw new APIException(APIErrorCode.VALIDATION_ERROR, 400, registerError);
}
// Claim the invite up front (throws 404/409 if bad) and hand it back if the
// account can't be created, so one invite can't open two accounts.
const inviterId = claimInvite(body.inviteToken);
let user;
try {
user = await createUser(body, inviterId);
} catch (err) {
releaseInvite(body.inviteToken);
throw err;
}
// Send welcome email (fire-and-forget)
if (isEmailEnabled()) {
const emailMarkdown = WELCOME_EMAIL_BODY
.replaceAll("{{username}}", user.username)
.replaceAll("{{site_name}}", OG_SITE_NAME);
sendEmail({
from: FROM_EMAIL,
to: user.email,
subject: `Welcome to ${OG_SITE_NAME}`,
text: emailMarkdown,
html: await marked(emailMarkdown),
}).catch((err) => console.error("[register] welcome email failed:", err));
}
const { passwordHash: _, ...publicUser } = user;
ctx.response.status = 201;
ctx.response.body = {
success: true,
data: { ...await issueTokens(user), user: publicUser },
};
});
// Login
router.post("/login", async (ctx) => {
const body = await ctx.request.body.json();
if (!isLoginUserRequest(body)) {
throw new APIException(
APIErrorCode.VALIDATION_ERROR,
400,
"Invalid request",
);
}
// Per account, so guessing one user's password is slow however many
// connections it comes from.
if (!takeToken(`login:${body.username.toLowerCase()}`, LOGIN_LIMIT)) {
throw new APIException(
APIErrorCode.VALIDATION_ERROR,
429,
"Too many login attempts — try again in a minute",
);
}
let user;
try {
user = getUserByUsername(body.username);
} catch {
// Same answer as a wrong password: don't confirm which usernames exist.
throw new APIException(
APIErrorCode.VALIDATION_ERROR,
401,
"Invalid username or password",
);
}
const valid = await verifyPassword(body.password, user.passwordHash);
if (!valid) {
throw new APIException(
APIErrorCode.VALIDATION_ERROR,
401,
"Invalid username or password",
);
}
const { passwordHash: _, ...publicUser } = user;
ctx.response.body = {
success: true,
data: { ...await issueTokens(user), user: publicUser },
};
});
// Get current user profile
router.get("/me", authMiddleware, (ctx: AuthContext) => {
try {
if (!ctx.state.user) {
throw new APIException(
APIErrorCode.UNAUTHORIZED,
401,
"Not authenticated",
);
}
const { passwordHash: _, ...publicUser } = getUserById(
ctx.state.user.userId,
);
ctx.response.body = {
success: true,
data: publicUser,
};
} catch (err) {
console.error(err);
throw new APIException(
APIErrorCode.SERVER_ERROR,
500,
"Failed to fetch user profile",
);
}
});
// A media token for a session that predates them (see createMediaToken).
router.get("/me/media-token", authMiddleware, async (ctx: AuthContext) => {
ctx.response.body = {
success: true,
data: { mediaToken: await createMediaToken(ctx.state.user.userId) },
};
});
// Request a password reset email (unauthenticated; always returns 200)
router.post("/request-password-reset", async (ctx) => {
const body = await ctx.request.body.json();
const email = typeof body?.email === "string" ? body.email.trim() : "";
if (email) {
await requestPasswordReset(email).catch((err) =>
console.error("[request-password-reset]", err)
);
}
ctx.response.body = { success: true };
});
// Consume a reset token and set a new password (unauthenticated)
router.post("/reset-password", async (ctx) => {
const body = await ctx.request.body.json();
const { token, newPassword } = (body ?? {}) as Record<string, unknown>;
if (typeof token !== "string" || typeof newPassword !== "string") {
throw new APIException(
APIErrorCode.VALIDATION_ERROR,
400,
"Invalid request",
);
}
await resetPassword(token, newPassword);
ctx.response.body = { success: true };
});
// Change current user's password (requires current password verification)
router.post("/me/change-password", authMiddleware, async (ctx: AuthContext) => {
const body = await ctx.request.body.json();
const { currentPassword, newPassword } = (body ?? {}) as Record<
string,
unknown
>;
if (typeof currentPassword !== "string" || typeof newPassword !== "string") {
throw new APIException(
APIErrorCode.VALIDATION_ERROR,
400,
"Invalid request",
);
}
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 user = getUserById(ctx.state.user.userId);
const valid = await verifyPassword(currentPassword, user.passwordHash);
if (!valid) {
throw new APIException(
APIErrorCode.VALIDATION_ERROR,
401,
"Current password is incorrect",
);
}
// Changing the password revokes every token issued before it, this
// session's included, so hand back fresh ones for the client to keep.
const updated = await updateUser(ctx.state.user.userId, {
password: newPassword,
});
ctx.response.body = { success: true, data: await issueTokens(updated) };
});
// Update current user profile (description, etc.)
router.patch("/me", authMiddleware, async (ctx: AuthContext) => {
const body = await ctx.request.body.json();
if (!isUpdateUserRequest(body)) {
throw new APIException(
APIErrorCode.VALIDATION_ERROR,
400,
"Invalid request",
);
}
// Only the profile fields. The role is assigned by an admin, the password
// changes through /me/change-password (which asks for the current one), and
// the username is fixed — a token alone must not be able to change any of
// them.
const updated = await updateUser(ctx.state.user.userId, {
description: body.description,
email: body.email,
});
const { passwordHash: _, email: _email, ...publicUser } = updated;
broadcastUserUpdated(publicUser);
ctx.response.body = { success: true, data: publicUser };
});
// Set another user's role. Requires the user:manage permission.
router.patch(
"/:userId/role",
authMiddleware,
requirePermission("user:manage"),
async (ctx: AuthContext<"/:userId/role">) => {
const body = await ctx.request.body.json();
const role: unknown = body?.role;
if (!isRole(role)) {
throw new APIException(APIErrorCode.VALIDATION_ERROR, 400, "Invalid role");
}
// Disallow changing your own role: prevents an admin from accidentally
// locking themselves out, and self-promotion is meaningless anyway.
if (ctx.params.userId === ctx.state.user.userId) {
throw new APIException(
APIErrorCode.VALIDATION_ERROR,
400,
"Cannot change your own role",
);
}
const updated = await updateUser(ctx.params.userId, { role });
const { passwordHash: _, email: _email, ...publicUser } = updated;
broadcastUserUpdated(publicUser);
ctx.response.body = { success: true, data: publicUser };
},
);
// User search for @mention autocomplete
router.get("/search", (ctx) => {
const q = (ctx.request.url.searchParams.get("q") ?? "").trim();
const results = searchUsers(q, 8);
ctx.response.body = { success: true, data: results };
});
// Public user profile by internal ID (used when only userId is available, e.g. dump.userId)
router.get("/by-id/:userId", (ctx) => {
const user = getUserById(ctx.params.userId);
const { passwordHash: _, email: _email, ...publicUser } = user;
ctx.response.body = { success: true, data: publicUser };
});
// Followed users for a user (public)
router.get("/:username/followed-users", (ctx) => {
const user = getUserByUsername(ctx.params.username);
const { page, limit } = parsePagination(ctx.request.url.searchParams);
const { items, total } = getFollowedUsersByUser(user.id, page, limit);
ctx.response.body = {
success: true,
data: {
items: items.map(({ passwordHash: _, email: _e, ...pub }) => pub),
total,
hasMore: page * limit < total,
},
};
});
// Followed playlists for a user (public only)
router.get("/:username/followed-playlists", (ctx) => {
const user = getUserByUsername(ctx.params.username);
const { page, limit } = parsePagination(ctx.request.url.searchParams);
const { items, total } = getFollowedPlaylistsByUser(user.id, page, limit);
ctx.response.body = {
success: true,
data: {
items,
total,
hasMore: page * limit < total,
} satisfies PaginatedData<typeof items[number]>,
};
});
// Playlists by user (optional auth: include private only if requester === owner)
router.get("/:username/playlists", async (ctx) => {
const user = getUserByUsername(ctx.params.username);
const requestingUserId = await parseOptionalAuth(ctx);
const { page, limit } = parsePagination(ctx.request.url.searchParams);
const { items, total } = listPlaylistsByUser(
user.id,
requestingUserId,
page,
limit,
);
ctx.response.body = {
success: true,
data: {
items,
total,
hasMore: page * limit < total,
} satisfies PaginatedData<typeof items[number]>,
};
});
// Invite tree for a user (public)
router.get("/:username/invitees", (ctx) => {
const user = getUserByUsername(ctx.params.username);
const tree = getInviteTree(user.id);
ctx.response.body = { success: true, data: tree };
});
// Public user profile by username (no passwordHash)
router.get("/:username", (ctx) => {
const user = getUserByUsername(ctx.params.username);
const { passwordHash: _, email: _email, ...publicUser } = user;
ctx.response.body = { success: true, data: publicUser };
});
// Dumps posted by user (optional auth: owner sees their private dumps)
router.get("/:username/dumps", async (ctx) => {
const user = getUserByUsername(ctx.params.username);
const requestingUserId = await parseOptionalAuth(ctx);
const { page, limit } = parsePagination(ctx.request.url.searchParams);
const includePrivate = requestingUserId === user.id;
const { items, total } = getDumpsByUser(user.id, page, limit, includePrivate);
ctx.response.body = {
success: true,
data: {
items,
total,
hasMore: page * limit < total,
} satisfies PaginatedData<typeof items[number]>,
};
});
// Dumps upvoted by user (optional auth: hide private dump entries for non-owners)
router.get("/:username/votes", async (ctx) => {
const user = getUserByUsername(ctx.params.username);
const requestingUserId = await parseOptionalAuth(ctx);
const { page, limit } = parsePagination(ctx.request.url.searchParams);
const { items, total } = getVotedDumpsByUser(
user.id,
page,
limit,
requestingUserId,
);
ctx.response.body = {
success: true,
data: {
items,
total,
hasMore: page * limit < total,
} satisfies PaginatedData<typeof items[number]>,
};
});
export default router;