feat: security and auditing

This commit is contained in:
2026-02-04 16:48:08 -08:00
parent 11be34cd21
commit 0a654f423a
42 changed files with 2195 additions and 160 deletions
+37 -3
View File
@@ -1,14 +1,48 @@
import * as path from 'path';
import { FastifyInstance } from 'fastify';
import { FastifyInstance, FastifyError } from 'fastify';
import AutoLoad from '@fastify/autoload';
import { AuditService } from './services/audit.service';
import { AuditAction, AuditCategory } from '@library/shared-types';
/* eslint-disable-next-line */
export interface AppOptions {}
export async function app(fastify: FastifyInstance, opts: AppOptions) {
// Place here your custom code!
// Add global error handler for security event logging
fastify.setErrorHandler(async (error: FastifyError, request, reply) => {
// Log CSRF validation failures
if (error.code === 'FST_CSRF_INVALID_TOKEN' || error.code === 'FST_CSRF_MISSING_SECRET') {
await AuditService.log({
action: AuditAction.CSRF_VALIDATION_FAILED,
category: AuditCategory.SECURITY,
details: `CSRF validation failed: ${error.message}, URL: ${request.url}`,
success: false,
}, request).catch(() => {
// Ignore logging errors
});
}
// Do not touch the following lines
// Log unauthorized access attempts
if (error.statusCode === 401 || error.statusCode === 403) {
await AuditService.log({
action: AuditAction.UNAUTHORIZED_ACCESS,
category: AuditCategory.SECURITY,
details: `Unauthorized access attempt: ${error.message}, URL: ${request.url}`,
success: false,
}, request).catch(() => {
// Ignore logging errors
});
}
// Send the error response (don't leak internal details for server errors)
const statusCode = error.statusCode ?? 500;
reply.status(statusCode).send({
statusCode,
error: statusCode >= 500 ? "Internal Server Error" : error.name,
message: statusCode >= 500 ? "An unexpected error occurred" : error.message,
});
});
// This loads all plugins defined in plugins
// those should be support plugins that are reused
+12 -2
View File
@@ -5,17 +5,27 @@
*/
import { FastifyReply, FastifyRequest } from "fastify";
import { UserService } from "../services/user.service";
const userService = new UserService();
/**
* Middleware to check if the authenticated user is an admin.
* Must be used after app.authenticate.
* Always checks the database to ensure admin status is current.
*/
export async function adminGuard(
request: FastifyRequest,
reply: FastifyReply
): Promise<void> {
const user = request.user as any;
if (!user || !user.isAdmin) {
const user = request.user as { id: string };
if (!user?.id) {
return reply.code(401).send({ error: "Unauthorized" });
}
const dbUser = await userService.getUserById(user.id);
if (!dbUser?.isAdmin) {
return reply.code(403).send({ error: "Forbidden: Admin access required" });
}
}
+30
View File
@@ -0,0 +1,30 @@
/**
* @copyright 2026 NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { FastifyReply, FastifyRequest } from "fastify";
import { UserService } from "../services/user.service";
const userService = new UserService();
/**
* Middleware to check if the authenticated user is banned.
* Must be used after app.authenticate.
*/
export async function bannedGuard(
request: FastifyRequest,
reply: FastifyReply
): Promise<void> {
const user = request.user as { id: string };
if (!user?.id) {
return reply.code(401).send({ error: "Unauthorized" });
}
const isBanned = await userService.isUserBanned(user.id);
if (isBanned) {
return reply.code(403).send({ error: "You have been banned from commenting" });
}
}
+23 -5
View File
@@ -23,13 +23,34 @@ declare module "@fastify/jwt" {
}
}
const getJwtSecret = (): string => {
const secret = process.env.JWT_SECRET;
if (!secret) {
throw new Error("JWT_SECRET environment variable is required");
}
return secret;
};
const authPlugin: FastifyPluginAsync = async (app) => {
const jwtSecret = getJwtSecret();
// Register cookie plugin with signing secret
app.register(fastifyCookie, {
secret: jwtSecret,
});
// Register JWT plugin
app.register(fastifyJwt, {
secret: process.env.JWT_SECRET || "your-secret-key",
secret: jwtSecret,
sign: {
algorithm: "HS256",
},
verify: {
algorithms: ["HS256"],
},
cookie: {
cookieName: "auth-token",
signed: false,
signed: true,
},
formatUser: (payload: { sub: string; email?: string; username: string; isAdmin: boolean }) => {
return {
@@ -41,9 +62,6 @@ const authPlugin: FastifyPluginAsync = async (app) => {
},
});
// Register cookie plugin
app.register(fastifyCookie);
// Register Discord OAuth2
app.register(fastifyOauth2, {
name: "oauth2Discord",
+26
View File
@@ -0,0 +1,26 @@
/**
* @copyright 2026 NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { FastifyPluginAsync } from "fastify";
import fastifyPlugin from "fastify-plugin";
import fastifyCors from "@fastify/cors";
const corsPlugin: FastifyPluginAsync = async (app) => {
const baseUrl = process.env.BASE_URL;
if (!baseUrl) {
throw new Error("BASE_URL environment variable is required");
}
await app.register(fastifyCors, {
origin: baseUrl,
credentials: true,
methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
allowedHeaders: ["Content-Type", "Authorization", "X-CSRF-Token"],
});
};
export default fastifyPlugin(corsPlugin);
+26
View File
@@ -0,0 +1,26 @@
/**
* @copyright 2026 NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { FastifyPluginAsync, FastifyRequest } from "fastify";
import fastifyPlugin from "fastify-plugin";
import fastifyCsrf from "@fastify/csrf-protection";
const csrfPlugin: FastifyPluginAsync = async (app) => {
await app.register(fastifyCsrf, {
sessionPlugin: "@fastify/cookie",
cookieOpts: {
path: "/",
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
},
getToken: (request: FastifyRequest) => {
return request.headers["x-csrf-token"] as string;
},
});
};
export default fastifyPlugin(csrfPlugin);
+27
View File
@@ -0,0 +1,27 @@
/**
* @copyright 2026 NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { FastifyPluginAsync } from "fastify";
import fastifyPlugin from "fastify-plugin";
import fastifyHelmet from "@fastify/helmet";
const helmetPlugin: FastifyPluginAsync = async (app) => {
await app.register(fastifyHelmet, {
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", "data:", "https:"],
scriptSrc: ["'self'"],
connectSrc: ["'self'", process.env.FRONTEND_URL ?? "http://localhost:4200"],
},
},
crossOriginEmbedderPolicy: false,
crossOriginResourcePolicy: { policy: "cross-origin" },
});
};
export default fastifyPlugin(helmetPlugin);
+37
View File
@@ -0,0 +1,37 @@
/**
* @copyright 2026 NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { FastifyPluginAsync } from "fastify";
import fastifyPlugin from "fastify-plugin";
import fastifyRateLimit from "@fastify/rate-limit";
import { AuditService } from "../services/audit.service";
import { AuditAction, AuditCategory } from "@library/shared-types";
const rateLimitPlugin: FastifyPluginAsync = async (app) => {
await app.register(fastifyRateLimit, {
max: 100,
timeWindow: "1 minute",
errorResponseBuilder: (request) => {
// Log rate limit exceeded event
AuditService.log({
action: AuditAction.RATE_LIMIT_EXCEEDED,
category: AuditCategory.SECURITY,
details: `Rate limit exceeded for URL: ${request.url}`,
success: false,
}, request).catch(() => {
// Ignore logging errors to avoid blocking the response
});
return {
statusCode: 429,
error: "Too Many Requests",
message: "You have exceeded the rate limit. Please try again later.",
};
},
});
};
export default fastifyPlugin(rateLimitPlugin);
+53 -6
View File
@@ -5,10 +5,12 @@
*/
import { FastifyPluginAsync } from "fastify";
import { Art, CreateArtDto, UpdateArtDto, Comment, CreateCommentDto } from "@library/shared-types";
import { Art, CreateArtDto, UpdateArtDto, Comment, CreateCommentDto, AuditAction, AuditCategory } from "@library/shared-types";
import { ArtService } from "../../services/art.service";
import { CommentService } from "../../services/comment.service";
import { AuditService } from "../../services/audit.service";
import { adminGuard } from "../../middleware/admin-guard";
import { bannedGuard } from "../../middleware/banned-guard";
const artRoutes: FastifyPluginAsync = async (app) => {
const artService = new ArtService();
@@ -39,9 +41,18 @@ const artRoutes: FastifyPluginAsync = async (app) => {
"/",
{
preValidation: [app.authenticate, adminGuard],
preHandler: [app.csrfProtection],
},
async (request) => {
return artService.createArt(request.body);
const art = await artService.createArt(request.body);
await AuditService.logFromRequest(request, {
action: AuditAction.ENTRY_CREATE,
category: AuditCategory.CONTENT,
resourceType: "art",
resourceId: art.id,
details: `Created art: ${art.title}`,
});
return art;
}
);
@@ -56,10 +67,21 @@ const artRoutes: FastifyPluginAsync = async (app) => {
"/:id",
{
preValidation: [app.authenticate, adminGuard],
preHandler: [app.csrfProtection],
},
async (request) => {
const { id } = request.params;
return artService.updateArt(id, request.body);
const art = await artService.updateArt(id, request.body);
if (art) {
await AuditService.logFromRequest(request, {
action: AuditAction.ENTRY_UPDATE,
category: AuditCategory.CONTENT,
resourceType: "art",
resourceId: id,
details: `Updated art: ${art.title}`,
});
}
return art;
}
);
@@ -70,10 +92,18 @@ const artRoutes: FastifyPluginAsync = async (app) => {
"/:id",
{
preValidation: [app.authenticate, adminGuard],
preHandler: [app.csrfProtection],
},
async (request) => {
const { id } = request.params;
await artService.deleteArt(id);
await AuditService.logFromRequest(request, {
action: AuditAction.ENTRY_DELETE,
category: AuditCategory.CONTENT,
resourceType: "art",
resourceId: id,
details: `Deleted art with ID: ${id}`,
});
return { success: true };
}
);
@@ -95,12 +125,21 @@ const artRoutes: FastifyPluginAsync = async (app) => {
app.post<{ Params: { id: string }; Body: CreateCommentDto; Reply: Comment }>(
"/:id/comments",
{
preValidation: [app.authenticate],
preValidation: [app.authenticate, bannedGuard],
preHandler: [app.csrfProtection],
},
async (request) => {
const { id } = request.params;
const userId = request.user.id;
return commentService.createCommentForArt(id, userId, request.body);
const comment = await commentService.createCommentForArt(id, userId, request.body);
await AuditService.logFromRequest(request, {
action: AuditAction.COMMENT_CREATE,
category: AuditCategory.CONTENT,
resourceType: "art",
resourceId: id,
details: `Added comment to art`,
});
return comment;
}
);
@@ -111,10 +150,18 @@ const artRoutes: FastifyPluginAsync = async (app) => {
"/:id/comments/:commentId",
{
preValidation: [app.authenticate, adminGuard],
preHandler: [app.csrfProtection],
},
async (request) => {
const { commentId } = request.params;
const { id, commentId } = request.params;
await commentService.deleteComment(commentId);
await AuditService.logFromRequest(request, {
action: AuditAction.COMMENT_DELETE,
category: AuditCategory.ADMIN,
resourceType: "art",
resourceId: id,
details: `Deleted comment ${commentId} from art`,
});
return { success: true };
}
);
+85
View File
@@ -0,0 +1,85 @@
/**
* @copyright 2026 NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { FastifyPluginAsync } from "fastify";
import { AuditService } from "../../services/audit.service";
import { adminGuard } from "../../middleware/admin-guard";
import type { AuditAction, AuditCategory } from "@library/shared-types";
interface AuditLogQuery {
action?: AuditAction;
category?: AuditCategory;
userId?: string;
success?: string;
startDate?: string;
endDate?: string;
page?: string;
limit?: string;
}
const auditRoutes: FastifyPluginAsync = async (app) => {
/**
* Get audit logs (admin only).
*/
app.get<{ Querystring: AuditLogQuery }>(
"/",
{
preValidation: [app.authenticate, adminGuard],
},
async (request) => {
const { action, category, userId, success, startDate, endDate, page, limit } = request.query;
return AuditService.getLogs({
action: action as AuditAction | undefined,
category: category as AuditCategory | undefined,
userId,
success: success === undefined ? undefined : success === "true",
startDate: startDate ? new Date(startDate) : undefined,
endDate: endDate ? new Date(endDate) : undefined,
page: page ? parseInt(page, 10) : 1,
limit: limit ? parseInt(limit, 10) : 50,
});
}
);
/**
* Get security logs (admin only).
*/
app.get<{ Querystring: { page?: string; limit?: string } }>(
"/security",
{
preValidation: [app.authenticate, adminGuard],
},
async (request) => {
const { page, limit } = request.query;
return AuditService.getSecurityLogs(
page ? parseInt(page, 10) : 1,
limit ? parseInt(limit, 10) : 50
);
}
);
/**
* Get logs for a specific user (admin only).
*/
app.get<{ Params: { userId: string }; Querystring: { page?: string; limit?: string } }>(
"/user/:userId",
{
preValidation: [app.authenticate, adminGuard],
},
async (request) => {
const { userId } = request.params;
const { page, limit } = request.query;
return AuditService.getLogsByUser(
userId,
page ? parseInt(page, 10) : 1,
limit ? parseInt(limit, 10) : 50
);
}
);
};
export default auditRoutes;
+56 -5
View File
@@ -1,6 +1,7 @@
import { FastifyPluginAsync } from "fastify";
import { AuthService } from "../../services/auth.service";
import { AuthResponse } from "@library/shared-types";
import { AuditService } from "../../services/audit.service";
import { AuthResponse, AuditAction, AuditCategory } from "@library/shared-types";
const authRoutes: FastifyPluginAsync = async (app) => {
const authService = new AuthService(app);
@@ -33,7 +34,16 @@ const authRoutes: FastifyPluginAsync = async (app) => {
// Generate JWT
const jwt = await authService.generateToken(user);
// Set cookie and redirect to frontend
// 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);
// Set signed cookie and redirect to frontend
reply
.setCookie("auth-token", jwt, {
path: "/",
@@ -41,13 +51,22 @@ const authRoutes: FastifyPluginAsync = async (app) => {
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.LOGIN_FAILED,
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", details: error instanceof Error ? error.message : String(error) });
.send({ error: "Authentication failed" });
}
});
@@ -59,8 +78,14 @@ const authRoutes: FastifyPluginAsync = async (app) => {
{
preValidation: [app.authenticate],
},
async (request) => {
const user = request.user as any;
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 {
@@ -74,12 +99,38 @@ const authRoutes: FastifyPluginAsync = async (app) => {
* Logout.
*/
app.post("/logout", async (request, reply) => {
// Try to get user ID from JWT if available
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,
})
.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;
+53 -6
View File
@@ -5,10 +5,12 @@
*/
import { FastifyPluginAsync } from "fastify";
import { Book, CreateBookDto, UpdateBookDto, Comment, CreateCommentDto } from "@library/shared-types";
import { Book, CreateBookDto, UpdateBookDto, Comment, CreateCommentDto, AuditAction, AuditCategory } from "@library/shared-types";
import { BookService } from "../../services/book.service";
import { CommentService } from "../../services/comment.service";
import { AuditService } from "../../services/audit.service";
import { adminGuard } from "../../middleware/admin-guard";
import { bannedGuard } from "../../middleware/banned-guard";
const booksRoutes: FastifyPluginAsync = async (app) => {
const bookService = new BookService();
@@ -39,9 +41,18 @@ const booksRoutes: FastifyPluginAsync = async (app) => {
"/",
{
preValidation: [app.authenticate, adminGuard],
preHandler: [app.csrfProtection],
},
async (request) => {
return bookService.createBook(request.body);
const book = await bookService.createBook(request.body);
await AuditService.logFromRequest(request, {
action: AuditAction.ENTRY_CREATE,
category: AuditCategory.CONTENT,
resourceType: "book",
resourceId: book.id,
details: `Created book: ${book.title}`,
});
return book;
}
);
@@ -56,10 +67,21 @@ const booksRoutes: FastifyPluginAsync = async (app) => {
"/:id",
{
preValidation: [app.authenticate, adminGuard],
preHandler: [app.csrfProtection],
},
async (request) => {
const { id } = request.params;
return bookService.updateBook(id, request.body);
const book = await bookService.updateBook(id, request.body);
if (book) {
await AuditService.logFromRequest(request, {
action: AuditAction.ENTRY_UPDATE,
category: AuditCategory.CONTENT,
resourceType: "book",
resourceId: id,
details: `Updated book: ${book.title}`,
});
}
return book;
}
);
@@ -70,10 +92,18 @@ const booksRoutes: FastifyPluginAsync = async (app) => {
"/:id",
{
preValidation: [app.authenticate, adminGuard],
preHandler: [app.csrfProtection],
},
async (request) => {
const { id } = request.params;
await bookService.deleteBook(id);
await AuditService.logFromRequest(request, {
action: AuditAction.ENTRY_DELETE,
category: AuditCategory.CONTENT,
resourceType: "book",
resourceId: id,
details: `Deleted book with ID: ${id}`,
});
return { success: true };
}
);
@@ -95,12 +125,21 @@ const booksRoutes: FastifyPluginAsync = async (app) => {
app.post<{ Params: { id: string }; Body: CreateCommentDto; Reply: Comment }>(
"/:id/comments",
{
preValidation: [app.authenticate],
preValidation: [app.authenticate, bannedGuard],
preHandler: [app.csrfProtection],
},
async (request) => {
const { id } = request.params;
const userId = request.user.id;
return commentService.createCommentForBook(id, userId, request.body);
const comment = await commentService.createCommentForBook(id, userId, request.body);
await AuditService.logFromRequest(request, {
action: AuditAction.COMMENT_CREATE,
category: AuditCategory.CONTENT,
resourceType: "book",
resourceId: id,
details: `Added comment to book`,
});
return comment;
}
);
@@ -111,10 +150,18 @@ const booksRoutes: FastifyPluginAsync = async (app) => {
"/:id/comments/:commentId",
{
preValidation: [app.authenticate, adminGuard],
preHandler: [app.csrfProtection],
},
async (request) => {
const { commentId } = request.params;
const { id, commentId } = request.params;
await commentService.deleteComment(commentId);
await AuditService.logFromRequest(request, {
action: AuditAction.COMMENT_DELETE,
category: AuditCategory.ADMIN,
resourceType: "book",
resourceId: id,
details: `Deleted comment ${commentId} from book`,
});
return { success: true };
}
);
+53 -6
View File
@@ -5,10 +5,12 @@
*/
import { FastifyPluginAsync } from "fastify";
import { Game, CreateGameDto, UpdateGameDto, Comment, CreateCommentDto } from "@library/shared-types";
import { Game, CreateGameDto, UpdateGameDto, Comment, CreateCommentDto, AuditAction, AuditCategory } from "@library/shared-types";
import { GameService } from "../../services/game.service";
import { CommentService } from "../../services/comment.service";
import { AuditService } from "../../services/audit.service";
import { adminGuard } from "../../middleware/admin-guard";
import { bannedGuard } from "../../middleware/banned-guard";
const gamesRoutes: FastifyPluginAsync = async (app) => {
const gameService = new GameService();
@@ -33,9 +35,18 @@ const gamesRoutes: FastifyPluginAsync = async (app) => {
"/",
{
preValidation: [app.authenticate, adminGuard],
preHandler: [app.csrfProtection],
},
async (request) => {
return gameService.createGame(request.body);
const game = await gameService.createGame(request.body);
await AuditService.logFromRequest(request, {
action: AuditAction.ENTRY_CREATE,
category: AuditCategory.CONTENT,
resourceType: "game",
resourceId: game.id,
details: `Created game: ${game.title}`,
});
return game;
}
);
@@ -48,10 +59,21 @@ const gamesRoutes: FastifyPluginAsync = async (app) => {
"/:id",
{
preValidation: [app.authenticate, adminGuard],
preHandler: [app.csrfProtection],
},
async (request) => {
const { id } = request.params;
return gameService.updateGame(id, request.body);
const game = await gameService.updateGame(id, request.body);
if (game) {
await AuditService.logFromRequest(request, {
action: AuditAction.ENTRY_UPDATE,
category: AuditCategory.CONTENT,
resourceType: "game",
resourceId: id,
details: `Updated game: ${game.title}`,
});
}
return game;
}
);
@@ -60,10 +82,18 @@ const gamesRoutes: FastifyPluginAsync = async (app) => {
"/:id",
{
preValidation: [app.authenticate, adminGuard],
preHandler: [app.csrfProtection],
},
async (request) => {
const { id } = request.params;
await gameService.deleteGame(id);
await AuditService.logFromRequest(request, {
action: AuditAction.ENTRY_DELETE,
category: AuditCategory.CONTENT,
resourceType: "game",
resourceId: id,
details: `Deleted game with ID: ${id}`,
});
return { success: true };
}
);
@@ -81,12 +111,21 @@ const gamesRoutes: FastifyPluginAsync = async (app) => {
app.post<{ Params: { id: string }; Body: CreateCommentDto; Reply: Comment }>(
"/:id/comments",
{
preValidation: [app.authenticate],
preValidation: [app.authenticate, bannedGuard],
preHandler: [app.csrfProtection],
},
async (request) => {
const { id } = request.params;
const userId = request.user.id;
return commentService.createCommentForGame(id, userId, request.body);
const comment = await commentService.createCommentForGame(id, userId, request.body);
await AuditService.logFromRequest(request, {
action: AuditAction.COMMENT_CREATE,
category: AuditCategory.CONTENT,
resourceType: "game",
resourceId: id,
details: `Added comment to game`,
});
return comment;
}
);
@@ -95,10 +134,18 @@ const gamesRoutes: FastifyPluginAsync = async (app) => {
"/:id/comments/:commentId",
{
preValidation: [app.authenticate, adminGuard],
preHandler: [app.csrfProtection],
},
async (request) => {
const { commentId } = request.params;
const { id, commentId } = request.params;
await commentService.deleteComment(commentId);
await AuditService.logFromRequest(request, {
action: AuditAction.COMMENT_DELETE,
category: AuditCategory.ADMIN,
resourceType: "game",
resourceId: id,
details: `Deleted comment ${commentId} from game`,
});
return { success: true };
}
);
+53 -6
View File
@@ -5,10 +5,12 @@
*/
import { FastifyPluginAsync } from "fastify";
import { Manga, CreateMangaDto, UpdateMangaDto, Comment, CreateCommentDto } from "@library/shared-types";
import { Manga, CreateMangaDto, UpdateMangaDto, Comment, CreateCommentDto, AuditAction, AuditCategory } from "@library/shared-types";
import { MangaService } from "../../services/manga.service";
import { CommentService } from "../../services/comment.service";
import { AuditService } from "../../services/audit.service";
import { adminGuard } from "../../middleware/admin-guard";
import { bannedGuard } from "../../middleware/banned-guard";
const mangaRoutes: FastifyPluginAsync = async (app) => {
const mangaService = new MangaService();
@@ -30,9 +32,18 @@ const mangaRoutes: FastifyPluginAsync = async (app) => {
"/",
{
preValidation: [app.authenticate, adminGuard],
preHandler: [app.csrfProtection],
},
async (request) => {
return mangaService.createManga(request.body);
const manga = await mangaService.createManga(request.body);
await AuditService.logFromRequest(request, {
action: AuditAction.ENTRY_CREATE,
category: AuditCategory.CONTENT,
resourceType: "manga",
resourceId: manga.id,
details: `Created manga: ${manga.title}`,
});
return manga;
}
);
@@ -44,10 +55,21 @@ const mangaRoutes: FastifyPluginAsync = async (app) => {
"/:id",
{
preValidation: [app.authenticate, adminGuard],
preHandler: [app.csrfProtection],
},
async (request) => {
const { id } = request.params;
return mangaService.updateManga(id, request.body);
const manga = await mangaService.updateManga(id, request.body);
if (manga) {
await AuditService.logFromRequest(request, {
action: AuditAction.ENTRY_UPDATE,
category: AuditCategory.CONTENT,
resourceType: "manga",
resourceId: id,
details: `Updated manga: ${manga.title}`,
});
}
return manga;
}
);
@@ -55,10 +77,18 @@ const mangaRoutes: FastifyPluginAsync = async (app) => {
"/:id",
{
preValidation: [app.authenticate, adminGuard],
preHandler: [app.csrfProtection],
},
async (request) => {
const { id } = request.params;
await mangaService.deleteManga(id);
await AuditService.logFromRequest(request, {
action: AuditAction.ENTRY_DELETE,
category: AuditCategory.CONTENT,
resourceType: "manga",
resourceId: id,
details: `Deleted manga with ID: ${id}`,
});
return { success: true };
}
);
@@ -74,12 +104,21 @@ const mangaRoutes: FastifyPluginAsync = async (app) => {
app.post<{ Params: { id: string }; Body: CreateCommentDto; Reply: Comment }>(
"/:id/comments",
{
preValidation: [app.authenticate],
preValidation: [app.authenticate, bannedGuard],
preHandler: [app.csrfProtection],
},
async (request) => {
const { id } = request.params;
const userId = request.user.id;
return commentService.createCommentForManga(id, userId, request.body);
const comment = await commentService.createCommentForManga(id, userId, request.body);
await AuditService.logFromRequest(request, {
action: AuditAction.COMMENT_CREATE,
category: AuditCategory.CONTENT,
resourceType: "manga",
resourceId: id,
details: `Added comment to manga`,
});
return comment;
}
);
@@ -87,10 +126,18 @@ const mangaRoutes: FastifyPluginAsync = async (app) => {
"/:id/comments/:commentId",
{
preValidation: [app.authenticate, adminGuard],
preHandler: [app.csrfProtection],
},
async (request) => {
const { commentId } = request.params;
const { id, commentId } = request.params;
await commentService.deleteComment(commentId);
await AuditService.logFromRequest(request, {
action: AuditAction.COMMENT_DELETE,
category: AuditCategory.ADMIN,
resourceType: "manga",
resourceId: id,
details: `Deleted comment ${commentId} from manga`,
});
return { success: true };
}
);
+53 -6
View File
@@ -5,10 +5,12 @@
*/
import { FastifyPluginAsync } from "fastify";
import { Music, CreateMusicDto, UpdateMusicDto, Comment, CreateCommentDto } from "@library/shared-types";
import { Music, CreateMusicDto, UpdateMusicDto, Comment, CreateCommentDto, AuditAction, AuditCategory } from "@library/shared-types";
import { MusicService } from "../../services/music.service";
import { CommentService } from "../../services/comment.service";
import { AuditService } from "../../services/audit.service";
import { adminGuard } from "../../middleware/admin-guard";
import { bannedGuard } from "../../middleware/banned-guard";
const musicRoutes: FastifyPluginAsync = async (app) => {
const musicService = new MusicService();
@@ -39,9 +41,18 @@ const musicRoutes: FastifyPluginAsync = async (app) => {
"/",
{
preValidation: [app.authenticate, adminGuard],
preHandler: [app.csrfProtection],
},
async (request) => {
return musicService.createMusic(request.body);
const music = await musicService.createMusic(request.body);
await AuditService.logFromRequest(request, {
action: AuditAction.ENTRY_CREATE,
category: AuditCategory.CONTENT,
resourceType: "music",
resourceId: music.id,
details: `Created music: ${music.title}`,
});
return music;
}
);
@@ -56,10 +67,21 @@ const musicRoutes: FastifyPluginAsync = async (app) => {
"/:id",
{
preValidation: [app.authenticate, adminGuard],
preHandler: [app.csrfProtection],
},
async (request) => {
const { id } = request.params;
return musicService.updateMusic(id, request.body);
const music = await musicService.updateMusic(id, request.body);
if (music) {
await AuditService.logFromRequest(request, {
action: AuditAction.ENTRY_UPDATE,
category: AuditCategory.CONTENT,
resourceType: "music",
resourceId: id,
details: `Updated music: ${music.title}`,
});
}
return music;
}
);
@@ -70,10 +92,18 @@ const musicRoutes: FastifyPluginAsync = async (app) => {
"/:id",
{
preValidation: [app.authenticate, adminGuard],
preHandler: [app.csrfProtection],
},
async (request) => {
const { id } = request.params;
await musicService.deleteMusic(id);
await AuditService.logFromRequest(request, {
action: AuditAction.ENTRY_DELETE,
category: AuditCategory.CONTENT,
resourceType: "music",
resourceId: id,
details: `Deleted music with ID: ${id}`,
});
return { success: true };
}
);
@@ -95,12 +125,21 @@ const musicRoutes: FastifyPluginAsync = async (app) => {
app.post<{ Params: { id: string }; Body: CreateCommentDto; Reply: Comment }>(
"/:id/comments",
{
preValidation: [app.authenticate],
preValidation: [app.authenticate, bannedGuard],
preHandler: [app.csrfProtection],
},
async (request) => {
const { id } = request.params;
const userId = request.user.id;
return commentService.createCommentForMusic(id, userId, request.body);
const comment = await commentService.createCommentForMusic(id, userId, request.body);
await AuditService.logFromRequest(request, {
action: AuditAction.COMMENT_CREATE,
category: AuditCategory.CONTENT,
resourceType: "music",
resourceId: id,
details: `Added comment to music`,
});
return comment;
}
);
@@ -111,10 +150,18 @@ const musicRoutes: FastifyPluginAsync = async (app) => {
"/:id/comments/:commentId",
{
preValidation: [app.authenticate, adminGuard],
preHandler: [app.csrfProtection],
},
async (request) => {
const { commentId } = request.params;
const { id, commentId } = request.params;
await commentService.deleteComment(commentId);
await AuditService.logFromRequest(request, {
action: AuditAction.COMMENT_DELETE,
category: AuditCategory.ADMIN,
resourceType: "music",
resourceId: id,
details: `Deleted comment ${commentId} from music`,
});
return { success: true };
}
);
+53 -6
View File
@@ -5,10 +5,12 @@
*/
import { FastifyPluginAsync } from "fastify";
import { Show, CreateShowDto, UpdateShowDto, Comment, CreateCommentDto } from "@library/shared-types";
import { Show, CreateShowDto, UpdateShowDto, Comment, CreateCommentDto, AuditAction, AuditCategory } from "@library/shared-types";
import { ShowService } from "../../services/show.service";
import { CommentService } from "../../services/comment.service";
import { AuditService } from "../../services/audit.service";
import { adminGuard } from "../../middleware/admin-guard";
import { bannedGuard } from "../../middleware/banned-guard";
const showsRoutes: FastifyPluginAsync = async (app) => {
const showService = new ShowService();
@@ -30,9 +32,18 @@ const showsRoutes: FastifyPluginAsync = async (app) => {
"/",
{
preValidation: [app.authenticate, adminGuard],
preHandler: [app.csrfProtection],
},
async (request) => {
return showService.createShow(request.body);
const show = await showService.createShow(request.body);
await AuditService.logFromRequest(request, {
action: AuditAction.ENTRY_CREATE,
category: AuditCategory.CONTENT,
resourceType: "show",
resourceId: show.id,
details: `Created show: ${show.title}`,
});
return show;
}
);
@@ -44,10 +55,21 @@ const showsRoutes: FastifyPluginAsync = async (app) => {
"/:id",
{
preValidation: [app.authenticate, adminGuard],
preHandler: [app.csrfProtection],
},
async (request) => {
const { id } = request.params;
return showService.updateShow(id, request.body);
const show = await showService.updateShow(id, request.body);
if (show) {
await AuditService.logFromRequest(request, {
action: AuditAction.ENTRY_UPDATE,
category: AuditCategory.CONTENT,
resourceType: "show",
resourceId: id,
details: `Updated show: ${show.title}`,
});
}
return show;
}
);
@@ -55,10 +77,18 @@ const showsRoutes: FastifyPluginAsync = async (app) => {
"/:id",
{
preValidation: [app.authenticate, adminGuard],
preHandler: [app.csrfProtection],
},
async (request) => {
const { id } = request.params;
await showService.deleteShow(id);
await AuditService.logFromRequest(request, {
action: AuditAction.ENTRY_DELETE,
category: AuditCategory.CONTENT,
resourceType: "show",
resourceId: id,
details: `Deleted show with ID: ${id}`,
});
return { success: true };
}
);
@@ -74,12 +104,21 @@ const showsRoutes: FastifyPluginAsync = async (app) => {
app.post<{ Params: { id: string }; Body: CreateCommentDto; Reply: Comment }>(
"/:id/comments",
{
preValidation: [app.authenticate],
preValidation: [app.authenticate, bannedGuard],
preHandler: [app.csrfProtection],
},
async (request) => {
const { id } = request.params;
const userId = request.user.id;
return commentService.createCommentForShow(id, userId, request.body);
const comment = await commentService.createCommentForShow(id, userId, request.body);
await AuditService.logFromRequest(request, {
action: AuditAction.COMMENT_CREATE,
category: AuditCategory.CONTENT,
resourceType: "show",
resourceId: id,
details: `Added comment to show`,
});
return comment;
}
);
@@ -87,10 +126,18 @@ const showsRoutes: FastifyPluginAsync = async (app) => {
"/:id/comments/:commentId",
{
preValidation: [app.authenticate, adminGuard],
preHandler: [app.csrfProtection],
},
async (request) => {
const { commentId } = request.params;
const { id, commentId } = request.params;
await commentService.deleteComment(commentId);
await AuditService.logFromRequest(request, {
action: AuditAction.COMMENT_DELETE,
category: AuditCategory.ADMIN,
resourceType: "show",
resourceId: id,
details: `Deleted comment ${commentId} from show`,
});
return { success: true };
}
);
+92
View File
@@ -0,0 +1,92 @@
/**
* @copyright 2026 NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { FastifyPluginAsync } from "fastify";
import { User, AuditAction, AuditCategory } from "@library/shared-types";
import { UserService } from "../../services/user.service";
import { AuditService } from "../../services/audit.service";
import { adminGuard } from "../../middleware/admin-guard";
const usersRoutes: FastifyPluginAsync = async (app) => {
const userService = new UserService();
app.get<{ Reply: User[] }>(
"/",
{
preValidation: [app.authenticate, adminGuard],
},
async () => {
return userService.getAllUsers();
}
);
app.get<{ Params: { id: string }; Reply: User | null }>(
"/:id",
{
preValidation: [app.authenticate, adminGuard],
},
async (request) => {
const { id } = request.params;
return userService.getUserById(id);
}
);
app.post<{ Params: { id: string }; Reply: User | { error: string } }>(
"/:id/ban",
{
preValidation: [app.authenticate, adminGuard],
preHandler: [app.csrfProtection],
},
async (request, reply) => {
const { id } = request.params;
const currentUser = request.user as { id: string };
if (currentUser.id === id) {
return reply.code(400).send({ error: "You cannot ban yourself" });
}
const user = await userService.banUser(id);
if (!user) {
return reply.code(404).send({ error: "User not found" });
}
await AuditService.logFromRequest(request, {
action: AuditAction.USER_BAN,
category: AuditCategory.ADMIN,
targetUserId: id,
details: `Banned user: ${user.username}`,
});
return user;
}
);
app.post<{ Params: { id: string }; Reply: User | { error: string } }>(
"/:id/unban",
{
preValidation: [app.authenticate, adminGuard],
preHandler: [app.csrfProtection],
},
async (request, reply) => {
const { id } = request.params;
const user = await userService.unbanUser(id);
if (!user) {
return reply.code(404).send({ error: "User not found" });
}
await AuditService.logFromRequest(request, {
action: AuditAction.USER_UNBAN,
category: AuditCategory.ADMIN,
targetUserId: id,
details: `Unbanned user: ${user.username}`,
});
return user;
}
);
};
export default usersRoutes;
+103
View File
@@ -0,0 +1,103 @@
import type { FastifyRequest } from "fastify";
import { prisma } from "../lib/prisma";
import type { AuditAction, AuditCategory, AuditLogFilters } from "@library/shared-types";
interface AuditLogData {
action: AuditAction;
category: AuditCategory;
userId?: string;
targetUserId?: string;
resourceType?: string;
resourceId?: string;
details?: string;
success?: boolean;
}
export const AuditService = {
async log(data: AuditLogData, request?: FastifyRequest) {
const userAgent = request?.headers["user-agent"] ?? undefined;
return prisma.auditLog.create({
data: {
action: data.action,
category: data.category,
userId: data.userId,
targetUserId: data.targetUserId,
resourceType: data.resourceType,
resourceId: data.resourceId,
details: data.details,
userAgent,
success: data.success ?? true,
},
});
},
async logFromRequest(
request: FastifyRequest,
data: Omit<AuditLogData, "userId">
) {
const userId = (request.user as { id?: string } | undefined)?.id;
return this.log(
{
...data,
userId,
},
request
);
},
async getLogs(filters: AuditLogFilters = {}) {
const { action, category, userId, success, startDate, endDate, page = 1, limit = 50 } = filters;
const where: Record<string, unknown> = {};
if (action) {
where.action = action;
}
if (category) {
where.category = category;
}
if (userId) {
where.userId = userId;
}
if (success !== undefined) {
where.success = success;
}
if (startDate || endDate) {
where.createdAt = {};
if (startDate) {
(where.createdAt as Record<string, Date>).gte = startDate;
}
if (endDate) {
(where.createdAt as Record<string, Date>).lte = endDate;
}
}
const [logs, total] = await Promise.all([
prisma.auditLog.findMany({
where,
orderBy: { createdAt: "desc" },
skip: (page - 1) * limit,
take: limit,
}),
prisma.auditLog.count({ where }),
]);
return {
logs,
total,
page,
limit,
totalPages: Math.ceil(total / limit),
};
},
async getLogsByUser(userId: string, page = 1, limit = 50) {
return this.getLogs({ userId, page, limit });
},
async getSecurityLogs(page = 1, limit = 50) {
return this.getLogs({ category: "SECURITY" as AuditCategory, page, limit });
},
};
+24
View File
@@ -30,6 +30,29 @@ export class AuthService {
return this.app.jwt.verify(token) as JwtPayload;
}
/**
* Get user by ID from database.
*/
async getUserById(id: string): Promise<User | null> {
const dbUser = await this.prisma.user.findUnique({
where: { id },
});
if (!dbUser) {
return null;
}
return {
id: dbUser.id,
discordId: dbUser.discordId,
username: dbUser.username,
email: dbUser.email,
avatarUrl: dbUser.avatar || undefined,
isAdmin: dbUser.isAdmin,
isBanned: dbUser.isBanned,
};
}
/**
* Create or update user from Discord OAuth data.
*/
@@ -64,6 +87,7 @@ export class AuthService {
email: dbUser.email,
avatarUrl: dbUser.avatar || undefined,
isAdmin: dbUser.isAdmin,
isBanned: dbUser.isBanned,
};
}
}
+2 -1
View File
@@ -9,4 +9,5 @@ export { GameService } from "./game.service";
export { BookService } from "./book.service";
export { MusicService } from "./music.service";
export { ShowService } from "./show.service";
export { MangaService } from "./manga.service";
export { MangaService } from "./manga.service";
export { AuditService } from "./audit.service";
+91
View File
@@ -0,0 +1,91 @@
/**
* @copyright 2026 NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { User } from "@library/shared-types";
import { prisma } from "../lib/prisma";
export class UserService {
private prisma = prisma;
async getAllUsers(): Promise<User[]> {
const users = await this.prisma.user.findMany({
orderBy: { username: "asc" },
});
return users.map((user) => ({
id: user.id,
discordId: user.discordId,
username: user.username,
email: user.email,
avatarUrl: user.avatar || undefined,
isAdmin: user.isAdmin,
isBanned: user.isBanned,
}));
}
async getUserById(id: string): Promise<User | null> {
const user = await this.prisma.user.findUnique({
where: { id },
});
if (!user) {
return null;
}
return {
id: user.id,
discordId: user.discordId,
username: user.username,
email: user.email,
avatarUrl: user.avatar || undefined,
isAdmin: user.isAdmin,
isBanned: user.isBanned,
};
}
async banUser(id: string): Promise<User | null> {
const user = await this.prisma.user.update({
where: { id },
data: { isBanned: true },
});
return {
id: user.id,
discordId: user.discordId,
username: user.username,
email: user.email,
avatarUrl: user.avatar || undefined,
isAdmin: user.isAdmin,
isBanned: user.isBanned,
};
}
async unbanUser(id: string): Promise<User | null> {
const user = await this.prisma.user.update({
where: { id },
data: { isBanned: false },
});
return {
id: user.id,
discordId: user.discordId,
username: user.username,
email: user.email,
avatarUrl: user.avatar || undefined,
isAdmin: user.isAdmin,
isBanned: user.isBanned,
};
}
async isUserBanned(id: string): Promise<boolean> {
const user = await this.prisma.user.findUnique({
where: { id },
select: { isBanned: true },
});
return user?.isBanned ?? false;
}
}