Files
gerbeur/api/routes/users.ts
2026-03-30 14:55:30 +00:00

274 lines
7.8 KiB
TypeScript

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,
getUserById,
getUserByUsername,
searchUsers,
updateUser,
} from "../services/user-service.ts";
import { redeemInvite, validateInvite } from "../services/invite-service.ts";
import { isEmailEnabled, sendEmail } from "../services/email-service.ts";
import { FROM_EMAIL, OG_SITE_NAME, 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 } 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",
);
}
});
// 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 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]>,
};
});
// 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;