Files
gerbeur/api/routes/users.ts
khannurien d392be7cb8
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 50s
v3: api/ gets one layout rule and keeps to it, and whatever crosses the wire is defined once, in shared/ — api/model/interfaces.ts and src/model.ts declared the same 40 types by hand and had drifted: the error envelope, email required on one side and optional on the other, Invitee/InviteTreeEntry and LinkPreview/UrlPreviewResponse for one shape each. The validation limits, the role permissions, the chat length cap, the @mention pattern and the Bandcamp and YouTube player responses were copied too, behind "keep in sync" comments. shared/ now holds them once, one file per domain (api, category, chat, comment, dump, follow, mention, notification, permissions, player, playlist, search, user, validation, ws), and imports nothing from either half. The server sends Dates; the client reads Wire<T>, the same type with every Date an ISO string, and turns it back with src/utils/shared/deserialize.ts. APIErrorCode becomes a const object, since the frontend's erasableSyntaxOnly rejects an enum. The shared types showed two real gaps: the avatar upload and GET /api/users/me return the full self user and the frontend read them as public users, and a stored session may predate media tokens, so the client keeps it as its own Session type. The backend rule: each folder holds one kind of module, each file is named after its domain. routes/ parse and answer; services/ hold the logic and every SQL query (lib/auth.ts's token check moves to user-service); remote/ is the only place that fetches from other sites — the guarded fetch and the HTML scraping out of rich-content-service, public-address, the Bandcamp and YouTube resolvers, the link providers, the image proxy (fetch, checks and cache, out of routes/preview.ts) and the Twemoji glyphs (out of lib/site-icons.ts). model/ holds per table a row type, its guard and its mappers; validators/ the request guards; db/connection.ts the handle and transaction(), db/startup.ts the purges and default admin that db.ts ran on import, called at the same moment. Tests mirror the source one file per module, test/<layer>/<module>.test.ts, with shared/'s under shared/test/ (deno task test runs both); the test that covered the bandcamp and soundcloud providers together is two now, and the backend can() has its own, so 202 tests. deno lint src/ is clean too: window.* becomes globalThis.*, and test bodies and act() callbacks that never awaited lose their async. The README states the rule. No behaviour changes; the catalogues only move their source references.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 20:19:48 +00:00

479 lines
14 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,
type APIResponse,
type PaginatedData,
} from "../../shared/api.ts";
import { APIException } from "../lib/api-exception.ts";
import {
type AuthResponse,
type AuthTokens,
type Invitee,
isRole,
type MediaTokenResponse,
type PublicUser,
type SelfUser,
type UserSuggestion,
} from "../../shared/user.ts";
import type { Dump } from "../../shared/dump.ts";
import {
fieldOf,
isLoginUserRequest,
isRegisterUserRequest,
isUpdateUserRequest,
validateRegisterUserRequest,
} from "../validators/user.ts";
import type { Playlist } from "../../shared/playlist.ts";
import { toPublicUser, toSelfUser, type User } from "../model/user.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, WELCOME_EMAIL_BODY } from "../config.ts";
import { VALIDATION } from "../../shared/validation.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): Promise<AuthTokens> {
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: unknown = await ctx.request.body.json();
if (!isRegisterUserRequest(body)) {
throw new APIException(
APIErrorCode.VALIDATION_ERROR,
400,
validateRegisterUserRequest(body) ?? "Invalid request",
);
}
// 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 responseBody: APIResponse<AuthResponse> = {
success: true,
data: { ...await issueTokens(user), user: toSelfUser(user) },
};
ctx.response.status = 201;
ctx.response.body = responseBody;
});
// Login
router.post("/login", async (ctx) => {
const body: unknown = 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 responseBody: APIResponse<AuthResponse> = {
success: true,
data: { ...await issueTokens(user), user: toSelfUser(user) },
};
ctx.response.body = responseBody;
});
// Get current user profile
router.get("/me", authMiddleware, (ctx: AuthContext) => {
try {
if (!ctx.state.user) {
throw new APIException(
APIErrorCode.UNAUTHORIZED,
401,
"Not authenticated",
);
}
const responseBody: APIResponse<SelfUser> = {
success: true,
data: toSelfUser(getUserById(ctx.state.user.userId)),
};
ctx.response.body = responseBody;
} 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) => {
const responseBody: APIResponse<MediaTokenResponse> = {
success: true,
data: { mediaToken: await createMediaToken(ctx.state.user.userId) },
};
ctx.response.body = responseBody;
});
// Request a password reset email (unauthenticated; always returns 200)
router.post("/request-password-reset", async (ctx) => {
const body: unknown = await ctx.request.body.json();
const email = fieldOf(body, "email");
const trimmed = typeof email === "string" ? email.trim() : "";
if (trimmed) {
await requestPasswordReset(trimmed).catch((err) =>
console.error("[request-password-reset]", err)
);
}
const responseBody: APIResponse<null> = { success: true, data: null };
ctx.response.body = responseBody;
});
// Consume a reset token and set a new password (unauthenticated)
router.post("/reset-password", async (ctx) => {
const body: unknown = await ctx.request.body.json();
const token = fieldOf(body, "token");
const newPassword = fieldOf(body, "newPassword");
if (typeof token !== "string" || typeof newPassword !== "string") {
throw new APIException(
APIErrorCode.VALIDATION_ERROR,
400,
"Invalid request",
);
}
await resetPassword(token, newPassword);
const responseBody: APIResponse<null> = { success: true, data: null };
ctx.response.body = responseBody;
});
// Change current user's password (requires current password verification)
router.post("/me/change-password", authMiddleware, async (ctx: AuthContext) => {
const body: unknown = await ctx.request.body.json();
const currentPassword = fieldOf(body, "currentPassword");
const newPassword = fieldOf(body, "newPassword");
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,
});
const responseBody: APIResponse<AuthTokens> = {
success: true,
data: await issueTokens(updated),
};
ctx.response.body = responseBody;
});
// Update current user profile (description, etc.)
router.patch("/me", authMiddleware, async (ctx: AuthContext) => {
const body: unknown = 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 publicUser = toPublicUser(updated);
broadcastUserUpdated(publicUser);
const responseBody: APIResponse<PublicUser> = {
success: true,
data: publicUser,
};
ctx.response.body = responseBody;
});
// 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: unknown = await ctx.request.body.json();
const role = fieldOf(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 publicUser = toPublicUser(updated);
broadcastUserUpdated(publicUser);
const responseBody: APIResponse<PublicUser> = {
success: true,
data: publicUser,
};
ctx.response.body = responseBody;
},
);
// User search for @mention autocomplete
router.get("/search", (ctx) => {
const q = (ctx.request.url.searchParams.get("q") ?? "").trim();
const responseBody: APIResponse<UserSuggestion[]> = {
success: true,
data: searchUsers(q, 8),
};
ctx.response.body = responseBody;
});
// Public user profile by internal ID (used when only userId is available, e.g. dump.userId)
router.get("/by-id/:userId", (ctx) => {
const responseBody: APIResponse<PublicUser> = {
success: true,
data: toPublicUser(getUserById(ctx.params.userId)),
};
ctx.response.body = responseBody;
});
// 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, hasMore } = getFollowedUsersByUser(user.id, page, limit);
const responseBody: APIResponse<PaginatedData<PublicUser>> = {
success: true,
data: {
items: items.map(toPublicUser),
hasMore,
},
};
ctx.response.body = responseBody;
});
// 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, hasMore } = getFollowedPlaylistsByUser(user.id, page, limit);
const responseBody: APIResponse<PaginatedData<Playlist>> = {
success: true,
data: { items, hasMore },
};
ctx.response.body = responseBody;
});
// 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, hasMore } = listPlaylistsByUser(
user.id,
requestingUserId,
page,
limit,
);
const responseBody: APIResponse<PaginatedData<Playlist>> = {
success: true,
data: { items, hasMore },
};
ctx.response.body = responseBody;
});
// Invite tree for a user (public)
router.get("/:username/invitees", (ctx) => {
const user = getUserByUsername(ctx.params.username);
const responseBody: APIResponse<Invitee[]> = {
success: true,
data: getInviteTree(user.id),
};
ctx.response.body = responseBody;
});
// Public user profile by username (no passwordHash)
router.get("/:username", (ctx) => {
const responseBody: APIResponse<PublicUser> = {
success: true,
data: toPublicUser(getUserByUsername(ctx.params.username)),
};
ctx.response.body = responseBody;
});
// 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, hasMore } = getDumpsByUser(
user.id,
page,
limit,
includePrivate,
);
const responseBody: APIResponse<PaginatedData<Dump>> = {
success: true,
data: { items, hasMore },
};
ctx.response.body = responseBody;
});
// 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, hasMore } = getVotedDumpsByUser(
user.id,
page,
limit,
requestingUserId,
);
const responseBody: APIResponse<PaginatedData<Dump>> = {
success: true,
data: { items, hasMore },
};
ctx.response.body = responseBody;
});
export default router;