import { Router } from "@oak/oak"; import { APIErrorCode, APIException, isLoginUserRequest, isUpdateUserRequest, type PaginatedData, validateRegisterUserRequest, } from "../model/interfaces.ts"; import { createJWT, verifyPassword } from "../lib/jwt.ts"; import { type AuthContext, authMiddleware } 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 { redeemInvite, validateInvite } 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" }); // 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); } // Validate invite — throws 404/409 if bad const inviterId = await validateInvite(body.inviteToken); const user = await createUser(body, inviterId); // Mark invite as used only after the user row is committed try { redeemInvite(body.inviteToken); } catch (err) { console.error("[register] redeemInvite failed (user created):", 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 authToken = await createJWT({ userId: user.id, username: user.username, isAdmin: user.isAdmin, }); const { passwordHash: _, ...publicUser } = user; ctx.response.status = 201; ctx.response.body = { success: true, data: { token: authToken, user: publicUser }, }; }); // Login router.post("/login", async (ctx) => { try { const body = await ctx.request.body.json(); if (!isLoginUserRequest(body)) { throw new APIException( APIErrorCode.VALIDATION_ERROR, 400, "Invalid request", ); } const user = getUserByUsername(body.username); const valid = await verifyPassword(body.password, user.passwordHash); if (!valid) { throw new APIException( APIErrorCode.VALIDATION_ERROR, 401, "Invalid username or password", ); } const token = await createJWT({ userId: user.id, username: user.username, isAdmin: user.isAdmin, }); const { passwordHash: _, ...publicUser } = user; ctx.response.body = { success: true, data: { token, user: publicUser, }, }; } catch (err) { console.error(err); throw new APIException(APIErrorCode.SERVER_ERROR, 500, "Failed to login"); } }); // 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", ); } }); // 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; 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", ); } await updateUser(ctx.state.user.userId, { password: newPassword }); ctx.response.body = { success: true }; }); // 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", ); } const updated = await updateUser(ctx.state.user.userId, body); 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, }; }); // 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, }; }); // 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, }; }); // 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, }; }); export default router;