From 0a654f423ab632ab9f4d953e38e7edb52f911e77 Mon Sep 17 00:00:00 2001 From: Naomi Carrigan Date: Wed, 4 Feb 2026 16:48:08 -0800 Subject: [PATCH] feat: security and auditing --- api/prisma/schema.prisma | 38 ++ api/src/app/app.ts | 40 +- api/src/app/middleware/admin-guard.ts | 14 +- api/src/app/middleware/banned-guard.ts | 30 ++ api/src/app/plugins/auth.ts | 28 +- api/src/app/plugins/cors.ts | 26 ++ api/src/app/plugins/csrf.ts | 26 ++ api/src/app/plugins/helmet.ts | 27 ++ api/src/app/plugins/rate-limit.ts | 37 ++ api/src/app/routes/art/index.ts | 59 ++- api/src/app/routes/audit/index.ts | 85 ++++ api/src/app/routes/auth/index.ts | 61 ++- api/src/app/routes/books/index.ts | 59 ++- api/src/app/routes/games/index.ts | 59 ++- api/src/app/routes/manga/index.ts | 59 ++- api/src/app/routes/music/index.ts | 59 ++- api/src/app/routes/shows/index.ts | 59 ++- api/src/app/routes/users/index.ts | 92 +++++ api/src/app/services/audit.service.ts | 103 +++++ api/src/app/services/auth.service.ts | 24 ++ api/src/app/services/index.ts | 3 +- api/src/app/services/user.service.ts | 91 +++++ apps/frontend/src/app/app.routes.ts | 8 + .../components/admin/admin-audit.component.ts | 373 ++++++++++++++++++ .../components/admin/admin-users.component.ts | 268 +++++++++++++ .../components/art/art-gallery.component.ts | 39 +- .../components/books/books-list.component.ts | 39 +- .../components/games/games-list.component.ts | 39 +- .../app/components/header/header.component.ts | 11 +- .../components/manga/manga-list.component.ts | 39 +- .../components/music/music-list.component.ts | 39 +- .../components/shows/shows-list.component.ts | 39 +- apps/frontend/src/app/services/api.service.ts | 81 ++-- .../src/app/services/audit.service.ts | 84 ++++ .../frontend/src/app/services/auth.service.ts | 1 + .../src/app/services/sanitize.service.ts | 28 ++ .../frontend/src/app/services/user.service.ts | 29 ++ package.json | 6 +- pnpm-lock.yaml | 102 +++-- shared-types/src/index.ts | 3 +- shared-types/src/lib/audit.types.ts | 47 +++ shared-types/src/lib/auth.types.ts | 1 + 42 files changed, 2195 insertions(+), 160 deletions(-) create mode 100644 api/src/app/middleware/banned-guard.ts create mode 100644 api/src/app/plugins/cors.ts create mode 100644 api/src/app/plugins/csrf.ts create mode 100644 api/src/app/plugins/helmet.ts create mode 100644 api/src/app/plugins/rate-limit.ts create mode 100644 api/src/app/routes/audit/index.ts create mode 100644 api/src/app/routes/users/index.ts create mode 100644 api/src/app/services/audit.service.ts create mode 100644 api/src/app/services/user.service.ts create mode 100644 apps/frontend/src/app/components/admin/admin-audit.component.ts create mode 100644 apps/frontend/src/app/components/admin/admin-users.component.ts create mode 100644 apps/frontend/src/app/services/audit.service.ts create mode 100644 apps/frontend/src/app/services/sanitize.service.ts create mode 100644 apps/frontend/src/app/services/user.service.ts create mode 100644 shared-types/src/lib/audit.types.ts diff --git a/api/prisma/schema.prisma b/api/prisma/schema.prisma index 03c622c..ebc2739 100644 --- a/api/prisma/schema.prisma +++ b/api/prisma/schema.prisma @@ -152,6 +152,7 @@ model User { email String @unique avatar String? isAdmin Boolean @default(false) + isBanned Boolean @default(false) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt comments Comment[] @@ -177,3 +178,40 @@ model Comment { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } + +model AuditLog { + id String @id @default(auto()) @map("_id") @db.ObjectId + action AuditAction + category AuditCategory + userId String? @db.ObjectId + targetUserId String? @db.ObjectId + resourceType String? + resourceId String? + details String? + userAgent String? + success Boolean @default(true) + createdAt DateTime @default(now()) +} + +enum AuditAction { + LOGIN + LOGOUT + LOGIN_FAILED + COMMENT_CREATE + COMMENT_DELETE + ENTRY_CREATE + ENTRY_UPDATE + ENTRY_DELETE + USER_BAN + USER_UNBAN + RATE_LIMIT_EXCEEDED + CSRF_VALIDATION_FAILED + UNAUTHORIZED_ACCESS +} + +enum AuditCategory { + AUTH + CONTENT + ADMIN + SECURITY +} diff --git a/api/src/app/app.ts b/api/src/app/app.ts index 3f23081..37d7d4e 100644 --- a/api/src/app/app.ts +++ b/api/src/app/app.ts @@ -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 diff --git a/api/src/app/middleware/admin-guard.ts b/api/src/app/middleware/admin-guard.ts index f493308..84371ee 100644 --- a/api/src/app/middleware/admin-guard.ts +++ b/api/src/app/middleware/admin-guard.ts @@ -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 { - 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" }); } } \ No newline at end of file diff --git a/api/src/app/middleware/banned-guard.ts b/api/src/app/middleware/banned-guard.ts new file mode 100644 index 0000000..02aedfb --- /dev/null +++ b/api/src/app/middleware/banned-guard.ts @@ -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 { + 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" }); + } +} diff --git a/api/src/app/plugins/auth.ts b/api/src/app/plugins/auth.ts index 668cbbf..7b7689b 100644 --- a/api/src/app/plugins/auth.ts +++ b/api/src/app/plugins/auth.ts @@ -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", diff --git a/api/src/app/plugins/cors.ts b/api/src/app/plugins/cors.ts new file mode 100644 index 0000000..4760b30 --- /dev/null +++ b/api/src/app/plugins/cors.ts @@ -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); diff --git a/api/src/app/plugins/csrf.ts b/api/src/app/plugins/csrf.ts new file mode 100644 index 0000000..b44136e --- /dev/null +++ b/api/src/app/plugins/csrf.ts @@ -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); diff --git a/api/src/app/plugins/helmet.ts b/api/src/app/plugins/helmet.ts new file mode 100644 index 0000000..53e9f6f --- /dev/null +++ b/api/src/app/plugins/helmet.ts @@ -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); diff --git a/api/src/app/plugins/rate-limit.ts b/api/src/app/plugins/rate-limit.ts new file mode 100644 index 0000000..3f2ba5f --- /dev/null +++ b/api/src/app/plugins/rate-limit.ts @@ -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); diff --git a/api/src/app/routes/art/index.ts b/api/src/app/routes/art/index.ts index 3adeb9d..63f5b1b 100644 --- a/api/src/app/routes/art/index.ts +++ b/api/src/app/routes/art/index.ts @@ -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 }; } ); diff --git a/api/src/app/routes/audit/index.ts b/api/src/app/routes/audit/index.ts new file mode 100644 index 0000000..da3bf26 --- /dev/null +++ b/api/src/app/routes/audit/index.ts @@ -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; diff --git a/api/src/app/routes/auth/index.ts b/api/src/app/routes/auth/index.ts index a83728d..178721f 100644 --- a/api/src/app/routes/auth/index.ts +++ b/api/src/app/routes/auth/index.ts @@ -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; \ No newline at end of file diff --git a/api/src/app/routes/books/index.ts b/api/src/app/routes/books/index.ts index 07be569..3cf7275 100644 --- a/api/src/app/routes/books/index.ts +++ b/api/src/app/routes/books/index.ts @@ -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 }; } ); diff --git a/api/src/app/routes/games/index.ts b/api/src/app/routes/games/index.ts index cc6e2d8..5d77843 100644 --- a/api/src/app/routes/games/index.ts +++ b/api/src/app/routes/games/index.ts @@ -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 }; } ); diff --git a/api/src/app/routes/manga/index.ts b/api/src/app/routes/manga/index.ts index 0e0f2d3..8705395 100644 --- a/api/src/app/routes/manga/index.ts +++ b/api/src/app/routes/manga/index.ts @@ -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 }; } ); diff --git a/api/src/app/routes/music/index.ts b/api/src/app/routes/music/index.ts index fe73704..ca3ee3a 100644 --- a/api/src/app/routes/music/index.ts +++ b/api/src/app/routes/music/index.ts @@ -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 }; } ); diff --git a/api/src/app/routes/shows/index.ts b/api/src/app/routes/shows/index.ts index 4eb2850..2f189bd 100644 --- a/api/src/app/routes/shows/index.ts +++ b/api/src/app/routes/shows/index.ts @@ -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 }; } ); diff --git a/api/src/app/routes/users/index.ts b/api/src/app/routes/users/index.ts new file mode 100644 index 0000000..485fd56 --- /dev/null +++ b/api/src/app/routes/users/index.ts @@ -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; diff --git a/api/src/app/services/audit.service.ts b/api/src/app/services/audit.service.ts new file mode 100644 index 0000000..aa27859 --- /dev/null +++ b/api/src/app/services/audit.service.ts @@ -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 + ) { + 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 = {}; + + 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).gte = startDate; + } + if (endDate) { + (where.createdAt as Record).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 }); + }, +}; diff --git a/api/src/app/services/auth.service.ts b/api/src/app/services/auth.service.ts index a365069..fddeda8 100644 --- a/api/src/app/services/auth.service.ts +++ b/api/src/app/services/auth.service.ts @@ -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 { + 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, }; } } diff --git a/api/src/app/services/index.ts b/api/src/app/services/index.ts index 035938f..5efb715 100644 --- a/api/src/app/services/index.ts +++ b/api/src/app/services/index.ts @@ -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"; \ No newline at end of file +export { MangaService } from "./manga.service"; +export { AuditService } from "./audit.service"; \ No newline at end of file diff --git a/api/src/app/services/user.service.ts b/api/src/app/services/user.service.ts new file mode 100644 index 0000000..3126a80 --- /dev/null +++ b/api/src/app/services/user.service.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + const user = await this.prisma.user.findUnique({ + where: { id }, + select: { isBanned: true }, + }); + + return user?.isBanned ?? false; + } +} diff --git a/apps/frontend/src/app/app.routes.ts b/apps/frontend/src/app/app.routes.ts index 7a99108..93d97c8 100644 --- a/apps/frontend/src/app/app.routes.ts +++ b/apps/frontend/src/app/app.routes.ts @@ -29,6 +29,14 @@ export const appRoutes: Route[] = [ path: 'manga', loadComponent: () => import('./components/manga/manga-list.component').then(m => m.MangaListComponent) }, + { + path: 'admin/users', + loadComponent: () => import('./components/admin/admin-users.component').then(m => m.AdminUsersComponent) + }, + { + path: 'admin/audit', + loadComponent: () => import('./components/admin/admin-audit.component').then(m => m.AdminAuditComponent) + }, { path: '**', redirectTo: '' diff --git a/apps/frontend/src/app/components/admin/admin-audit.component.ts b/apps/frontend/src/app/components/admin/admin-audit.component.ts new file mode 100644 index 0000000..a2c3409 --- /dev/null +++ b/apps/frontend/src/app/components/admin/admin-audit.component.ts @@ -0,0 +1,373 @@ +import { Component, OnInit, inject, signal } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { AuditLogService } from '../../services/audit.service'; +import { AuthService } from '../../services/auth.service'; +import { Router } from '@angular/router'; +import type { AuditLog, AuditAction, AuditCategory } from '@library/shared-types'; + +@Component({ + selector: 'app-admin-audit', + standalone: true, + imports: [CommonModule, FormsModule], + template: ` +
+

Audit Logs

+ + @if (!authService.isAuthenticated() || !authService.user()?.isAdmin) { +
+

You must be an admin to view this page.

+
+ } @else { +
+
+ + +
+ +
+ + +
+ +
+ + +
+ + +
+ + @if (loading()) { +
Loading audit logs...
+ } @else if (logs().length === 0) { +
No audit logs found.
+ } @else { +
+ + + + + + + + + + + + @for (log of logs(); track log.id) { + + + + + + + + } + +
TimeCategoryActionDetailsStatus
{{ formatDate(log.createdAt) }} + + {{ auditService.getCategoryLabel(log.category) }} + + {{ auditService.getActionLabel(log.action) }}{{ log.details ?? '-' }} + + {{ log.success ? '✓' : '✗' }} + +
+
+ + + } + } +
+ `, + styles: [` + .audit-container { + max-width: 1400px; + margin: 0 auto; + padding: 2rem; + } + + h1 { + color: #2d1b4e; + margin-bottom: 1.5rem; + } + + .unauthorized { + text-align: center; + padding: 2rem; + background: #fef2f2; + border-radius: 8px; + color: #991b1b; + } + + .filters { + display: flex; + gap: 1rem; + margin-bottom: 1.5rem; + flex-wrap: wrap; + align-items: flex-end; + background: rgba(255, 255, 255, 0.8); + padding: 1rem; + border-radius: 8px; + border: 1px solid #e5e7eb; + } + + .filter-group { + display: flex; + flex-direction: column; + gap: 0.25rem; + } + + .filter-group label { + font-size: 0.85rem; + color: #6b7280; + } + + .filter-group select { + padding: 0.5rem; + border: 1px solid #d1d5db; + border-radius: 4px; + min-width: 150px; + } + + .refresh-btn { + padding: 0.5rem 1rem; + background: #8b5cf6; + color: white; + border: none; + border-radius: 4px; + cursor: pointer; + } + + .refresh-btn:hover { + background: #7c3aed; + } + + .loading, .no-logs { + text-align: center; + padding: 2rem; + color: #6b7280; + } + + .logs-table-container { + overflow-x: auto; + background: white; + border-radius: 8px; + border: 1px solid #e5e7eb; + } + + .logs-table { + width: 100%; + border-collapse: collapse; + } + + .logs-table th, + .logs-table td { + padding: 0.75rem 1rem; + text-align: left; + border-bottom: 1px solid #e5e7eb; + } + + .logs-table th { + background: #f9fafb; + font-weight: 600; + color: #374151; + } + + .logs-table tr:hover { + background: #f9fafb; + } + + .logs-table tr.failed { + background: #fef2f2; + } + + .logs-table tr.failed:hover { + background: #fee2e2; + } + + .time { + white-space: nowrap; + font-size: 0.85rem; + color: #6b7280; + } + + .category-badge { + display: inline-block; + padding: 0.25rem 0.5rem; + border-radius: 4px; + color: white; + font-size: 0.75rem; + font-weight: 500; + } + + .details { + max-width: 400px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .status-badge { + display: inline-block; + width: 24px; + height: 24px; + border-radius: 50%; + text-align: center; + line-height: 24px; + font-weight: bold; + } + + .status-badge.success { + background: #dcfce7; + color: #16a34a; + } + + .status-badge.failed { + background: #fee2e2; + color: #dc2626; + } + + .pagination { + display: flex; + justify-content: center; + align-items: center; + gap: 1rem; + margin-top: 1rem; + padding: 1rem; + } + + .pagination button { + padding: 0.5rem 1rem; + background: #8b5cf6; + color: white; + border: none; + border-radius: 4px; + cursor: pointer; + } + + .pagination button:disabled { + background: #d1d5db; + cursor: not-allowed; + } + + .pagination button:not(:disabled):hover { + background: #7c3aed; + } + `], +}) +export class AdminAuditComponent implements OnInit { + authService = inject(AuthService); + auditService = inject(AuditLogService); + private router = inject(Router); + + logs = signal([]); + loading = signal(true); + currentPage = signal(1); + totalPages = signal(1); + + selectedCategory = ''; + selectedAction = ''; + selectedSuccess = ''; + + ngOnInit() { + if (!this.authService.isAuthenticated() || !this.authService.user()?.isAdmin) { + this.router.navigate(['/']); + return; + } + this.loadLogs(); + } + + async loadLogs() { + this.loading.set(true); + try { + const filters: Record = { + page: this.currentPage(), + limit: 50, + }; + + if (this.selectedCategory) { + filters['category'] = this.selectedCategory as AuditCategory; + } + if (this.selectedAction) { + filters['action'] = this.selectedAction as AuditAction; + } + if (this.selectedSuccess !== '') { + filters['success'] = this.selectedSuccess === 'true'; + } + + const response = await this.auditService.getLogs(filters); + this.logs.set(response.logs); + this.totalPages.set(response.totalPages); + } catch (error) { + console.error('Failed to load audit logs:', error); + } finally { + this.loading.set(false); + } + } + + goToPage(page: number) { + this.currentPage.set(page); + this.loadLogs(); + } + + formatDate(date: Date | string): string { + const d = new Date(date); + return d.toLocaleString(); + } +} diff --git a/apps/frontend/src/app/components/admin/admin-users.component.ts b/apps/frontend/src/app/components/admin/admin-users.component.ts new file mode 100644 index 0000000..ff7c30e --- /dev/null +++ b/apps/frontend/src/app/components/admin/admin-users.component.ts @@ -0,0 +1,268 @@ +/** + * @copyright 2026 NHCarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ + +import { Component, OnInit, inject, signal } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { Router } from '@angular/router'; +import { UserService } from '../../services/user.service'; +import { AuthService } from '../../services/auth.service'; +import { User } from '@library/shared-types'; + +@Component({ + selector: 'app-admin-users', + standalone: true, + imports: [CommonModule], + template: ` +
+

User Management

+ + @if (loading()) { +

Loading users...

+ } @else if (error()) { +

{{ error() }}

+ } @else { +
+ @for (user of users(); track user.id) { +
+ + +
+ } @empty { +

No users found.

+ } +
+ } +
+ `, + styles: [` + .admin-container { + max-width: 800px; + margin: 0 auto; + padding: 2rem; + } + + h2 { + color: var(--witch-purple); + margin-bottom: 2rem; + } + + .loading, .error, .no-users { + text-align: center; + padding: 2rem; + color: var(--witch-mauve); + } + + .error { + color: var(--witch-rose); + } + + .users-list { + display: flex; + flex-direction: column; + gap: 1rem; + } + + .user-card { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1rem 1.5rem; + background: var(--witch-moon); + border-radius: 8px; + box-shadow: 0 2px 8px var(--witch-shadow); + transition: all 0.3s; + } + + .user-card.banned { + opacity: 0.7; + background: var(--witch-lavender); + } + + .user-info { + display: flex; + align-items: center; + gap: 1rem; + } + + .avatar { + width: 48px; + height: 48px; + border-radius: 50%; + object-fit: cover; + } + + .avatar-placeholder { + width: 48px; + height: 48px; + border-radius: 50%; + background: var(--witch-purple); + color: var(--witch-moon); + display: flex; + align-items: center; + justify-content: center; + font-weight: bold; + font-size: 1.2rem; + } + + .user-details { + display: flex; + flex-direction: column; + gap: 0.25rem; + } + + .username { + font-weight: 600; + color: var(--witch-purple); + } + + .email { + font-size: 0.85rem; + color: var(--witch-mauve); + } + + .admin-badge { + display: inline-block; + background: var(--witch-rose); + color: var(--witch-moon); + padding: 0.15rem 0.5rem; + border-radius: 4px; + font-size: 0.75rem; + width: fit-content; + } + + .banned-badge { + display: inline-block; + background: var(--witch-plum); + color: var(--witch-moon); + padding: 0.15rem 0.5rem; + border-radius: 4px; + font-size: 0.75rem; + width: fit-content; + } + + .user-actions { + display: flex; + gap: 0.5rem; + } + + .btn { + padding: 0.5rem 1rem; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 0.9rem; + transition: all 0.3s; + } + + .btn:hover { + transform: translateY(-2px); + box-shadow: 0 4px 8px var(--witch-shadow); + } + + .btn-ban { + background: var(--witch-rose); + color: var(--witch-moon); + } + + .btn-ban:hover { + background: var(--witch-plum); + } + + .btn-unban { + background: var(--witch-purple); + color: var(--witch-moon); + } + + .btn-unban:hover { + background: var(--witch-mauve); + color: var(--witch-purple); + } + `] +}) +export class AdminUsersComponent implements OnInit { + private userService = inject(UserService); + private authService = inject(AuthService); + private router = inject(Router); + + users = signal([]); + loading = signal(true); + error = signal(null); + + ngOnInit(): void { + if (!this.authService.isAdmin()) { + this.router.navigate(['/']); + return; + } + + this.loadUsers(); + } + + private loadUsers(): void { + this.loading.set(true); + this.error.set(null); + + this.userService.getAllUsers().subscribe({ + next: (users) => { + this.users.set(users); + this.loading.set(false); + }, + error: (err) => { + this.error.set(err.message ?? 'Failed to load users'); + this.loading.set(false); + } + }); + } + + banUser(userId: string): void { + this.userService.banUser(userId).subscribe({ + next: (updatedUser) => { + this.users.update(users => + users.map(u => u.id === userId ? updatedUser : u) + ); + }, + error: (err) => { + this.error.set(err.message ?? 'Failed to ban user'); + } + }); + } + + unbanUser(userId: string): void { + this.userService.unbanUser(userId).subscribe({ + next: (updatedUser) => { + this.users.update(users => + users.map(u => u.id === userId ? updatedUser : u) + ); + }, + error: (err) => { + this.error.set(err.message ?? 'Failed to unban user'); + } + }); + } +} diff --git a/apps/frontend/src/app/components/art/art-gallery.component.ts b/apps/frontend/src/app/components/art/art-gallery.component.ts index 811fbd4..4adc988 100644 --- a/apps/frontend/src/app/components/art/art-gallery.component.ts +++ b/apps/frontend/src/app/components/art/art-gallery.component.ts @@ -10,6 +10,7 @@ import { FormsModule } from '@angular/forms'; import { ArtService } from '../../services/art.service'; import { AuthService } from '../../services/auth.service'; import { CommentsService } from '../../services/comments.service'; +import { SanitizeService } from '../../services/sanitize.service'; import { Art, CreateArtDto, UpdateArtDto, Comment } from '@library/shared-types'; @Component({ @@ -210,15 +211,21 @@ import { Art, CreateArtDto, UpdateArtDto, Comment } from '@library/shared-types' @if (expandedComments()[art.id]) {
@if (authService.isAuthenticated()) { -
- - -
+ @if (authService.user()?.isBanned) { +
+ You have been banned from commenting. +
+ } @else { +
+ + +
+ } } @if (commentsLoading()[art.id]) { @@ -236,7 +243,7 @@ import { Art, CreateArtDto, UpdateArtDto, Comment } from '@library/shared-types' }
-
+
} @empty {
No comments yet. Be the first to comment!
@@ -621,12 +628,24 @@ import { Art, CreateArtDto, UpdateArtDto, Comment } from '@library/shared-types' color: var(--witch-mauve); font-size: 0.9rem; } + + .banned-notice { + background: #fef2f2; + border: 1px solid #fecaca; + color: #991b1b; + padding: 0.75rem 1rem; + border-radius: 4px; + text-align: center; + margin-bottom: 1rem; + font-size: 0.9rem; + } `] }) export class ArtGalleryComponent implements OnInit { artService = inject(ArtService); authService = inject(AuthService); commentsService = inject(CommentsService); + sanitizeService = inject(SanitizeService); artPieces = signal([]); loading = signal(true); diff --git a/apps/frontend/src/app/components/books/books-list.component.ts b/apps/frontend/src/app/components/books/books-list.component.ts index 02347c2..37886ab 100644 --- a/apps/frontend/src/app/components/books/books-list.component.ts +++ b/apps/frontend/src/app/components/books/books-list.component.ts @@ -10,6 +10,7 @@ import { FormsModule } from '@angular/forms'; import { BooksService } from '../../services/books.service'; import { AuthService } from '../../services/auth.service'; import { CommentsService } from '../../services/comments.service'; +import { SanitizeService } from '../../services/sanitize.service'; import { Book, BookStatus, CreateBookDto, UpdateBookDto, Comment } from '@library/shared-types'; @Component({ @@ -317,15 +318,21 @@ import { Book, BookStatus, CreateBookDto, UpdateBookDto, Comment } from '@librar @if (expandedComments()[book.id]) {
@if (authService.isAuthenticated()) { -
- - -
+ @if (authService.user()?.isBanned) { +
+ You have been banned from commenting. +
+ } @else { +
+ + +
+ } } @if (commentsLoading()[book.id]) { @@ -343,7 +350,7 @@ import { Book, BookStatus, CreateBookDto, UpdateBookDto, Comment } from '@librar }
-
+
} @empty {
No comments yet. Be the first to comment!
@@ -703,6 +710,17 @@ import { Book, BookStatus, CreateBookDto, UpdateBookDto, Comment } from '@librar font-size: 0.9rem; } + .banned-notice { + background: #fef2f2; + border: 1px solid #fecaca; + color: #991b1b; + padding: 0.75rem 1rem; + border-radius: 4px; + text-align: center; + margin-bottom: 1rem; + font-size: 0.9rem; + } + .image-preview { margin-top: 0.5rem; display: flex; @@ -741,6 +759,7 @@ export class BooksListComponent implements OnInit { booksService = inject(BooksService); authService = inject(AuthService); commentsService = inject(CommentsService); + sanitizeService = inject(SanitizeService); books = signal([]); loading = signal(true); diff --git a/apps/frontend/src/app/components/games/games-list.component.ts b/apps/frontend/src/app/components/games/games-list.component.ts index 89b267a..f5e9e55 100644 --- a/apps/frontend/src/app/components/games/games-list.component.ts +++ b/apps/frontend/src/app/components/games/games-list.component.ts @@ -10,6 +10,7 @@ import { FormsModule } from '@angular/forms'; import { GamesService } from '../../services/games.service'; import { AuthService } from '../../services/auth.service'; import { CommentsService } from '../../services/comments.service'; +import { SanitizeService } from '../../services/sanitize.service'; import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment } from '@library/shared-types'; @Component({ @@ -282,15 +283,21 @@ import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment } from '@librar @if (expandedComments()[game.id]) {
@if (authService.isAuthenticated()) { -
- - -
+ @if (authService.user()?.isBanned) { +
+ You have been banned from commenting. +
+ } @else { +
+ + +
+ } } @if (commentsLoading()[game.id]) { @@ -308,7 +315,7 @@ import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment } from '@librar }
-
+
} @empty {
No comments yet. Be the first to comment!
@@ -582,6 +589,17 @@ import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment } from '@librar font-size: 0.9rem; } + .banned-notice { + background: #fef2f2; + border: 1px solid #fecaca; + color: #991b1b; + padding: 0.75rem 1rem; + border-radius: 4px; + text-align: center; + margin-bottom: 1rem; + font-size: 0.9rem; + } + .image-preview { margin-top: 0.5rem; display: flex; @@ -620,6 +638,7 @@ export class GamesListComponent implements OnInit { gamesService = inject(GamesService); authService = inject(AuthService); commentsService = inject(CommentsService); + sanitizeService = inject(SanitizeService); games = signal([]); loading = signal(true); diff --git a/apps/frontend/src/app/components/header/header.component.ts b/apps/frontend/src/app/components/header/header.component.ts index f0795a0..a5156f3 100644 --- a/apps/frontend/src/app/components/header/header.component.ts +++ b/apps/frontend/src/app/components/header/header.component.ts @@ -33,7 +33,8 @@ import { AuthService } from '../../services/auth.service'; @if (authService.user(); as user) { Welcome, {{ user.username }}! @if (user.isAdmin) { - Admin + Users + Audit } } @else { @@ -111,6 +112,14 @@ import { AuthService } from '../../services/auth.service'; padding: 0.25rem 0.5rem; border-radius: 4px; font-size: 0.8rem; + text-decoration: none; + cursor: pointer; + transition: all 0.3s; + } + + .admin-badge:hover { + background-color: var(--witch-plum); + transform: translateY(-2px); } .btn { diff --git a/apps/frontend/src/app/components/manga/manga-list.component.ts b/apps/frontend/src/app/components/manga/manga-list.component.ts index 46a5ade..e65fea3 100644 --- a/apps/frontend/src/app/components/manga/manga-list.component.ts +++ b/apps/frontend/src/app/components/manga/manga-list.component.ts @@ -10,6 +10,7 @@ import { FormsModule } from '@angular/forms'; import { MangaService } from '../../services/manga.service'; import { AuthService } from '../../services/auth.service'; import { CommentsService } from '../../services/comments.service'; +import { SanitizeService } from '../../services/sanitize.service'; import { Manga, MangaStatus, CreateMangaDto, UpdateMangaDto, Comment } from '@library/shared-types'; @Component({ @@ -282,15 +283,21 @@ import { Manga, MangaStatus, CreateMangaDto, UpdateMangaDto, Comment } from '@li @if (expandedComments()[manga.id]) {
@if (authService.isAuthenticated()) { -
- - -
+ @if (authService.user()?.isBanned) { +
+ You have been banned from commenting. +
+ } @else { +
+ + +
+ } } @if (commentsLoading()[manga.id]) { @@ -308,7 +315,7 @@ import { Manga, MangaStatus, CreateMangaDto, UpdateMangaDto, Comment } from '@li }
-
+
} @empty {
No comments yet. Be the first to comment!
@@ -584,6 +591,17 @@ import { Manga, MangaStatus, CreateMangaDto, UpdateMangaDto, Comment } from '@li font-size: 0.9rem; } + .banned-notice { + background: #fef2f2; + border: 1px solid #fecaca; + color: #991b1b; + padding: 0.75rem 1rem; + border-radius: 4px; + text-align: center; + margin-bottom: 1rem; + font-size: 0.9rem; + } + .image-preview { margin-top: 0.5rem; display: flex; @@ -622,6 +640,7 @@ export class MangaListComponent implements OnInit { mangaService = inject(MangaService); authService = inject(AuthService); commentsService = inject(CommentsService); + sanitizeService = inject(SanitizeService); mangaList = signal([]); loading = signal(true); diff --git a/apps/frontend/src/app/components/music/music-list.component.ts b/apps/frontend/src/app/components/music/music-list.component.ts index 16dd27f..8682b0d 100644 --- a/apps/frontend/src/app/components/music/music-list.component.ts +++ b/apps/frontend/src/app/components/music/music-list.component.ts @@ -10,6 +10,7 @@ import { FormsModule } from '@angular/forms'; import { MusicService } from '../../services/music.service'; import { AuthService } from '../../services/auth.service'; import { CommentsService } from '../../services/comments.service'; +import { SanitizeService } from '../../services/sanitize.service'; import { Music, MusicStatus, MusicType, CreateMusicDto, UpdateMusicDto, Comment } from '@library/shared-types'; @Component({ @@ -355,15 +356,21 @@ import { Music, MusicStatus, MusicType, CreateMusicDto, UpdateMusicDto, Comment @if (expandedComments()[music.id]) {
@if (authService.isAuthenticated()) { -
- - -
+ @if (authService.user()?.isBanned) { +
+ You have been banned from commenting. +
+ } @else { +
+ + +
+ } } @if (commentsLoading()[music.id]) { @@ -381,7 +388,7 @@ import { Music, MusicStatus, MusicType, CreateMusicDto, UpdateMusicDto, Comment }
-
+
} @empty {
No comments yet. Be the first to comment!
@@ -775,6 +782,17 @@ import { Music, MusicStatus, MusicType, CreateMusicDto, UpdateMusicDto, Comment font-size: 0.9rem; } + .banned-notice { + background: #fef2f2; + border: 1px solid #fecaca; + color: #991b1b; + padding: 0.75rem 1rem; + border-radius: 4px; + text-align: center; + margin-bottom: 1rem; + font-size: 0.9rem; + } + .image-preview { margin-top: 0.5rem; display: flex; @@ -813,6 +831,7 @@ export class MusicListComponent implements OnInit { musicService = inject(MusicService); authService = inject(AuthService); commentsService = inject(CommentsService); + sanitizeService = inject(SanitizeService); music = signal([]); loading = signal(true); diff --git a/apps/frontend/src/app/components/shows/shows-list.component.ts b/apps/frontend/src/app/components/shows/shows-list.component.ts index b17e2f1..f3c7090 100644 --- a/apps/frontend/src/app/components/shows/shows-list.component.ts +++ b/apps/frontend/src/app/components/shows/shows-list.component.ts @@ -10,6 +10,7 @@ import { FormsModule } from '@angular/forms'; import { ShowsService } from '../../services/shows.service'; import { AuthService } from '../../services/auth.service'; import { CommentsService } from '../../services/comments.service'; +import { SanitizeService } from '../../services/sanitize.service'; import { Show, ShowStatus, ShowType, CreateShowDto, UpdateShowDto, Comment } from '@library/shared-types'; @Component({ @@ -278,15 +279,21 @@ import { Show, ShowStatus, ShowType, CreateShowDto, UpdateShowDto, Comment } fro @if (expandedComments()[show.id]) {
@if (authService.isAuthenticated()) { -
- - -
+ @if (authService.user()?.isBanned) { +
+ You have been banned from commenting. +
+ } @else { +
+ + +
+ } } @if (commentsLoading()[show.id]) { @@ -304,7 +311,7 @@ import { Show, ShowStatus, ShowType, CreateShowDto, UpdateShowDto, Comment } fro }
-
+
} @empty {
No comments yet. Be the first to comment!
@@ -579,6 +586,17 @@ import { Show, ShowStatus, ShowType, CreateShowDto, UpdateShowDto, Comment } fro font-size: 0.9rem; } + .banned-notice { + background: #fef2f2; + border: 1px solid #fecaca; + color: #991b1b; + padding: 0.75rem 1rem; + border-radius: 4px; + text-align: center; + margin-bottom: 1rem; + font-size: 0.9rem; + } + .image-preview { margin-top: 0.5rem; display: flex; @@ -617,6 +635,7 @@ export class ShowsListComponent implements OnInit { showsService = inject(ShowsService); authService = inject(AuthService); commentsService = inject(CommentsService); + sanitizeService = inject(SanitizeService); shows = signal([]); loading = signal(true); diff --git a/apps/frontend/src/app/services/api.service.ts b/apps/frontend/src/app/services/api.service.ts index 533ac62..ecb4f3f 100644 --- a/apps/frontend/src/app/services/api.service.ts +++ b/apps/frontend/src/app/services/api.service.ts @@ -4,50 +4,85 @@ * @author Naomi Carrigan */ -import { Injectable } from '@angular/core'; +import { Injectable, signal, inject } from '@angular/core'; import { HttpClient, HttpHeaders } from '@angular/common/http'; -import { Observable } from 'rxjs'; +import { Observable, switchMap, tap, of } from 'rxjs'; import { environment } from '../../environments/environment'; +interface CsrfTokenResponse { + csrfToken: string; +} + @Injectable({ providedIn: 'root' }) export class ApiService { + private http = inject(HttpClient); private readonly apiUrl = environment.apiUrl; - - constructor(private http: HttpClient) {} + private csrfToken = signal(null); private getHeaders(): HttpHeaders { - // Auth token is sent as httpOnly cookie, no need to add manually - return new HttpHeaders({ + const headers: Record = { 'Content-Type': 'application/json' - }); + }; + + const token = this.csrfToken(); + if (token) { + headers['X-CSRF-Token'] = token; + } + + return new HttpHeaders(headers); + } + + private ensureCsrfToken(): Observable { + const existingToken = this.csrfToken(); + if (existingToken) { + return of(existingToken); + } + + return this.http.get(`${this.apiUrl}/auth/csrf-token`, { + withCredentials: true + }).pipe( + tap(response => this.csrfToken.set(response.csrfToken)), + switchMap(response => of(response.csrfToken)) + ); } get(endpoint: string): Observable { return this.http.get(`${this.apiUrl}${endpoint}`, { - headers: this.getHeaders(), - withCredentials: true // Important for cookies - }); - } - - post(endpoint: string, body: any): Observable { - return this.http.post(`${this.apiUrl}${endpoint}`, body, { headers: this.getHeaders(), withCredentials: true }); } - put(endpoint: string, body: any): Observable { - return this.http.put(`${this.apiUrl}${endpoint}`, body, { - headers: this.getHeaders(), - withCredentials: true - }); + post(endpoint: string, body: unknown): Observable { + return this.ensureCsrfToken().pipe( + switchMap(() => this.http.post(`${this.apiUrl}${endpoint}`, body, { + headers: this.getHeaders(), + withCredentials: true + })) + ); + } + + put(endpoint: string, body: unknown): Observable { + return this.ensureCsrfToken().pipe( + switchMap(() => this.http.put(`${this.apiUrl}${endpoint}`, body, { + headers: this.getHeaders(), + withCredentials: true + })) + ); } delete(endpoint: string): Observable { - return this.http.delete(`${this.apiUrl}${endpoint}`, { - withCredentials: true - }); + return this.ensureCsrfToken().pipe( + switchMap(() => this.http.delete(`${this.apiUrl}${endpoint}`, { + headers: this.getHeaders(), + withCredentials: true + })) + ); } -} \ No newline at end of file + + clearCsrfToken(): void { + this.csrfToken.set(null); + } +} diff --git a/apps/frontend/src/app/services/audit.service.ts b/apps/frontend/src/app/services/audit.service.ts new file mode 100644 index 0000000..27f50ac --- /dev/null +++ b/apps/frontend/src/app/services/audit.service.ts @@ -0,0 +1,84 @@ +import { Injectable, inject } from '@angular/core'; +import { firstValueFrom } from 'rxjs'; +import { ApiService } from './api.service'; +import type { AuditLog, AuditLogFilters, AuditAction, AuditCategory } from '@library/shared-types'; + +interface AuditLogResponse { + logs: AuditLog[]; + total: number; + page: number; + limit: number; + totalPages: number; +} + +@Injectable({ + providedIn: 'root', +}) +export class AuditLogService { + private api = inject(ApiService); + + async getLogs(filters: AuditLogFilters = {}): Promise { + const params = new URLSearchParams(); + + if (filters.action) params.set('action', filters.action); + if (filters.category) params.set('category', filters.category); + if (filters.userId) params.set('userId', filters.userId); + if (filters.success !== undefined) params.set('success', String(filters.success)); + if (filters.startDate) params.set('startDate', filters.startDate.toISOString()); + if (filters.endDate) params.set('endDate', filters.endDate.toISOString()); + if (filters.page) params.set('page', String(filters.page)); + if (filters.limit) params.set('limit', String(filters.limit)); + + const queryString = params.toString(); + const url = queryString ? `/audit?${queryString}` : '/audit'; + + return firstValueFrom(this.api.get(url)); + } + + async getSecurityLogs(page = 1, limit = 50): Promise { + return firstValueFrom(this.api.get(`/audit/security?page=${page}&limit=${limit}`)); + } + + async getUserLogs(userId: string, page = 1, limit = 50): Promise { + return firstValueFrom(this.api.get(`/audit/user/${userId}?page=${page}&limit=${limit}`)); + } + + getActionLabel(action: AuditAction): string { + const labels: Record = { + LOGIN: 'Login', + LOGOUT: 'Logout', + LOGIN_FAILED: 'Login Failed', + COMMENT_CREATE: 'Comment Created', + COMMENT_DELETE: 'Comment Deleted', + ENTRY_CREATE: 'Entry Created', + ENTRY_UPDATE: 'Entry Updated', + ENTRY_DELETE: 'Entry Deleted', + USER_BAN: 'User Banned', + USER_UNBAN: 'User Unbanned', + RATE_LIMIT_EXCEEDED: 'Rate Limit Exceeded', + CSRF_VALIDATION_FAILED: 'CSRF Validation Failed', + UNAUTHORIZED_ACCESS: 'Unauthorized Access', + }; + return labels[action] ?? action; + } + + getCategoryLabel(category: AuditCategory): string { + const labels: Record = { + AUTH: 'Authentication', + CONTENT: 'Content', + ADMIN: 'Administration', + SECURITY: 'Security', + }; + return labels[category] ?? category; + } + + getCategoryColor(category: AuditCategory): string { + const colors: Record = { + AUTH: '#3b82f6', // blue + CONTENT: '#10b981', // green + ADMIN: '#8b5cf6', // purple + SECURITY: '#ef4444', // red + }; + return colors[category] ?? '#6b7280'; + } +} diff --git a/apps/frontend/src/app/services/auth.service.ts b/apps/frontend/src/app/services/auth.service.ts index 3b7fc83..2ae06e6 100644 --- a/apps/frontend/src/app/services/auth.service.ts +++ b/apps/frontend/src/app/services/auth.service.ts @@ -40,6 +40,7 @@ export class AuthService { return this.api.post<{ message: string }>('/auth/logout', {}).pipe( tap(() => { this.currentUser.set(null); + this.api.clearCsrfToken(); this.router.navigate(['/']); }) ); diff --git a/apps/frontend/src/app/services/sanitize.service.ts b/apps/frontend/src/app/services/sanitize.service.ts new file mode 100644 index 0000000..d8d7ba3 --- /dev/null +++ b/apps/frontend/src/app/services/sanitize.service.ts @@ -0,0 +1,28 @@ +/** + * @copyright 2026 NHCarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ + +import { Injectable, SecurityContext, inject } from '@angular/core'; +import { DomSanitizer, SafeHtml } from '@angular/platform-browser'; + +/** + * Service for sanitizing HTML content on the frontend. + * Provides defence-in-depth XSS protection alongside backend sanitization. + */ +@Injectable({ + providedIn: 'root' +}) +export class SanitizeService { + private sanitizer = inject(DomSanitizer); + + /** + * Sanitizes HTML content for safe rendering. + * This provides a second layer of protection after backend sanitization. + */ + sanitizeHtml(html: string): SafeHtml { + const sanitized = this.sanitizer.sanitize(SecurityContext.HTML, html); + return this.sanitizer.bypassSecurityTrustHtml(sanitized ?? ''); + } +} diff --git a/apps/frontend/src/app/services/user.service.ts b/apps/frontend/src/app/services/user.service.ts new file mode 100644 index 0000000..b8995e6 --- /dev/null +++ b/apps/frontend/src/app/services/user.service.ts @@ -0,0 +1,29 @@ +/** + * @copyright 2026 NHCarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ + +import { Injectable, inject } from '@angular/core'; +import { Observable } from 'rxjs'; +import { ApiService } from './api.service'; +import { User } from '@library/shared-types'; + +@Injectable({ + providedIn: 'root' +}) +export class UserService { + private api = inject(ApiService); + + getAllUsers(): Observable { + return this.api.get('/users'); + } + + banUser(userId: string): Observable { + return this.api.post(`/users/${userId}/ban`, {}); + } + + unbanUser(userId: string): Observable { + return this.api.post(`/users/${userId}/unban`, {}); + } +} diff --git a/package.json b/package.json index 76f1f20..f331ba0 100644 --- a/package.json +++ b/package.json @@ -24,13 +24,17 @@ "@angular/router": "21.1.2", "@fastify/autoload": "6.0.3", "@fastify/cookie": "^11.0.2", + "@fastify/cors": "^11.0.0", + "@fastify/csrf-protection": "^7.1.0", + "@fastify/helmet": "^13.0.2", "@fastify/jwt": "^10.0.0", "@fastify/oauth2": "^8.1.2", + "@fastify/rate-limit": "^10.3.0", "@fastify/sensible": "6.0.4", "@fastify/static": "^9.0.0", "@prisma/client": "6.19.2", "dompurify": "^3.3.1", - "fastify": "5.2.2", + "fastify": "5.7.3", "fastify-plugin": "5.0.1", "jsdom": "^28.0.0", "marked": "^17.0.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ffa4009..dcea136 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -32,12 +32,24 @@ importers: '@fastify/cookie': specifier: ^11.0.2 version: 11.0.2 + '@fastify/cors': + specifier: ^11.0.0 + version: 11.2.0 + '@fastify/csrf-protection': + specifier: ^7.1.0 + version: 7.1.0 + '@fastify/helmet': + specifier: ^13.0.2 + version: 13.0.2 '@fastify/jwt': specifier: ^10.0.0 version: 10.0.0 '@fastify/oauth2': specifier: ^8.1.2 version: 8.1.2 + '@fastify/rate-limit': + specifier: ^10.3.0 + version: 10.3.0 '@fastify/sensible': specifier: 6.0.4 version: 6.0.4 @@ -51,8 +63,8 @@ importers: specifier: ^3.3.1 version: 3.3.1 fastify: - specifier: 5.2.2 - version: 5.2.2 + specifier: 5.7.3 + version: 5.7.3 fastify-plugin: specifier: 5.0.1 version: 5.0.1 @@ -1599,6 +1611,15 @@ packages: '@fastify/cookie@11.0.2': resolution: {integrity: sha512-GWdwdGlgJxyvNv+QcKiGNevSspMQXncjMZ1J8IvuDQk0jvkzgWWZFNC2En3s+nHndZBGV8IbLwOI/sxCZw/mzA==} + '@fastify/cors@11.2.0': + resolution: {integrity: sha512-LbLHBuSAdGdSFZYTLVA3+Ch2t+sA6nq3Ejc6XLAKiQ6ViS2qFnvicpj0htsx03FyYeLs04HfRNBsz/a8SvbcUw==} + + '@fastify/csrf-protection@7.1.0': + resolution: {integrity: sha512-I2TDd4SRRYQivKCMHdB/8py+CPO9DT0e63lh4DO8MDCJh8NROq8HD/iO0IjYtwhsD3bZhr0cBXsFdfPvyTmzNw==} + + '@fastify/csrf@8.0.1': + resolution: {integrity: sha512-dAmCrdfJ3CV/A/hHHK/rRBjjLRRSIltgJB0BxiVfbhr/31G6fgF8l2I8evtH8mjS5kTIvd0JOh7MOA3HA6eYDw==} + '@fastify/error@4.2.0': resolution: {integrity: sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==} @@ -1608,6 +1629,9 @@ packages: '@fastify/forwarded@3.0.1': resolution: {integrity: sha512-JqDochHFqXs3C3Ml3gOY58zM7OqO9ENqPo0UqAjAjH8L01fRZqwX9iLeX34//kiJubF7r2ZQHtBRU36vONbLlw==} + '@fastify/helmet@13.0.2': + resolution: {integrity: sha512-tO1QMkOfNeCt9l4sG/FiWErH4QMm+RjHzbMTrgew1DYOQ2vb/6M1G2iNABBrD7Xq6dUk+HLzWW8u+rmmhQHifA==} + '@fastify/jwt@10.0.0': resolution: {integrity: sha512-2Qka3NiyNNcsfejMUvyzot1T4UYIzzcbkFGDdVyrl344fRZ/WkD6VFXOoXhxe2Pzf3LpJNkoSxUM4Ru4DVgkYA==} @@ -1620,6 +1644,9 @@ packages: '@fastify/proxy-addr@5.1.0': resolution: {integrity: sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==} + '@fastify/rate-limit@10.3.0': + resolution: {integrity: sha512-eIGkG9XKQs0nyynatApA3EVrojHOuq4l6fhB4eeCk4PIOeadvOJz9/4w3vGI44Go17uaXOWEcPkaD8kuKm7g6Q==} + '@fastify/send@4.1.0': resolution: {integrity: sha512-TMYeQLCBSy2TOFmV95hQWkiTYgC/SEx7vMdV+wnZVX4tt8VBLKzmH8vV9OzJehV0+XBfg+WxPMt5wp+JBUKsVw==} @@ -5378,8 +5405,8 @@ packages: fastify-plugin@5.0.1: resolution: {integrity: sha512-HCxs+YnRaWzCl+cWRYFnHmeRFyR5GVnJTAaCJQiYzQSDwK9MgJdyAsuL3nh0EWRCYMgQ5MeziymvmAhUHYHDUQ==} - fastify@5.2.2: - resolution: {integrity: sha512-22T/PnhquWozuFXg3Ish4md5ipsF1Nx1mJ9ulLdZPXSk14WFj/wMlyNB/yll9sQOojKRgOIxT2inK3Xpjg5hyw==} + fastify@5.7.3: + resolution: {integrity: sha512-QHzWSmTNUg9Ba8tNXzb92FTH77K+c8yeQPH80EeSIc9wyZj85jbPisMP0rwmyKv8oJwUFPe1UpN8HkNIXwCnUQ==} fastparallel@2.4.1: resolution: {integrity: sha512-qUmhxPgNHmvRjZKBFUNI0oZuuH9OlSIOXmJ98lhKPxMZZ7zS/Fi0wRHOihDSz0R1YiIOjxzOY4bq65YTcdBi2Q==} @@ -5745,6 +5772,10 @@ packages: resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==} hasBin: true + helmet@8.1.0: + resolution: {integrity: sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==} + engines: {node: '>=18.0.0'} + homedir-polyfill@1.0.3: resolution: {integrity: sha512-eSmmWE5bZTK2Nou4g0AI3zZ9rswp7GRKoKXS1BLUkvPviOqs4YTN1djQIqrXy9k5gEtdLPy86JjRwsNM9tnDcA==} engines: {node: '>=0.10.0'} @@ -7298,14 +7329,14 @@ packages: resolution: {integrity: sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==} engines: {node: '>=6'} - pino-abstract-transport@2.0.0: - resolution: {integrity: sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==} + pino-abstract-transport@3.0.0: + resolution: {integrity: sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==} pino-std-serializers@7.1.0: resolution: {integrity: sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==} - pino@9.14.0: - resolution: {integrity: sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w==} + pino@10.3.0: + resolution: {integrity: sha512-0GNPNzHXBKw6U/InGe79A3Crzyk9bcSyObF9/Gfo9DLEf5qj5RF50RSjsu0W1rZ6ZqRGdzDFCRBQvi9/rSGPtA==} hasBin: true pirates@4.0.7: @@ -8090,8 +8121,8 @@ packages: secure-compare@3.0.1: resolution: {integrity: sha512-AckIIV90rPDcBcglUwXPF3kg0P0qmPsPXAj6BBEENQE1p5yA1xfmDJzfi1Tappj37Pv2mVbKpL3Z1T+Nn7k1Qw==} - secure-json-parse@3.0.2: - resolution: {integrity: sha512-H6nS2o8bWfpFEV6U38sOSjS7bTbdgbCGU9wEM6W14P5H0QOsz94KCusifV44GpHDTu2nqZbuDNhTzu+mjDSw1w==} + secure-json-parse@4.1.0: + resolution: {integrity: sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==} select-hose@2.0.0: resolution: {integrity: sha512-mEugaLK+YfkijB4fx0e6kImuJdCIt2LxCRcbEYPqRGCs4F2ogyfZU5IAZRdjCP8JPq2AtdNoC/Dux63d9Kiryg==} @@ -8538,8 +8569,9 @@ packages: peerDependencies: tslib: ^2 - thread-stream@3.1.0: - resolution: {integrity: sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==} + thread-stream@4.0.0: + resolution: {integrity: sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==} + engines: {node: '>=20'} throttleit@1.0.1: resolution: {integrity: sha512-vDZpf9Chs9mAdfY046mcPt8fg5QSZr37hEH4TXYBnDF+izxgrbRGUAAaBvIk/fJm9aOFCGFd1EsNg5AZCbnQCQ==} @@ -11526,6 +11558,19 @@ snapshots: cookie: 1.1.1 fastify-plugin: 5.0.1 + '@fastify/cors@11.2.0': + dependencies: + fastify-plugin: 5.0.1 + toad-cache: 3.7.0 + + '@fastify/csrf-protection@7.1.0': + dependencies: + '@fastify/csrf': 8.0.1 + '@fastify/error': 4.2.0 + fastify-plugin: 5.0.1 + + '@fastify/csrf@8.0.1': {} + '@fastify/error@4.2.0': {} '@fastify/fast-json-stringify-compiler@5.0.3': @@ -11534,6 +11579,11 @@ snapshots: '@fastify/forwarded@3.0.1': {} + '@fastify/helmet@13.0.2': + dependencies: + fastify-plugin: 5.0.1 + helmet: 8.1.0 + '@fastify/jwt@10.0.0': dependencies: '@fastify/error': 4.2.0 @@ -11559,6 +11609,12 @@ snapshots: '@fastify/forwarded': 3.0.1 ipaddr.js: 2.3.0 + '@fastify/rate-limit@10.3.0': + dependencies: + '@lukeed/ms': 2.0.2 + fastify-plugin: 5.0.1 + toad-cache: 3.7.0 + '@fastify/send@4.1.0': dependencies: '@lukeed/ms': 2.0.2 @@ -16392,7 +16448,7 @@ snapshots: fastify-plugin@5.0.1: {} - fastify@5.2.2: + fastify@5.7.3: dependencies: '@fastify/ajv-compiler': 4.0.5 '@fastify/error': 4.2.0 @@ -16403,10 +16459,10 @@ snapshots: fast-json-stringify: 6.2.0 find-my-way: 9.4.0 light-my-request: 6.6.0 - pino: 9.14.0 - process-warning: 4.0.1 + pino: 10.3.0 + process-warning: 5.0.0 rfdc: 1.4.1 - secure-json-parse: 3.0.2 + secure-json-parse: 4.1.0 semver: 7.7.3 toad-cache: 3.7.0 @@ -16814,6 +16870,8 @@ snapshots: he@1.2.0: {} + helmet@8.1.0: {} + homedir-polyfill@1.0.3: dependencies: parse-passwd: 1.0.0 @@ -18687,25 +18745,25 @@ snapshots: pify@4.0.1: optional: true - pino-abstract-transport@2.0.0: + pino-abstract-transport@3.0.0: dependencies: split2: 4.2.0 pino-std-serializers@7.1.0: {} - pino@9.14.0: + pino@10.3.0: dependencies: '@pinojs/redact': 0.4.0 atomic-sleep: 1.0.0 on-exit-leak-free: 2.1.2 - pino-abstract-transport: 2.0.0 + pino-abstract-transport: 3.0.0 pino-std-serializers: 7.1.0 process-warning: 5.0.0 quick-format-unescaped: 4.0.4 real-require: 0.2.0 safe-stable-stringify: 2.5.0 sonic-boom: 4.2.0 - thread-stream: 3.1.0 + thread-stream: 4.0.0 pirates@4.0.7: {} @@ -19516,7 +19574,7 @@ snapshots: secure-compare@3.0.1: {} - secure-json-parse@3.0.2: {} + secure-json-parse@4.1.0: {} select-hose@2.0.0: {} @@ -20102,7 +20160,7 @@ snapshots: dependencies: tslib: 2.8.1 - thread-stream@3.1.0: + thread-stream@4.0.0: dependencies: real-require: 0.2.0 diff --git a/shared-types/src/index.ts b/shared-types/src/index.ts index 4947a43..de5166a 100644 --- a/shared-types/src/index.ts +++ b/shared-types/src/index.ts @@ -10,4 +10,5 @@ export * from "./lib/art.types"; export * from "./lib/show.types"; export * from "./lib/manga.types"; export type * from "./lib/auth.types"; -export * from "./lib/comment.types"; \ No newline at end of file +export * from "./lib/comment.types"; +export * from "./lib/audit.types"; \ No newline at end of file diff --git a/shared-types/src/lib/audit.types.ts b/shared-types/src/lib/audit.types.ts new file mode 100644 index 0000000..8f8c2ac --- /dev/null +++ b/shared-types/src/lib/audit.types.ts @@ -0,0 +1,47 @@ +export enum AuditAction { + LOGIN = "LOGIN", + LOGOUT = "LOGOUT", + LOGIN_FAILED = "LOGIN_FAILED", + COMMENT_CREATE = "COMMENT_CREATE", + COMMENT_DELETE = "COMMENT_DELETE", + ENTRY_CREATE = "ENTRY_CREATE", + ENTRY_UPDATE = "ENTRY_UPDATE", + ENTRY_DELETE = "ENTRY_DELETE", + USER_BAN = "USER_BAN", + USER_UNBAN = "USER_UNBAN", + RATE_LIMIT_EXCEEDED = "RATE_LIMIT_EXCEEDED", + CSRF_VALIDATION_FAILED = "CSRF_VALIDATION_FAILED", + UNAUTHORIZED_ACCESS = "UNAUTHORIZED_ACCESS", +} + +export enum AuditCategory { + AUTH = "AUTH", + CONTENT = "CONTENT", + ADMIN = "ADMIN", + SECURITY = "SECURITY", +} + +export interface AuditLog { + id: string; + action: AuditAction; + category: AuditCategory; + userId?: string; + targetUserId?: string; + resourceType?: string; + resourceId?: string; + details?: string; + userAgent?: string; + success: boolean; + createdAt: Date; +} + +export interface AuditLogFilters { + action?: AuditAction; + category?: AuditCategory; + userId?: string; + success?: boolean; + startDate?: Date; + endDate?: Date; + page?: number; + limit?: number; +} diff --git a/shared-types/src/lib/auth.types.ts b/shared-types/src/lib/auth.types.ts index 11a3517..5f72446 100644 --- a/shared-types/src/lib/auth.types.ts +++ b/shared-types/src/lib/auth.types.ts @@ -11,6 +11,7 @@ export interface User { avatarUrl?: string; discordId: string; isAdmin: boolean; + isBanned: boolean; } export interface JwtPayload {