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>
234 lines
7.3 KiB
TypeScript
234 lines
7.3 KiB
TypeScript
/**
|
|
* @copyright 2026 NHCarrigan
|
|
* @license Naomi's Public License
|
|
* @author Naomi Carrigan
|
|
*/
|
|
|
|
import { FastifyPluginAsync } from "fastify";
|
|
import { Game, CreateGameDto, UpdateGameDto, Comment, CreateCommentDto, AuditAction, AuditCategory, AchievementCategory } from "@library/shared-types";
|
|
import { GameService } from "../../services/game.service";
|
|
import { CommentService } from "../../services/comment.service";
|
|
import { AuditService } from "../../services/audit.service";
|
|
import { AchievementService } from "../../services/achievement.service";
|
|
import { adminGuard } from "../../middleware/admin-guard";
|
|
import { bannedGuard } from "../../middleware/banned-guard";
|
|
|
|
const gamesRoutes: FastifyPluginAsync = async (app) => {
|
|
const gameService = new GameService();
|
|
const commentService = new CommentService();
|
|
|
|
// Get all games (public route)
|
|
app.get<{ Reply: Game[] }>("/", async () => {
|
|
return gameService.getAllGames();
|
|
});
|
|
|
|
// Get all games in a series (public route)
|
|
app.get<{ Params: { seriesName: string }; Reply: Game[] }>(
|
|
"/series/:seriesName",
|
|
async (request) => {
|
|
const { seriesName } = request.params;
|
|
return gameService.getGamesBySeries(seriesName);
|
|
}
|
|
);
|
|
|
|
// Get single game (public route)
|
|
app.get<{ Params: { id: string }; Reply: Game | null }>(
|
|
"/:id",
|
|
async (request) => {
|
|
const { id } = request.params;
|
|
return gameService.getGameById(id);
|
|
}
|
|
);
|
|
|
|
// Create game (protected admin route)
|
|
app.post<{ Body: CreateGameDto; Reply: Game | { error: string } }>(
|
|
"/",
|
|
{
|
|
preValidation: [app.authenticate, adminGuard],
|
|
preHandler: [app.csrfProtection],
|
|
},
|
|
async (request, reply) => {
|
|
try {
|
|
const game = await gameService.createGame(request.body);
|
|
await AuditService.logFromRequest(request, {
|
|
action: AuditAction.entryCreate,
|
|
category: AuditCategory.content,
|
|
resourceType: "game",
|
|
resourceId: game.id,
|
|
details: `Created game: ${game.title}`,
|
|
});
|
|
return game;
|
|
} catch (error) {
|
|
if (error instanceof Error) {
|
|
return reply.code(400).send({ error: error.message });
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
);
|
|
|
|
// Update game (protected admin route)
|
|
app.put<{
|
|
Params: { id: string };
|
|
Body: UpdateGameDto;
|
|
Reply: Game | null | { error: string };
|
|
}>(
|
|
"/:id",
|
|
{
|
|
preValidation: [app.authenticate, adminGuard],
|
|
preHandler: [app.csrfProtection],
|
|
},
|
|
async (request, reply) => {
|
|
try {
|
|
const { id } = request.params;
|
|
const game = await gameService.updateGame(id, request.body);
|
|
if (game) {
|
|
await AuditService.logFromRequest(request, {
|
|
action: AuditAction.entryUpdate,
|
|
category: AuditCategory.content,
|
|
resourceType: "game",
|
|
resourceId: id,
|
|
details: `Updated game: ${game.title}`,
|
|
});
|
|
}
|
|
return game;
|
|
} catch (error) {
|
|
if (error instanceof Error) {
|
|
return reply.code(400).send({ error: error.message });
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
);
|
|
|
|
// Delete game (protected admin route)
|
|
app.delete<{ Params: { id: string }; Reply: { success: boolean } }>(
|
|
"/:id",
|
|
{
|
|
preValidation: [app.authenticate, adminGuard],
|
|
preHandler: [app.csrfProtection],
|
|
},
|
|
async (request) => {
|
|
const { id } = request.params;
|
|
await gameService.deleteGame(id);
|
|
await AuditService.logFromRequest(request, {
|
|
action: AuditAction.entryDelete,
|
|
category: AuditCategory.content,
|
|
resourceType: "game",
|
|
resourceId: id,
|
|
details: `Deleted game with ID: ${id}`,
|
|
});
|
|
return { success: true };
|
|
}
|
|
);
|
|
|
|
// Get comments for a game (public route)
|
|
app.get<{ Params: { id: string }; Reply: Comment[] }>(
|
|
"/:id/comments",
|
|
async (request) => {
|
|
const { id } = request.params;
|
|
return commentService.getCommentsForGame(id);
|
|
}
|
|
);
|
|
|
|
// Add comment to a game (authenticated users)
|
|
app.post<{ Params: { id: string }; Body: CreateCommentDto; Reply: Comment }>(
|
|
"/:id/comments",
|
|
{
|
|
preValidation: [app.authenticate, bannedGuard],
|
|
preHandler: [app.csrfProtection],
|
|
},
|
|
async (request) => {
|
|
const { id } = request.params;
|
|
const userId = request.user.id;
|
|
const comment = await commentService.createCommentForGame(id, userId, request.body);
|
|
await AuditService.logFromRequest(request, {
|
|
action: AuditAction.commentCreate,
|
|
category: AuditCategory.content,
|
|
resourceType: "game",
|
|
resourceId: id,
|
|
details: `Added comment to game`,
|
|
});
|
|
|
|
// Check for comment achievements
|
|
const achievementService = new AchievementService();
|
|
await achievementService.checkAchievements(
|
|
userId,
|
|
AchievementCategory.Comment,
|
|
request
|
|
);
|
|
|
|
return comment;
|
|
}
|
|
);
|
|
|
|
// Update comment (owner or admin)
|
|
app.put<{ Params: { id: string; commentId: string }; Body: CreateCommentDto; Reply: Comment | { error: string } }>(
|
|
"/:id/comments/:commentId",
|
|
{
|
|
preValidation: [app.authenticate],
|
|
preHandler: [app.csrfProtection],
|
|
},
|
|
async (request, reply) => {
|
|
const { id, commentId } = request.params;
|
|
const userId = request.user.id;
|
|
const isAdmin = request.user.isAdmin;
|
|
|
|
const verification = await commentService.verifyCommentOwnership(commentId, "game", id);
|
|
|
|
if (!verification.exists) {
|
|
return reply.code(404).send({ error: "Comment not found" });
|
|
}
|
|
|
|
if (verification.comment?.userId !== userId && !isAdmin) {
|
|
return reply.code(403).send({ error: "You can only edit your own comments" });
|
|
}
|
|
|
|
const comment = await commentService.updateComment(commentId, request.body.content);
|
|
await AuditService.logFromRequest(request, {
|
|
action: AuditAction.commentUpdate,
|
|
category: AuditCategory.content,
|
|
resourceType: "game",
|
|
resourceId: id,
|
|
details: `Updated comment ${commentId} on game`,
|
|
});
|
|
return comment;
|
|
}
|
|
);
|
|
|
|
// Delete comment (owner or admin)
|
|
app.delete<{ Params: { id: string; commentId: string }; Reply: { success: boolean } | { error: string } }>(
|
|
"/:id/comments/:commentId",
|
|
{
|
|
preValidation: [app.authenticate],
|
|
preHandler: [app.csrfProtection],
|
|
},
|
|
async (request, reply) => {
|
|
const { id, commentId } = request.params;
|
|
const userId = request.user.id;
|
|
const isAdmin = request.user.isAdmin;
|
|
|
|
const verification = await commentService.verifyCommentOwnership(commentId, "game", id);
|
|
|
|
if (!verification.exists) {
|
|
return reply.code(404).send({ error: "Comment not found" });
|
|
}
|
|
|
|
if (verification.comment?.userId !== userId && !isAdmin) {
|
|
return reply.code(403).send({ error: "You can only delete your own comments" });
|
|
}
|
|
|
|
await commentService.deleteComment(commentId);
|
|
await AuditService.logFromRequest(request, {
|
|
action: AuditAction.commentDelete,
|
|
category: isAdmin && verification.comment?.userId !== userId ? AuditCategory.admin : AuditCategory.content,
|
|
resourceType: "game",
|
|
resourceId: id,
|
|
details: `Deleted comment ${commentId} from game`,
|
|
});
|
|
return { success: true };
|
|
}
|
|
);
|
|
};
|
|
|
|
export default gamesRoutes; |