generated from nhcarrigan/template
983b78b0e9
## Summary This PR includes multiple feature additions and fixes to improve the library application: ### 🎨 Base64 Image Upload Support - Fixed Fastify body limit (1MB → 10MB) to accommodate base64-encoded images - Corrected base64 size calculation and validation logic - Improved error handling with proper 400 status codes and helpful messages - Removed duplicate validation that was blocking uploads - Users can now upload cover images up to 5MB (decoded size) ### 📝 Reusable Form Components - Created 6 form components: `GameForm`, `BookForm`, `MusicForm`, `ShowForm`, `MangaForm`, `ArtForm` - All forms support both 'add' and 'edit' modes with pre-population - Integrated inline editing into all detail views (edit/delete buttons) - Enhanced admin suggestions workflow with full forms instead of basic modals - Added scroll-to-top when clicking edit in list views for better UX ### 🖼️ Default Cover Image - Added beautiful library reading image as default cover for all media types - Fixed static asset serving to use correct MIME types - Updated all 12 components (6 list views + 6 detail views) to always show images ### 🔒 Tiered Rate Limiting - Unauthenticated users: 100 requests/minute - Authenticated users: 500 requests/minute (5x more lenient) - Admin users: No rate limits (complete bypass via allowList) ### 🎮 Discord Integration - Auto-assign library member role to users in NHCarrigan Discord server - Checks server membership on every login - Only assigns role if user is in server and doesn't have it yet - Graceful error handling without blocking login - Similar pattern to badge refresh flow ### 📚 Documentation - Added comprehensive CLAUDE.md with: - Project structure and tech stack - Development workflow and commands - Database schema documentation - Authentication flow details - Security features - Code style conventions - Common gotchas and solutions ## Test Plan - [x] Base64 image uploads work for cover images up to 5MB - [x] Helpful error messages appear for validation failures - [x] Edit/delete buttons appear on all detail views for admin users - [x] Inline edit forms display and save correctly - [x] Admin suggestions workflow uses full forms for all media types - [x] Scroll-to-top works when editing from list views - [x] Default cover image displays when no cover is provided - [x] Static assets serve with correct MIME types - [x] Rate limiting works correctly for different user types - [x] Discord role assignment works on login - [x] All builds pass without errors - [x] No TypeScript errors ## Related Issues Closes #65 - Base64 image upload issue ✨ This pull request was created with help from Hikari~ 🌸 Reviewed-on: #66 Co-authored-by: Hikari <hikari@nhcarrigan.com> Co-committed-by: Hikari <hikari@nhcarrigan.com>
321 lines
10 KiB
TypeScript
321 lines
10 KiB
TypeScript
import { FastifyPluginAsync } from "fastify";
|
|
import { AuthService } from "../../services/auth.service";
|
|
import { AuditService } from "../../services/audit.service";
|
|
import { AchievementService } from "../../services/achievement.service";
|
|
import { AuthResponse, AuditAction, AuditCategory, AchievementCategory } from "@library/shared-types";
|
|
|
|
const authRoutes: FastifyPluginAsync = async (app) => {
|
|
const authService = new AuthService(app);
|
|
|
|
/**
|
|
* Discord OAuth callback.
|
|
*/
|
|
app.get("/callback", async (request, reply) => {
|
|
try {
|
|
const tokenResult = await app.oauth2Discord.getAccessTokenFromAuthorizationCodeFlow(
|
|
request
|
|
);
|
|
|
|
// Get user data from Discord API
|
|
const discordResponse = await fetch("https://discord.com/api/users/@me", {
|
|
headers: {
|
|
Authorization: `Bearer ${tokenResult.token.access_token}`,
|
|
},
|
|
});
|
|
|
|
if (!discordResponse.ok) {
|
|
throw new Error("Failed to fetch Discord user data");
|
|
}
|
|
|
|
const userData = await discordResponse.json();
|
|
|
|
// Check if user is in our Discord server and has special roles
|
|
let inDiscord = false;
|
|
let isVip = false;
|
|
let isMod = false;
|
|
let isStaff = false;
|
|
const guildId = process.env.DISCORD_GUILD_ID;
|
|
const sponsorRoleId = process.env.SPONSOR_ROLE_ID;
|
|
const modRoleId = process.env.MOD_ROLE_ID;
|
|
const staffRoleId = process.env.STAFF_ROLE_ID;
|
|
|
|
if (guildId) {
|
|
const guildsResponse = await fetch("https://discord.com/api/users/@me/guilds", {
|
|
headers: {
|
|
Authorization: `Bearer ${tokenResult.token.access_token}`,
|
|
},
|
|
});
|
|
|
|
if (guildsResponse.ok) {
|
|
const guilds = await guildsResponse.json() as Array<{ id: string }>;
|
|
inDiscord = guilds.some(guild => guild.id === guildId);
|
|
}
|
|
|
|
// If user is in Discord, check for special roles
|
|
if (inDiscord) {
|
|
const memberResponse = await fetch(
|
|
`https://discord.com/api/users/@me/guilds/${guildId}/member`,
|
|
{
|
|
headers: {
|
|
Authorization: `Bearer ${tokenResult.token.access_token}`,
|
|
},
|
|
}
|
|
);
|
|
|
|
if (memberResponse.ok) {
|
|
const memberData = await memberResponse.json() as { roles: string[] };
|
|
if (sponsorRoleId) {
|
|
isVip = memberData.roles.includes(sponsorRoleId);
|
|
}
|
|
if (modRoleId) {
|
|
isMod = memberData.roles.includes(modRoleId);
|
|
}
|
|
if (staffRoleId) {
|
|
isStaff = memberData.roles.includes(staffRoleId);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Create or update user in database
|
|
const user = await authService.createOrUpdateUserFromDiscord(userData, inDiscord, isVip, isMod, isStaff);
|
|
|
|
// Generate access token and refresh token
|
|
const accessToken = await authService.generateToken(user);
|
|
const refreshToken = await authService.generateRefreshToken(user.id);
|
|
|
|
// Log successful login
|
|
await AuditService.log({
|
|
action: AuditAction.login,
|
|
category: AuditCategory.auth,
|
|
userId: user.id,
|
|
details: `User ${user.username} logged in via Discord`,
|
|
success: true,
|
|
}, request);
|
|
|
|
// Update login streak and check engagement achievements
|
|
const achievementService = new AchievementService();
|
|
await achievementService.updateLoginStreak(user.id);
|
|
await achievementService.checkAchievements(
|
|
user.id,
|
|
AchievementCategory.Engagement,
|
|
request
|
|
);
|
|
|
|
// Assign library member role if user is in Discord server but doesn't have it
|
|
const libraryRoleId = process.env.LIBRARY_ROLE_ID;
|
|
if (inDiscord && guildId && libraryRoleId) {
|
|
try {
|
|
const memberResponse = await fetch(
|
|
`https://discord.com/api/users/@me/guilds/${guildId}/member`,
|
|
{
|
|
headers: {
|
|
Authorization: `Bearer ${tokenResult.token.access_token}`,
|
|
},
|
|
}
|
|
);
|
|
|
|
if (memberResponse.ok) {
|
|
const memberData = await memberResponse.json() as { roles: string[] };
|
|
const hasLibraryRole = memberData.roles.includes(libraryRoleId);
|
|
|
|
if (!hasLibraryRole) {
|
|
const botToken = process.env.DISCORD_BOT_TOKEN;
|
|
if (botToken) {
|
|
const assignRoleResponse = await fetch(
|
|
`https://discord.com/api/v10/guilds/${guildId}/members/${userData.id}/roles/${libraryRoleId}`,
|
|
{
|
|
method: "PUT",
|
|
headers: {
|
|
Authorization: `Bot ${botToken}`,
|
|
"Content-Type": "application/json",
|
|
},
|
|
}
|
|
);
|
|
|
|
if (assignRoleResponse.ok || assignRoleResponse.status === 204) {
|
|
app.log.info(`Assigned library role to user ${user.username} (${user.id})`);
|
|
} else {
|
|
app.log.error(
|
|
`Failed to assign library role to user ${user.username}: ${assignRoleResponse.status}`
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} catch (error) {
|
|
// Don't fail the login if role assignment fails
|
|
app.log.error({ err: error }, "Error assigning library role");
|
|
}
|
|
}
|
|
|
|
// Set signed cookies and redirect to frontend
|
|
reply
|
|
.setCookie("auth-token", accessToken, {
|
|
path: "/",
|
|
httpOnly: true,
|
|
secure: process.env.NODE_ENV === "production",
|
|
sameSite: "lax",
|
|
maxAge: 15 * 60, // 15 minutes
|
|
signed: true,
|
|
})
|
|
.setCookie("refresh-token", refreshToken, {
|
|
path: "/api/auth",
|
|
httpOnly: true,
|
|
secure: process.env.NODE_ENV === "production",
|
|
sameSite: "lax",
|
|
maxAge: 7 * 24 * 60 * 60, // 7 days
|
|
signed: true,
|
|
})
|
|
.redirect("/"); // Redirect to root since API serves frontend
|
|
} catch (error) {
|
|
// Log failed login attempt
|
|
await AuditService.log({
|
|
action: AuditAction.loginFailed,
|
|
category: AuditCategory.security,
|
|
details: error instanceof Error ? error.message : String(error),
|
|
success: false,
|
|
}, request);
|
|
|
|
app.log.error({ err: error }, "Auth callback error");
|
|
reply
|
|
.code(401)
|
|
.send({ error: "Authentication failed" });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Get current user.
|
|
*/
|
|
app.get<{ Reply: AuthResponse | { error: string } }>(
|
|
"/me",
|
|
{
|
|
preValidation: [app.authenticate],
|
|
},
|
|
async (request, reply) => {
|
|
const jwtUser = request.user as { id: string };
|
|
const user = await authService.getUserById(jwtUser.id);
|
|
|
|
if (!user) {
|
|
return reply.code(404).send({ error: "User not found" });
|
|
}
|
|
|
|
const token = await authService.generateToken(user);
|
|
|
|
return {
|
|
user,
|
|
accessToken: token,
|
|
};
|
|
}
|
|
);
|
|
|
|
/**
|
|
* Refresh access token using refresh token.
|
|
*/
|
|
app.post("/refresh", async (request, reply) => {
|
|
const signedRefreshToken = request.cookies["refresh-token"];
|
|
if (!signedRefreshToken) {
|
|
return reply.code(401).send({ error: "No refresh token provided" });
|
|
}
|
|
|
|
const unsignedResult = request.unsignCookie(signedRefreshToken);
|
|
if (!unsignedResult.valid || !unsignedResult.value) {
|
|
return reply.code(401).send({ error: "Invalid refresh token" });
|
|
}
|
|
|
|
const refreshToken = unsignedResult.value;
|
|
const user = await authService.validateRefreshToken(refreshToken);
|
|
|
|
if (!user) {
|
|
reply.clearCookie("refresh-token", { path: "/api/auth", signed: true });
|
|
return reply.code(401).send({ error: "Refresh token expired or invalid" });
|
|
}
|
|
|
|
if (user.isBanned) {
|
|
await authService.revokeAllUserTokens(user.id);
|
|
reply
|
|
.clearCookie("auth-token", { path: "/", signed: true })
|
|
.clearCookie("refresh-token", { path: "/api/auth", signed: true });
|
|
return reply.code(403).send({ error: "Account is banned" });
|
|
}
|
|
|
|
// Rotate refresh token for security
|
|
const newRefreshToken = await authService.rotateRefreshToken(refreshToken, user.id);
|
|
if (!newRefreshToken) {
|
|
return reply.code(401).send({ error: "Failed to rotate refresh token" });
|
|
}
|
|
|
|
const newAccessToken = await authService.generateToken(user);
|
|
|
|
reply
|
|
.setCookie("auth-token", newAccessToken, {
|
|
path: "/",
|
|
httpOnly: true,
|
|
secure: process.env.NODE_ENV === "production",
|
|
sameSite: "lax",
|
|
maxAge: 15 * 60, // 15 minutes
|
|
signed: true,
|
|
})
|
|
.setCookie("refresh-token", newRefreshToken, {
|
|
path: "/api/auth",
|
|
httpOnly: true,
|
|
secure: process.env.NODE_ENV === "production",
|
|
sameSite: "lax",
|
|
maxAge: 7 * 24 * 60 * 60, // 7 days
|
|
signed: true,
|
|
})
|
|
.send({ user, accessToken: newAccessToken });
|
|
});
|
|
|
|
/**
|
|
* Logout.
|
|
*/
|
|
app.post("/logout", async (request, reply) => {
|
|
// Revoke refresh token if present
|
|
const signedRefreshToken = request.cookies["refresh-token"];
|
|
if (signedRefreshToken) {
|
|
const unsignedResult = request.unsignCookie(signedRefreshToken);
|
|
if (unsignedResult.valid && unsignedResult.value) {
|
|
await authService.revokeRefreshToken(unsignedResult.value);
|
|
}
|
|
}
|
|
|
|
// Try to get user ID from JWT for audit logging
|
|
try {
|
|
await request.jwtVerify();
|
|
const user = request.user as { id?: string; username?: string };
|
|
if (user?.id) {
|
|
await AuditService.log({
|
|
action: AuditAction.logout,
|
|
category: AuditCategory.auth,
|
|
userId: user.id,
|
|
details: `User ${user.username ?? "unknown"} logged out`,
|
|
success: true,
|
|
}, request);
|
|
}
|
|
} catch {
|
|
// User wasn't authenticated, just proceed with logout
|
|
}
|
|
|
|
reply
|
|
.clearCookie("auth-token", {
|
|
path: "/",
|
|
signed: true,
|
|
})
|
|
.clearCookie("refresh-token", {
|
|
path: "/api/auth",
|
|
signed: true,
|
|
})
|
|
.send({ message: "Logged out successfully" });
|
|
});
|
|
|
|
/**
|
|
* Get CSRF token for state-changing requests.
|
|
*/
|
|
app.get("/csrf-token", async (request, reply) => {
|
|
const token = reply.generateCsrf();
|
|
return { csrfToken: token };
|
|
});
|
|
};
|
|
|
|
export default authRoutes; |