feat: security and auditing

This commit is contained in:
2026-02-04 16:48:08 -08:00
parent 11be34cd21
commit 0a654f423a
42 changed files with 2195 additions and 160 deletions
+38
View File
@@ -152,6 +152,7 @@ model User {
email String @unique email String @unique
avatar String? avatar String?
isAdmin Boolean @default(false) isAdmin Boolean @default(false)
isBanned Boolean @default(false)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
comments Comment[] comments Comment[]
@@ -177,3 +178,40 @@ model Comment {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt 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
}
+37 -3
View File
@@ -1,14 +1,48 @@
import * as path from 'path'; import * as path from 'path';
import { FastifyInstance } from 'fastify'; import { FastifyInstance, FastifyError } from 'fastify';
import AutoLoad from '@fastify/autoload'; import AutoLoad from '@fastify/autoload';
import { AuditService } from './services/audit.service';
import { AuditAction, AuditCategory } from '@library/shared-types';
/* eslint-disable-next-line */ /* eslint-disable-next-line */
export interface AppOptions {} export interface AppOptions {}
export async function app(fastify: FastifyInstance, opts: 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 // This loads all plugins defined in plugins
// those should be support plugins that are reused // those should be support plugins that are reused
+12 -2
View File
@@ -5,17 +5,27 @@
*/ */
import { FastifyReply, FastifyRequest } from "fastify"; 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. * Middleware to check if the authenticated user is an admin.
* Must be used after app.authenticate. * Must be used after app.authenticate.
* Always checks the database to ensure admin status is current.
*/ */
export async function adminGuard( export async function adminGuard(
request: FastifyRequest, request: FastifyRequest,
reply: FastifyReply reply: FastifyReply
): Promise<void> { ): Promise<void> {
const user = request.user as any; const user = request.user as { id: string };
if (!user || !user.isAdmin) {
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" }); return reply.code(403).send({ error: "Forbidden: Admin access required" });
} }
} }
+30
View File
@@ -0,0 +1,30 @@
/**
* @copyright 2026 NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { FastifyReply, FastifyRequest } from "fastify";
import { UserService } from "../services/user.service";
const userService = new UserService();
/**
* Middleware to check if the authenticated user is banned.
* Must be used after app.authenticate.
*/
export async function bannedGuard(
request: FastifyRequest,
reply: FastifyReply
): Promise<void> {
const user = request.user as { id: string };
if (!user?.id) {
return reply.code(401).send({ error: "Unauthorized" });
}
const isBanned = await userService.isUserBanned(user.id);
if (isBanned) {
return reply.code(403).send({ error: "You have been banned from commenting" });
}
}
+23 -5
View File
@@ -23,13 +23,34 @@ declare module "@fastify/jwt" {
} }
} }
const getJwtSecret = (): string => {
const secret = process.env.JWT_SECRET;
if (!secret) {
throw new Error("JWT_SECRET environment variable is required");
}
return secret;
};
const authPlugin: FastifyPluginAsync = async (app) => { const authPlugin: FastifyPluginAsync = async (app) => {
const jwtSecret = getJwtSecret();
// Register cookie plugin with signing secret
app.register(fastifyCookie, {
secret: jwtSecret,
});
// Register JWT plugin // Register JWT plugin
app.register(fastifyJwt, { app.register(fastifyJwt, {
secret: process.env.JWT_SECRET || "your-secret-key", secret: jwtSecret,
sign: {
algorithm: "HS256",
},
verify: {
algorithms: ["HS256"],
},
cookie: { cookie: {
cookieName: "auth-token", cookieName: "auth-token",
signed: false, signed: true,
}, },
formatUser: (payload: { sub: string; email?: string; username: string; isAdmin: boolean }) => { formatUser: (payload: { sub: string; email?: string; username: string; isAdmin: boolean }) => {
return { return {
@@ -41,9 +62,6 @@ const authPlugin: FastifyPluginAsync = async (app) => {
}, },
}); });
// Register cookie plugin
app.register(fastifyCookie);
// Register Discord OAuth2 // Register Discord OAuth2
app.register(fastifyOauth2, { app.register(fastifyOauth2, {
name: "oauth2Discord", name: "oauth2Discord",
+26
View File
@@ -0,0 +1,26 @@
/**
* @copyright 2026 NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { FastifyPluginAsync } from "fastify";
import fastifyPlugin from "fastify-plugin";
import fastifyCors from "@fastify/cors";
const corsPlugin: FastifyPluginAsync = async (app) => {
const baseUrl = process.env.BASE_URL;
if (!baseUrl) {
throw new Error("BASE_URL environment variable is required");
}
await app.register(fastifyCors, {
origin: baseUrl,
credentials: true,
methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
allowedHeaders: ["Content-Type", "Authorization", "X-CSRF-Token"],
});
};
export default fastifyPlugin(corsPlugin);
+26
View File
@@ -0,0 +1,26 @@
/**
* @copyright 2026 NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { FastifyPluginAsync, FastifyRequest } from "fastify";
import fastifyPlugin from "fastify-plugin";
import fastifyCsrf from "@fastify/csrf-protection";
const csrfPlugin: FastifyPluginAsync = async (app) => {
await app.register(fastifyCsrf, {
sessionPlugin: "@fastify/cookie",
cookieOpts: {
path: "/",
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
},
getToken: (request: FastifyRequest) => {
return request.headers["x-csrf-token"] as string;
},
});
};
export default fastifyPlugin(csrfPlugin);
+27
View File
@@ -0,0 +1,27 @@
/**
* @copyright 2026 NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { FastifyPluginAsync } from "fastify";
import fastifyPlugin from "fastify-plugin";
import fastifyHelmet from "@fastify/helmet";
const helmetPlugin: FastifyPluginAsync = async (app) => {
await app.register(fastifyHelmet, {
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", "data:", "https:"],
scriptSrc: ["'self'"],
connectSrc: ["'self'", process.env.FRONTEND_URL ?? "http://localhost:4200"],
},
},
crossOriginEmbedderPolicy: false,
crossOriginResourcePolicy: { policy: "cross-origin" },
});
};
export default fastifyPlugin(helmetPlugin);
+37
View File
@@ -0,0 +1,37 @@
/**
* @copyright 2026 NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { FastifyPluginAsync } from "fastify";
import fastifyPlugin from "fastify-plugin";
import fastifyRateLimit from "@fastify/rate-limit";
import { AuditService } from "../services/audit.service";
import { AuditAction, AuditCategory } from "@library/shared-types";
const rateLimitPlugin: FastifyPluginAsync = async (app) => {
await app.register(fastifyRateLimit, {
max: 100,
timeWindow: "1 minute",
errorResponseBuilder: (request) => {
// Log rate limit exceeded event
AuditService.log({
action: AuditAction.RATE_LIMIT_EXCEEDED,
category: AuditCategory.SECURITY,
details: `Rate limit exceeded for URL: ${request.url}`,
success: false,
}, request).catch(() => {
// Ignore logging errors to avoid blocking the response
});
return {
statusCode: 429,
error: "Too Many Requests",
message: "You have exceeded the rate limit. Please try again later.",
};
},
});
};
export default fastifyPlugin(rateLimitPlugin);
+53 -6
View File
@@ -5,10 +5,12 @@
*/ */
import { FastifyPluginAsync } from "fastify"; import { 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 { ArtService } from "../../services/art.service";
import { CommentService } from "../../services/comment.service"; import { CommentService } from "../../services/comment.service";
import { AuditService } from "../../services/audit.service";
import { adminGuard } from "../../middleware/admin-guard"; import { adminGuard } from "../../middleware/admin-guard";
import { bannedGuard } from "../../middleware/banned-guard";
const artRoutes: FastifyPluginAsync = async (app) => { const artRoutes: FastifyPluginAsync = async (app) => {
const artService = new ArtService(); const artService = new ArtService();
@@ -39,9 +41,18 @@ const artRoutes: FastifyPluginAsync = async (app) => {
"/", "/",
{ {
preValidation: [app.authenticate, adminGuard], preValidation: [app.authenticate, adminGuard],
preHandler: [app.csrfProtection],
}, },
async (request) => { 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", "/:id",
{ {
preValidation: [app.authenticate, adminGuard], preValidation: [app.authenticate, adminGuard],
preHandler: [app.csrfProtection],
}, },
async (request) => { async (request) => {
const { id } = request.params; 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", "/:id",
{ {
preValidation: [app.authenticate, adminGuard], preValidation: [app.authenticate, adminGuard],
preHandler: [app.csrfProtection],
}, },
async (request) => { async (request) => {
const { id } = request.params; const { id } = request.params;
await artService.deleteArt(id); 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 }; return { success: true };
} }
); );
@@ -95,12 +125,21 @@ const artRoutes: FastifyPluginAsync = async (app) => {
app.post<{ Params: { id: string }; Body: CreateCommentDto; Reply: Comment }>( app.post<{ Params: { id: string }; Body: CreateCommentDto; Reply: Comment }>(
"/:id/comments", "/:id/comments",
{ {
preValidation: [app.authenticate], preValidation: [app.authenticate, bannedGuard],
preHandler: [app.csrfProtection],
}, },
async (request) => { async (request) => {
const { id } = request.params; const { id } = request.params;
const userId = request.user.id; 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", "/:id/comments/:commentId",
{ {
preValidation: [app.authenticate, adminGuard], preValidation: [app.authenticate, adminGuard],
preHandler: [app.csrfProtection],
}, },
async (request) => { async (request) => {
const { commentId } = request.params; const { id, commentId } = request.params;
await commentService.deleteComment(commentId); 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 }; return { success: true };
} }
); );
+85
View File
@@ -0,0 +1,85 @@
/**
* @copyright 2026 NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { FastifyPluginAsync } from "fastify";
import { AuditService } from "../../services/audit.service";
import { adminGuard } from "../../middleware/admin-guard";
import type { AuditAction, AuditCategory } from "@library/shared-types";
interface AuditLogQuery {
action?: AuditAction;
category?: AuditCategory;
userId?: string;
success?: string;
startDate?: string;
endDate?: string;
page?: string;
limit?: string;
}
const auditRoutes: FastifyPluginAsync = async (app) => {
/**
* Get audit logs (admin only).
*/
app.get<{ Querystring: AuditLogQuery }>(
"/",
{
preValidation: [app.authenticate, adminGuard],
},
async (request) => {
const { action, category, userId, success, startDate, endDate, page, limit } = request.query;
return AuditService.getLogs({
action: action as AuditAction | undefined,
category: category as AuditCategory | undefined,
userId,
success: success === undefined ? undefined : success === "true",
startDate: startDate ? new Date(startDate) : undefined,
endDate: endDate ? new Date(endDate) : undefined,
page: page ? parseInt(page, 10) : 1,
limit: limit ? parseInt(limit, 10) : 50,
});
}
);
/**
* Get security logs (admin only).
*/
app.get<{ Querystring: { page?: string; limit?: string } }>(
"/security",
{
preValidation: [app.authenticate, adminGuard],
},
async (request) => {
const { page, limit } = request.query;
return AuditService.getSecurityLogs(
page ? parseInt(page, 10) : 1,
limit ? parseInt(limit, 10) : 50
);
}
);
/**
* Get logs for a specific user (admin only).
*/
app.get<{ Params: { userId: string }; Querystring: { page?: string; limit?: string } }>(
"/user/:userId",
{
preValidation: [app.authenticate, adminGuard],
},
async (request) => {
const { userId } = request.params;
const { page, limit } = request.query;
return AuditService.getLogsByUser(
userId,
page ? parseInt(page, 10) : 1,
limit ? parseInt(limit, 10) : 50
);
}
);
};
export default auditRoutes;
+56 -5
View File
@@ -1,6 +1,7 @@
import { FastifyPluginAsync } from "fastify"; import { FastifyPluginAsync } from "fastify";
import { AuthService } from "../../services/auth.service"; 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 authRoutes: FastifyPluginAsync = async (app) => {
const authService = new AuthService(app); const authService = new AuthService(app);
@@ -33,7 +34,16 @@ const authRoutes: FastifyPluginAsync = async (app) => {
// Generate JWT // Generate JWT
const jwt = await authService.generateToken(user); 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 reply
.setCookie("auth-token", jwt, { .setCookie("auth-token", jwt, {
path: "/", path: "/",
@@ -41,13 +51,22 @@ const authRoutes: FastifyPluginAsync = async (app) => {
secure: process.env.NODE_ENV === "production", secure: process.env.NODE_ENV === "production",
sameSite: "lax", sameSite: "lax",
maxAge: 7 * 24 * 60 * 60, // 7 days maxAge: 7 * 24 * 60 * 60, // 7 days
signed: true,
}) })
.redirect("/"); // Redirect to root since API serves frontend .redirect("/"); // Redirect to root since API serves frontend
} catch (error) { } 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"); app.log.error({ err: error }, "Auth callback error");
reply reply
.code(401) .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], preValidation: [app.authenticate],
}, },
async (request) => { async (request, reply) => {
const user = request.user as any; 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); const token = await authService.generateToken(user);
return { return {
@@ -74,12 +99,38 @@ const authRoutes: FastifyPluginAsync = async (app) => {
* Logout. * Logout.
*/ */
app.post("/logout", async (request, reply) => { 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 reply
.clearCookie("auth-token", { .clearCookie("auth-token", {
path: "/", path: "/",
signed: true,
}) })
.send({ message: "Logged out successfully" }); .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; export default authRoutes;
+53 -6
View File
@@ -5,10 +5,12 @@
*/ */
import { FastifyPluginAsync } from "fastify"; 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 { BookService } from "../../services/book.service";
import { CommentService } from "../../services/comment.service"; import { CommentService } from "../../services/comment.service";
import { AuditService } from "../../services/audit.service";
import { adminGuard } from "../../middleware/admin-guard"; import { adminGuard } from "../../middleware/admin-guard";
import { bannedGuard } from "../../middleware/banned-guard";
const booksRoutes: FastifyPluginAsync = async (app) => { const booksRoutes: FastifyPluginAsync = async (app) => {
const bookService = new BookService(); const bookService = new BookService();
@@ -39,9 +41,18 @@ const booksRoutes: FastifyPluginAsync = async (app) => {
"/", "/",
{ {
preValidation: [app.authenticate, adminGuard], preValidation: [app.authenticate, adminGuard],
preHandler: [app.csrfProtection],
}, },
async (request) => { 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", "/:id",
{ {
preValidation: [app.authenticate, adminGuard], preValidation: [app.authenticate, adminGuard],
preHandler: [app.csrfProtection],
}, },
async (request) => { async (request) => {
const { id } = request.params; 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", "/:id",
{ {
preValidation: [app.authenticate, adminGuard], preValidation: [app.authenticate, adminGuard],
preHandler: [app.csrfProtection],
}, },
async (request) => { async (request) => {
const { id } = request.params; const { id } = request.params;
await bookService.deleteBook(id); 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 }; return { success: true };
} }
); );
@@ -95,12 +125,21 @@ const booksRoutes: FastifyPluginAsync = async (app) => {
app.post<{ Params: { id: string }; Body: CreateCommentDto; Reply: Comment }>( app.post<{ Params: { id: string }; Body: CreateCommentDto; Reply: Comment }>(
"/:id/comments", "/:id/comments",
{ {
preValidation: [app.authenticate], preValidation: [app.authenticate, bannedGuard],
preHandler: [app.csrfProtection],
}, },
async (request) => { async (request) => {
const { id } = request.params; const { id } = request.params;
const userId = request.user.id; 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", "/:id/comments/:commentId",
{ {
preValidation: [app.authenticate, adminGuard], preValidation: [app.authenticate, adminGuard],
preHandler: [app.csrfProtection],
}, },
async (request) => { async (request) => {
const { commentId } = request.params; const { id, commentId } = request.params;
await commentService.deleteComment(commentId); 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 }; return { success: true };
} }
); );
+53 -6
View File
@@ -5,10 +5,12 @@
*/ */
import { FastifyPluginAsync } from "fastify"; 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 { GameService } from "../../services/game.service";
import { CommentService } from "../../services/comment.service"; import { CommentService } from "../../services/comment.service";
import { AuditService } from "../../services/audit.service";
import { adminGuard } from "../../middleware/admin-guard"; import { adminGuard } from "../../middleware/admin-guard";
import { bannedGuard } from "../../middleware/banned-guard";
const gamesRoutes: FastifyPluginAsync = async (app) => { const gamesRoutes: FastifyPluginAsync = async (app) => {
const gameService = new GameService(); const gameService = new GameService();
@@ -33,9 +35,18 @@ const gamesRoutes: FastifyPluginAsync = async (app) => {
"/", "/",
{ {
preValidation: [app.authenticate, adminGuard], preValidation: [app.authenticate, adminGuard],
preHandler: [app.csrfProtection],
}, },
async (request) => { 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", "/:id",
{ {
preValidation: [app.authenticate, adminGuard], preValidation: [app.authenticate, adminGuard],
preHandler: [app.csrfProtection],
}, },
async (request) => { async (request) => {
const { id } = request.params; 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", "/:id",
{ {
preValidation: [app.authenticate, adminGuard], preValidation: [app.authenticate, adminGuard],
preHandler: [app.csrfProtection],
}, },
async (request) => { async (request) => {
const { id } = request.params; const { id } = request.params;
await gameService.deleteGame(id); 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 }; return { success: true };
} }
); );
@@ -81,12 +111,21 @@ const gamesRoutes: FastifyPluginAsync = async (app) => {
app.post<{ Params: { id: string }; Body: CreateCommentDto; Reply: Comment }>( app.post<{ Params: { id: string }; Body: CreateCommentDto; Reply: Comment }>(
"/:id/comments", "/:id/comments",
{ {
preValidation: [app.authenticate], preValidation: [app.authenticate, bannedGuard],
preHandler: [app.csrfProtection],
}, },
async (request) => { async (request) => {
const { id } = request.params; const { id } = request.params;
const userId = request.user.id; 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", "/:id/comments/:commentId",
{ {
preValidation: [app.authenticate, adminGuard], preValidation: [app.authenticate, adminGuard],
preHandler: [app.csrfProtection],
}, },
async (request) => { async (request) => {
const { commentId } = request.params; const { id, commentId } = request.params;
await commentService.deleteComment(commentId); 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 }; return { success: true };
} }
); );
+53 -6
View File
@@ -5,10 +5,12 @@
*/ */
import { FastifyPluginAsync } from "fastify"; 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 { MangaService } from "../../services/manga.service";
import { CommentService } from "../../services/comment.service"; import { CommentService } from "../../services/comment.service";
import { AuditService } from "../../services/audit.service";
import { adminGuard } from "../../middleware/admin-guard"; import { adminGuard } from "../../middleware/admin-guard";
import { bannedGuard } from "../../middleware/banned-guard";
const mangaRoutes: FastifyPluginAsync = async (app) => { const mangaRoutes: FastifyPluginAsync = async (app) => {
const mangaService = new MangaService(); const mangaService = new MangaService();
@@ -30,9 +32,18 @@ const mangaRoutes: FastifyPluginAsync = async (app) => {
"/", "/",
{ {
preValidation: [app.authenticate, adminGuard], preValidation: [app.authenticate, adminGuard],
preHandler: [app.csrfProtection],
}, },
async (request) => { 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", "/:id",
{ {
preValidation: [app.authenticate, adminGuard], preValidation: [app.authenticate, adminGuard],
preHandler: [app.csrfProtection],
}, },
async (request) => { async (request) => {
const { id } = request.params; 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", "/:id",
{ {
preValidation: [app.authenticate, adminGuard], preValidation: [app.authenticate, adminGuard],
preHandler: [app.csrfProtection],
}, },
async (request) => { async (request) => {
const { id } = request.params; const { id } = request.params;
await mangaService.deleteManga(id); 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 }; return { success: true };
} }
); );
@@ -74,12 +104,21 @@ const mangaRoutes: FastifyPluginAsync = async (app) => {
app.post<{ Params: { id: string }; Body: CreateCommentDto; Reply: Comment }>( app.post<{ Params: { id: string }; Body: CreateCommentDto; Reply: Comment }>(
"/:id/comments", "/:id/comments",
{ {
preValidation: [app.authenticate], preValidation: [app.authenticate, bannedGuard],
preHandler: [app.csrfProtection],
}, },
async (request) => { async (request) => {
const { id } = request.params; const { id } = request.params;
const userId = request.user.id; 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", "/:id/comments/:commentId",
{ {
preValidation: [app.authenticate, adminGuard], preValidation: [app.authenticate, adminGuard],
preHandler: [app.csrfProtection],
}, },
async (request) => { async (request) => {
const { commentId } = request.params; const { id, commentId } = request.params;
await commentService.deleteComment(commentId); 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 }; return { success: true };
} }
); );
+53 -6
View File
@@ -5,10 +5,12 @@
*/ */
import { FastifyPluginAsync } from "fastify"; 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 { MusicService } from "../../services/music.service";
import { CommentService } from "../../services/comment.service"; import { CommentService } from "../../services/comment.service";
import { AuditService } from "../../services/audit.service";
import { adminGuard } from "../../middleware/admin-guard"; import { adminGuard } from "../../middleware/admin-guard";
import { bannedGuard } from "../../middleware/banned-guard";
const musicRoutes: FastifyPluginAsync = async (app) => { const musicRoutes: FastifyPluginAsync = async (app) => {
const musicService = new MusicService(); const musicService = new MusicService();
@@ -39,9 +41,18 @@ const musicRoutes: FastifyPluginAsync = async (app) => {
"/", "/",
{ {
preValidation: [app.authenticate, adminGuard], preValidation: [app.authenticate, adminGuard],
preHandler: [app.csrfProtection],
}, },
async (request) => { 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", "/:id",
{ {
preValidation: [app.authenticate, adminGuard], preValidation: [app.authenticate, adminGuard],
preHandler: [app.csrfProtection],
}, },
async (request) => { async (request) => {
const { id } = request.params; 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", "/:id",
{ {
preValidation: [app.authenticate, adminGuard], preValidation: [app.authenticate, adminGuard],
preHandler: [app.csrfProtection],
}, },
async (request) => { async (request) => {
const { id } = request.params; const { id } = request.params;
await musicService.deleteMusic(id); 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 }; return { success: true };
} }
); );
@@ -95,12 +125,21 @@ const musicRoutes: FastifyPluginAsync = async (app) => {
app.post<{ Params: { id: string }; Body: CreateCommentDto; Reply: Comment }>( app.post<{ Params: { id: string }; Body: CreateCommentDto; Reply: Comment }>(
"/:id/comments", "/:id/comments",
{ {
preValidation: [app.authenticate], preValidation: [app.authenticate, bannedGuard],
preHandler: [app.csrfProtection],
}, },
async (request) => { async (request) => {
const { id } = request.params; const { id } = request.params;
const userId = request.user.id; 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", "/:id/comments/:commentId",
{ {
preValidation: [app.authenticate, adminGuard], preValidation: [app.authenticate, adminGuard],
preHandler: [app.csrfProtection],
}, },
async (request) => { async (request) => {
const { commentId } = request.params; const { id, commentId } = request.params;
await commentService.deleteComment(commentId); 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 }; return { success: true };
} }
); );
+53 -6
View File
@@ -5,10 +5,12 @@
*/ */
import { FastifyPluginAsync } from "fastify"; 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 { ShowService } from "../../services/show.service";
import { CommentService } from "../../services/comment.service"; import { CommentService } from "../../services/comment.service";
import { AuditService } from "../../services/audit.service";
import { adminGuard } from "../../middleware/admin-guard"; import { adminGuard } from "../../middleware/admin-guard";
import { bannedGuard } from "../../middleware/banned-guard";
const showsRoutes: FastifyPluginAsync = async (app) => { const showsRoutes: FastifyPluginAsync = async (app) => {
const showService = new ShowService(); const showService = new ShowService();
@@ -30,9 +32,18 @@ const showsRoutes: FastifyPluginAsync = async (app) => {
"/", "/",
{ {
preValidation: [app.authenticate, adminGuard], preValidation: [app.authenticate, adminGuard],
preHandler: [app.csrfProtection],
}, },
async (request) => { 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", "/:id",
{ {
preValidation: [app.authenticate, adminGuard], preValidation: [app.authenticate, adminGuard],
preHandler: [app.csrfProtection],
}, },
async (request) => { async (request) => {
const { id } = request.params; 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", "/:id",
{ {
preValidation: [app.authenticate, adminGuard], preValidation: [app.authenticate, adminGuard],
preHandler: [app.csrfProtection],
}, },
async (request) => { async (request) => {
const { id } = request.params; const { id } = request.params;
await showService.deleteShow(id); 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 }; return { success: true };
} }
); );
@@ -74,12 +104,21 @@ const showsRoutes: FastifyPluginAsync = async (app) => {
app.post<{ Params: { id: string }; Body: CreateCommentDto; Reply: Comment }>( app.post<{ Params: { id: string }; Body: CreateCommentDto; Reply: Comment }>(
"/:id/comments", "/:id/comments",
{ {
preValidation: [app.authenticate], preValidation: [app.authenticate, bannedGuard],
preHandler: [app.csrfProtection],
}, },
async (request) => { async (request) => {
const { id } = request.params; const { id } = request.params;
const userId = request.user.id; 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", "/:id/comments/:commentId",
{ {
preValidation: [app.authenticate, adminGuard], preValidation: [app.authenticate, adminGuard],
preHandler: [app.csrfProtection],
}, },
async (request) => { async (request) => {
const { commentId } = request.params; const { id, commentId } = request.params;
await commentService.deleteComment(commentId); 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 }; return { success: true };
} }
); );
+92
View File
@@ -0,0 +1,92 @@
/**
* @copyright 2026 NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { FastifyPluginAsync } from "fastify";
import { User, AuditAction, AuditCategory } from "@library/shared-types";
import { UserService } from "../../services/user.service";
import { AuditService } from "../../services/audit.service";
import { adminGuard } from "../../middleware/admin-guard";
const usersRoutes: FastifyPluginAsync = async (app) => {
const userService = new UserService();
app.get<{ Reply: User[] }>(
"/",
{
preValidation: [app.authenticate, adminGuard],
},
async () => {
return userService.getAllUsers();
}
);
app.get<{ Params: { id: string }; Reply: User | null }>(
"/:id",
{
preValidation: [app.authenticate, adminGuard],
},
async (request) => {
const { id } = request.params;
return userService.getUserById(id);
}
);
app.post<{ Params: { id: string }; Reply: User | { error: string } }>(
"/:id/ban",
{
preValidation: [app.authenticate, adminGuard],
preHandler: [app.csrfProtection],
},
async (request, reply) => {
const { id } = request.params;
const currentUser = request.user as { id: string };
if (currentUser.id === id) {
return reply.code(400).send({ error: "You cannot ban yourself" });
}
const user = await userService.banUser(id);
if (!user) {
return reply.code(404).send({ error: "User not found" });
}
await AuditService.logFromRequest(request, {
action: AuditAction.USER_BAN,
category: AuditCategory.ADMIN,
targetUserId: id,
details: `Banned user: ${user.username}`,
});
return user;
}
);
app.post<{ Params: { id: string }; Reply: User | { error: string } }>(
"/:id/unban",
{
preValidation: [app.authenticate, adminGuard],
preHandler: [app.csrfProtection],
},
async (request, reply) => {
const { id } = request.params;
const user = await userService.unbanUser(id);
if (!user) {
return reply.code(404).send({ error: "User not found" });
}
await AuditService.logFromRequest(request, {
action: AuditAction.USER_UNBAN,
category: AuditCategory.ADMIN,
targetUserId: id,
details: `Unbanned user: ${user.username}`,
});
return user;
}
);
};
export default usersRoutes;
+103
View File
@@ -0,0 +1,103 @@
import type { FastifyRequest } from "fastify";
import { prisma } from "../lib/prisma";
import type { AuditAction, AuditCategory, AuditLogFilters } from "@library/shared-types";
interface AuditLogData {
action: AuditAction;
category: AuditCategory;
userId?: string;
targetUserId?: string;
resourceType?: string;
resourceId?: string;
details?: string;
success?: boolean;
}
export const AuditService = {
async log(data: AuditLogData, request?: FastifyRequest) {
const userAgent = request?.headers["user-agent"] ?? undefined;
return prisma.auditLog.create({
data: {
action: data.action,
category: data.category,
userId: data.userId,
targetUserId: data.targetUserId,
resourceType: data.resourceType,
resourceId: data.resourceId,
details: data.details,
userAgent,
success: data.success ?? true,
},
});
},
async logFromRequest(
request: FastifyRequest,
data: Omit<AuditLogData, "userId">
) {
const userId = (request.user as { id?: string } | undefined)?.id;
return this.log(
{
...data,
userId,
},
request
);
},
async getLogs(filters: AuditLogFilters = {}) {
const { action, category, userId, success, startDate, endDate, page = 1, limit = 50 } = filters;
const where: Record<string, unknown> = {};
if (action) {
where.action = action;
}
if (category) {
where.category = category;
}
if (userId) {
where.userId = userId;
}
if (success !== undefined) {
where.success = success;
}
if (startDate || endDate) {
where.createdAt = {};
if (startDate) {
(where.createdAt as Record<string, Date>).gte = startDate;
}
if (endDate) {
(where.createdAt as Record<string, Date>).lte = endDate;
}
}
const [logs, total] = await Promise.all([
prisma.auditLog.findMany({
where,
orderBy: { createdAt: "desc" },
skip: (page - 1) * limit,
take: limit,
}),
prisma.auditLog.count({ where }),
]);
return {
logs,
total,
page,
limit,
totalPages: Math.ceil(total / limit),
};
},
async getLogsByUser(userId: string, page = 1, limit = 50) {
return this.getLogs({ userId, page, limit });
},
async getSecurityLogs(page = 1, limit = 50) {
return this.getLogs({ category: "SECURITY" as AuditCategory, page, limit });
},
};
+24
View File
@@ -30,6 +30,29 @@ export class AuthService {
return this.app.jwt.verify(token) as JwtPayload; return this.app.jwt.verify(token) as JwtPayload;
} }
/**
* Get user by ID from database.
*/
async getUserById(id: string): Promise<User | null> {
const dbUser = await this.prisma.user.findUnique({
where: { id },
});
if (!dbUser) {
return null;
}
return {
id: dbUser.id,
discordId: dbUser.discordId,
username: dbUser.username,
email: dbUser.email,
avatarUrl: dbUser.avatar || undefined,
isAdmin: dbUser.isAdmin,
isBanned: dbUser.isBanned,
};
}
/** /**
* Create or update user from Discord OAuth data. * Create or update user from Discord OAuth data.
*/ */
@@ -64,6 +87,7 @@ export class AuthService {
email: dbUser.email, email: dbUser.email,
avatarUrl: dbUser.avatar || undefined, avatarUrl: dbUser.avatar || undefined,
isAdmin: dbUser.isAdmin, isAdmin: dbUser.isAdmin,
isBanned: dbUser.isBanned,
}; };
} }
} }
+1
View File
@@ -10,3 +10,4 @@ export { BookService } from "./book.service";
export { MusicService } from "./music.service"; export { MusicService } from "./music.service";
export { ShowService } from "./show.service"; export { ShowService } from "./show.service";
export { MangaService } from "./manga.service"; export { MangaService } from "./manga.service";
export { AuditService } from "./audit.service";
+91
View File
@@ -0,0 +1,91 @@
/**
* @copyright 2026 NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { User } from "@library/shared-types";
import { prisma } from "../lib/prisma";
export class UserService {
private prisma = prisma;
async getAllUsers(): Promise<User[]> {
const users = await this.prisma.user.findMany({
orderBy: { username: "asc" },
});
return users.map((user) => ({
id: user.id,
discordId: user.discordId,
username: user.username,
email: user.email,
avatarUrl: user.avatar || undefined,
isAdmin: user.isAdmin,
isBanned: user.isBanned,
}));
}
async getUserById(id: string): Promise<User | null> {
const user = await this.prisma.user.findUnique({
where: { id },
});
if (!user) {
return null;
}
return {
id: user.id,
discordId: user.discordId,
username: user.username,
email: user.email,
avatarUrl: user.avatar || undefined,
isAdmin: user.isAdmin,
isBanned: user.isBanned,
};
}
async banUser(id: string): Promise<User | null> {
const user = await this.prisma.user.update({
where: { id },
data: { isBanned: true },
});
return {
id: user.id,
discordId: user.discordId,
username: user.username,
email: user.email,
avatarUrl: user.avatar || undefined,
isAdmin: user.isAdmin,
isBanned: user.isBanned,
};
}
async unbanUser(id: string): Promise<User | null> {
const user = await this.prisma.user.update({
where: { id },
data: { isBanned: false },
});
return {
id: user.id,
discordId: user.discordId,
username: user.username,
email: user.email,
avatarUrl: user.avatar || undefined,
isAdmin: user.isAdmin,
isBanned: user.isBanned,
};
}
async isUserBanned(id: string): Promise<boolean> {
const user = await this.prisma.user.findUnique({
where: { id },
select: { isBanned: true },
});
return user?.isBanned ?? false;
}
}
+8
View File
@@ -29,6 +29,14 @@ export const appRoutes: Route[] = [
path: 'manga', path: 'manga',
loadComponent: () => import('./components/manga/manga-list.component').then(m => m.MangaListComponent) 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: '**', path: '**',
redirectTo: '' redirectTo: ''
@@ -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: `
<div class="audit-container">
<h1>Audit Logs</h1>
@if (!authService.isAuthenticated() || !authService.user()?.isAdmin) {
<div class="unauthorized">
<p>You must be an admin to view this page.</p>
</div>
} @else {
<div class="filters">
<div class="filter-group">
<label for="category-filter">Category:</label>
<select
id="category-filter"
[(ngModel)]="selectedCategory"
(change)="loadLogs()"
>
<option value="">All Categories</option>
<option value="AUTH">Authentication</option>
<option value="CONTENT">Content</option>
<option value="ADMIN">Administration</option>
<option value="SECURITY">Security</option>
</select>
</div>
<div class="filter-group">
<label for="action-filter">Action:</label>
<select
id="action-filter"
[(ngModel)]="selectedAction"
(change)="loadLogs()"
>
<option value="">All Actions</option>
<option value="LOGIN">Login</option>
<option value="LOGOUT">Logout</option>
<option value="LOGIN_FAILED">Login Failed</option>
<option value="COMMENT_CREATE">Comment Created</option>
<option value="COMMENT_DELETE">Comment Deleted</option>
<option value="ENTRY_CREATE">Entry Created</option>
<option value="ENTRY_UPDATE">Entry Updated</option>
<option value="ENTRY_DELETE">Entry Deleted</option>
<option value="USER_BAN">User Banned</option>
<option value="USER_UNBAN">User Unbanned</option>
<option value="RATE_LIMIT_EXCEEDED">Rate Limit Exceeded</option>
<option value="CSRF_VALIDATION_FAILED">CSRF Failed</option>
<option value="UNAUTHORIZED_ACCESS">Unauthorized Access</option>
</select>
</div>
<div class="filter-group">
<label for="success-filter">Status:</label>
<select
id="success-filter"
[(ngModel)]="selectedSuccess"
(change)="loadLogs()"
>
<option value="">All</option>
<option value="true">Success</option>
<option value="false">Failed</option>
</select>
</div>
<button class="refresh-btn" (click)="loadLogs()">Refresh</button>
</div>
@if (loading()) {
<div class="loading">Loading audit logs...</div>
} @else if (logs().length === 0) {
<div class="no-logs">No audit logs found.</div>
} @else {
<div class="logs-table-container">
<table class="logs-table">
<thead>
<tr>
<th>Time</th>
<th>Category</th>
<th>Action</th>
<th>Details</th>
<th>Status</th>
</tr>
</thead>
<tbody>
@for (log of logs(); track log.id) {
<tr [class.failed]="!log.success">
<td class="time">{{ formatDate(log.createdAt) }}</td>
<td>
<span
class="category-badge"
[style.background-color]="auditService.getCategoryColor(log.category)"
>
{{ auditService.getCategoryLabel(log.category) }}
</span>
</td>
<td>{{ auditService.getActionLabel(log.action) }}</td>
<td class="details">{{ log.details ?? '-' }}</td>
<td>
<span class="status-badge" [class.success]="log.success" [class.failed]="!log.success">
{{ log.success ? '✓' : '✗' }}
</span>
</td>
</tr>
}
</tbody>
</table>
</div>
<div class="pagination">
<button
[disabled]="currentPage() <= 1"
(click)="goToPage(currentPage() - 1)"
>
Previous
</button>
<span>Page {{ currentPage() }} of {{ totalPages() }}</span>
<button
[disabled]="currentPage() >= totalPages()"
(click)="goToPage(currentPage() + 1)"
>
Next
</button>
</div>
}
}
</div>
`,
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<AuditLog[]>([]);
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<string, unknown> = {
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();
}
}
@@ -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: `
<div class="admin-container">
<h2>User Management</h2>
@if (loading()) {
<p class="loading">Loading users...</p>
} @else if (error()) {
<p class="error">{{ error() }}</p>
} @else {
<div class="users-list">
@for (user of users(); track user.id) {
<div class="user-card" [class.banned]="user.isBanned">
<div class="user-info">
@if (user.avatarUrl) {
<img [src]="user.avatarUrl" [alt]="user.username" class="avatar" />
} @else {
<div class="avatar-placeholder">{{ user.username.charAt(0).toUpperCase() }}</div>
}
<div class="user-details">
<span class="username">{{ user.username }}</span>
<span class="email">{{ user.email }}</span>
@if (user.isAdmin) {
<span class="admin-badge">Admin</span>
}
@if (user.isBanned) {
<span class="banned-badge">Banned</span>
}
</div>
</div>
<div class="user-actions">
@if (!user.isAdmin) {
@if (user.isBanned) {
<button (click)="unbanUser(user.id)" class="btn btn-unban">Unban</button>
} @else {
<button (click)="banUser(user.id)" class="btn btn-ban">Ban</button>
}
}
</div>
</div>
} @empty {
<p class="no-users">No users found.</p>
}
</div>
}
</div>
`,
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<User[]>([]);
loading = signal(true);
error = signal<string | null>(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');
}
});
}
}
@@ -10,6 +10,7 @@ import { FormsModule } from '@angular/forms';
import { ArtService } from '../../services/art.service'; import { ArtService } from '../../services/art.service';
import { AuthService } from '../../services/auth.service'; import { AuthService } from '../../services/auth.service';
import { CommentsService } from '../../services/comments.service'; import { CommentsService } from '../../services/comments.service';
import { SanitizeService } from '../../services/sanitize.service';
import { Art, CreateArtDto, UpdateArtDto, Comment } from '@library/shared-types'; import { Art, CreateArtDto, UpdateArtDto, Comment } from '@library/shared-types';
@Component({ @Component({
@@ -210,6 +211,11 @@ import { Art, CreateArtDto, UpdateArtDto, Comment } from '@library/shared-types'
@if (expandedComments()[art.id]) { @if (expandedComments()[art.id]) {
<div class="comments-container"> <div class="comments-container">
@if (authService.isAuthenticated()) { @if (authService.isAuthenticated()) {
@if (authService.user()?.isBanned) {
<div class="banned-notice">
You have been banned from commenting.
</div>
} @else {
<form (ngSubmit)="addComment(art.id)" class="comment-form"> <form (ngSubmit)="addComment(art.id)" class="comment-form">
<textarea <textarea
[(ngModel)]="newCommentContent[art.id]" [(ngModel)]="newCommentContent[art.id]"
@@ -220,6 +226,7 @@ import { Art, CreateArtDto, UpdateArtDto, Comment } from '@library/shared-types'
<button type="submit" class="btn btn-primary btn-sm">Post Comment</button> <button type="submit" class="btn btn-primary btn-sm">Post Comment</button>
</form> </form>
} }
}
@if (commentsLoading()[art.id]) { @if (commentsLoading()[art.id]) {
<div class="comments-loading">Loading comments...</div> <div class="comments-loading">Loading comments...</div>
@@ -236,7 +243,7 @@ import { Art, CreateArtDto, UpdateArtDto, Comment } from '@library/shared-types'
<button (click)="deleteComment(art.id, comment.id)" class="btn btn-danger btn-xs">Delete</button> <button (click)="deleteComment(art.id, comment.id)" class="btn btn-danger btn-xs">Delete</button>
} }
</div> </div>
<div class="comment-content" [innerHTML]="comment.content"></div> <div class="comment-content" [innerHTML]="sanitizeService.sanitizeHtml(comment.content)"></div>
</div> </div>
} @empty { } @empty {
<div class="no-comments">No comments yet. Be the first to comment!</div> <div class="no-comments">No comments yet. Be the first to comment!</div>
@@ -621,12 +628,24 @@ import { Art, CreateArtDto, UpdateArtDto, Comment } from '@library/shared-types'
color: var(--witch-mauve); color: var(--witch-mauve);
font-size: 0.9rem; 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 { export class ArtGalleryComponent implements OnInit {
artService = inject(ArtService); artService = inject(ArtService);
authService = inject(AuthService); authService = inject(AuthService);
commentsService = inject(CommentsService); commentsService = inject(CommentsService);
sanitizeService = inject(SanitizeService);
artPieces = signal<Art[]>([]); artPieces = signal<Art[]>([]);
loading = signal(true); loading = signal(true);
@@ -10,6 +10,7 @@ import { FormsModule } from '@angular/forms';
import { BooksService } from '../../services/books.service'; import { BooksService } from '../../services/books.service';
import { AuthService } from '../../services/auth.service'; import { AuthService } from '../../services/auth.service';
import { CommentsService } from '../../services/comments.service'; import { CommentsService } from '../../services/comments.service';
import { SanitizeService } from '../../services/sanitize.service';
import { Book, BookStatus, CreateBookDto, UpdateBookDto, Comment } from '@library/shared-types'; import { Book, BookStatus, CreateBookDto, UpdateBookDto, Comment } from '@library/shared-types';
@Component({ @Component({
@@ -317,6 +318,11 @@ import { Book, BookStatus, CreateBookDto, UpdateBookDto, Comment } from '@librar
@if (expandedComments()[book.id]) { @if (expandedComments()[book.id]) {
<div class="comments-container"> <div class="comments-container">
@if (authService.isAuthenticated()) { @if (authService.isAuthenticated()) {
@if (authService.user()?.isBanned) {
<div class="banned-notice">
You have been banned from commenting.
</div>
} @else {
<form (ngSubmit)="addComment(book.id)" class="comment-form"> <form (ngSubmit)="addComment(book.id)" class="comment-form">
<textarea <textarea
[(ngModel)]="newCommentContent[book.id]" [(ngModel)]="newCommentContent[book.id]"
@@ -327,6 +333,7 @@ import { Book, BookStatus, CreateBookDto, UpdateBookDto, Comment } from '@librar
<button type="submit" class="btn btn-primary btn-sm">Post Comment</button> <button type="submit" class="btn btn-primary btn-sm">Post Comment</button>
</form> </form>
} }
}
@if (commentsLoading()[book.id]) { @if (commentsLoading()[book.id]) {
<div class="comments-loading">Loading comments...</div> <div class="comments-loading">Loading comments...</div>
@@ -343,7 +350,7 @@ import { Book, BookStatus, CreateBookDto, UpdateBookDto, Comment } from '@librar
<button (click)="deleteComment(book.id, comment.id)" class="btn btn-danger btn-xs">Delete</button> <button (click)="deleteComment(book.id, comment.id)" class="btn btn-danger btn-xs">Delete</button>
} }
</div> </div>
<div class="comment-content" [innerHTML]="comment.content"></div> <div class="comment-content" [innerHTML]="sanitizeService.sanitizeHtml(comment.content)"></div>
</div> </div>
} @empty { } @empty {
<div class="no-comments">No comments yet. Be the first to comment!</div> <div class="no-comments">No comments yet. Be the first to comment!</div>
@@ -703,6 +710,17 @@ import { Book, BookStatus, CreateBookDto, UpdateBookDto, Comment } from '@librar
font-size: 0.9rem; 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 { .image-preview {
margin-top: 0.5rem; margin-top: 0.5rem;
display: flex; display: flex;
@@ -741,6 +759,7 @@ export class BooksListComponent implements OnInit {
booksService = inject(BooksService); booksService = inject(BooksService);
authService = inject(AuthService); authService = inject(AuthService);
commentsService = inject(CommentsService); commentsService = inject(CommentsService);
sanitizeService = inject(SanitizeService);
books = signal<Book[]>([]); books = signal<Book[]>([]);
loading = signal(true); loading = signal(true);
@@ -10,6 +10,7 @@ import { FormsModule } from '@angular/forms';
import { GamesService } from '../../services/games.service'; import { GamesService } from '../../services/games.service';
import { AuthService } from '../../services/auth.service'; import { AuthService } from '../../services/auth.service';
import { CommentsService } from '../../services/comments.service'; import { CommentsService } from '../../services/comments.service';
import { SanitizeService } from '../../services/sanitize.service';
import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment } from '@library/shared-types'; import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment } from '@library/shared-types';
@Component({ @Component({
@@ -282,6 +283,11 @@ import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment } from '@librar
@if (expandedComments()[game.id]) { @if (expandedComments()[game.id]) {
<div class="comments-container"> <div class="comments-container">
@if (authService.isAuthenticated()) { @if (authService.isAuthenticated()) {
@if (authService.user()?.isBanned) {
<div class="banned-notice">
You have been banned from commenting.
</div>
} @else {
<form (ngSubmit)="addComment(game.id)" class="comment-form"> <form (ngSubmit)="addComment(game.id)" class="comment-form">
<textarea <textarea
[(ngModel)]="newCommentContent[game.id]" [(ngModel)]="newCommentContent[game.id]"
@@ -292,6 +298,7 @@ import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment } from '@librar
<button type="submit" class="btn btn-primary btn-sm">Post Comment</button> <button type="submit" class="btn btn-primary btn-sm">Post Comment</button>
</form> </form>
} }
}
@if (commentsLoading()[game.id]) { @if (commentsLoading()[game.id]) {
<div class="comments-loading">Loading comments...</div> <div class="comments-loading">Loading comments...</div>
@@ -308,7 +315,7 @@ import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment } from '@librar
<button (click)="deleteComment(game.id, comment.id)" class="btn btn-danger btn-xs">Delete</button> <button (click)="deleteComment(game.id, comment.id)" class="btn btn-danger btn-xs">Delete</button>
} }
</div> </div>
<div class="comment-content" [innerHTML]="comment.content"></div> <div class="comment-content" [innerHTML]="sanitizeService.sanitizeHtml(comment.content)"></div>
</div> </div>
} @empty { } @empty {
<div class="no-comments">No comments yet. Be the first to comment!</div> <div class="no-comments">No comments yet. Be the first to comment!</div>
@@ -582,6 +589,17 @@ import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment } from '@librar
font-size: 0.9rem; 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 { .image-preview {
margin-top: 0.5rem; margin-top: 0.5rem;
display: flex; display: flex;
@@ -620,6 +638,7 @@ export class GamesListComponent implements OnInit {
gamesService = inject(GamesService); gamesService = inject(GamesService);
authService = inject(AuthService); authService = inject(AuthService);
commentsService = inject(CommentsService); commentsService = inject(CommentsService);
sanitizeService = inject(SanitizeService);
games = signal<Game[]>([]); games = signal<Game[]>([]);
loading = signal(true); loading = signal(true);
@@ -33,7 +33,8 @@ import { AuthService } from '../../services/auth.service';
@if (authService.user(); as user) { @if (authService.user(); as user) {
<span class="welcome">Welcome, {{ user.username }}!</span> <span class="welcome">Welcome, {{ user.username }}!</span>
@if (user.isAdmin) { @if (user.isAdmin) {
<span class="admin-badge">Admin</span> <a routerLink="/admin/users" class="admin-badge">Users</a>
<a routerLink="/admin/audit" class="admin-badge">Audit</a>
} }
<button (click)="logout()" class="btn btn-secondary">Logout</button> <button (click)="logout()" class="btn btn-secondary">Logout</button>
} @else { } @else {
@@ -111,6 +112,14 @@ import { AuthService } from '../../services/auth.service';
padding: 0.25rem 0.5rem; padding: 0.25rem 0.5rem;
border-radius: 4px; border-radius: 4px;
font-size: 0.8rem; 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 { .btn {
@@ -10,6 +10,7 @@ import { FormsModule } from '@angular/forms';
import { MangaService } from '../../services/manga.service'; import { MangaService } from '../../services/manga.service';
import { AuthService } from '../../services/auth.service'; import { AuthService } from '../../services/auth.service';
import { CommentsService } from '../../services/comments.service'; import { CommentsService } from '../../services/comments.service';
import { SanitizeService } from '../../services/sanitize.service';
import { Manga, MangaStatus, CreateMangaDto, UpdateMangaDto, Comment } from '@library/shared-types'; import { Manga, MangaStatus, CreateMangaDto, UpdateMangaDto, Comment } from '@library/shared-types';
@Component({ @Component({
@@ -282,6 +283,11 @@ import { Manga, MangaStatus, CreateMangaDto, UpdateMangaDto, Comment } from '@li
@if (expandedComments()[manga.id]) { @if (expandedComments()[manga.id]) {
<div class="comments-container"> <div class="comments-container">
@if (authService.isAuthenticated()) { @if (authService.isAuthenticated()) {
@if (authService.user()?.isBanned) {
<div class="banned-notice">
You have been banned from commenting.
</div>
} @else {
<form (ngSubmit)="addComment(manga.id)" class="comment-form"> <form (ngSubmit)="addComment(manga.id)" class="comment-form">
<textarea <textarea
[(ngModel)]="newCommentContent[manga.id]" [(ngModel)]="newCommentContent[manga.id]"
@@ -292,6 +298,7 @@ import { Manga, MangaStatus, CreateMangaDto, UpdateMangaDto, Comment } from '@li
<button type="submit" class="btn btn-primary btn-sm">Post Comment</button> <button type="submit" class="btn btn-primary btn-sm">Post Comment</button>
</form> </form>
} }
}
@if (commentsLoading()[manga.id]) { @if (commentsLoading()[manga.id]) {
<div class="comments-loading">Loading comments...</div> <div class="comments-loading">Loading comments...</div>
@@ -308,7 +315,7 @@ import { Manga, MangaStatus, CreateMangaDto, UpdateMangaDto, Comment } from '@li
<button (click)="deleteComment(manga.id, comment.id)" class="btn btn-danger btn-xs">Delete</button> <button (click)="deleteComment(manga.id, comment.id)" class="btn btn-danger btn-xs">Delete</button>
} }
</div> </div>
<div class="comment-content" [innerHTML]="comment.content"></div> <div class="comment-content" [innerHTML]="sanitizeService.sanitizeHtml(comment.content)"></div>
</div> </div>
} @empty { } @empty {
<div class="no-comments">No comments yet. Be the first to comment!</div> <div class="no-comments">No comments yet. Be the first to comment!</div>
@@ -584,6 +591,17 @@ import { Manga, MangaStatus, CreateMangaDto, UpdateMangaDto, Comment } from '@li
font-size: 0.9rem; 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 { .image-preview {
margin-top: 0.5rem; margin-top: 0.5rem;
display: flex; display: flex;
@@ -622,6 +640,7 @@ export class MangaListComponent implements OnInit {
mangaService = inject(MangaService); mangaService = inject(MangaService);
authService = inject(AuthService); authService = inject(AuthService);
commentsService = inject(CommentsService); commentsService = inject(CommentsService);
sanitizeService = inject(SanitizeService);
mangaList = signal<Manga[]>([]); mangaList = signal<Manga[]>([]);
loading = signal(true); loading = signal(true);
@@ -10,6 +10,7 @@ import { FormsModule } from '@angular/forms';
import { MusicService } from '../../services/music.service'; import { MusicService } from '../../services/music.service';
import { AuthService } from '../../services/auth.service'; import { AuthService } from '../../services/auth.service';
import { CommentsService } from '../../services/comments.service'; import { CommentsService } from '../../services/comments.service';
import { SanitizeService } from '../../services/sanitize.service';
import { Music, MusicStatus, MusicType, CreateMusicDto, UpdateMusicDto, Comment } from '@library/shared-types'; import { Music, MusicStatus, MusicType, CreateMusicDto, UpdateMusicDto, Comment } from '@library/shared-types';
@Component({ @Component({
@@ -355,6 +356,11 @@ import { Music, MusicStatus, MusicType, CreateMusicDto, UpdateMusicDto, Comment
@if (expandedComments()[music.id]) { @if (expandedComments()[music.id]) {
<div class="comments-container"> <div class="comments-container">
@if (authService.isAuthenticated()) { @if (authService.isAuthenticated()) {
@if (authService.user()?.isBanned) {
<div class="banned-notice">
You have been banned from commenting.
</div>
} @else {
<form (ngSubmit)="addComment(music.id)" class="comment-form"> <form (ngSubmit)="addComment(music.id)" class="comment-form">
<textarea <textarea
[(ngModel)]="newCommentContent[music.id]" [(ngModel)]="newCommentContent[music.id]"
@@ -365,6 +371,7 @@ import { Music, MusicStatus, MusicType, CreateMusicDto, UpdateMusicDto, Comment
<button type="submit" class="btn btn-primary btn-sm">Post Comment</button> <button type="submit" class="btn btn-primary btn-sm">Post Comment</button>
</form> </form>
} }
}
@if (commentsLoading()[music.id]) { @if (commentsLoading()[music.id]) {
<div class="comments-loading">Loading comments...</div> <div class="comments-loading">Loading comments...</div>
@@ -381,7 +388,7 @@ import { Music, MusicStatus, MusicType, CreateMusicDto, UpdateMusicDto, Comment
<button (click)="deleteComment(music.id, comment.id)" class="btn btn-danger btn-xs">Delete</button> <button (click)="deleteComment(music.id, comment.id)" class="btn btn-danger btn-xs">Delete</button>
} }
</div> </div>
<div class="comment-content" [innerHTML]="comment.content"></div> <div class="comment-content" [innerHTML]="sanitizeService.sanitizeHtml(comment.content)"></div>
</div> </div>
} @empty { } @empty {
<div class="no-comments">No comments yet. Be the first to comment!</div> <div class="no-comments">No comments yet. Be the first to comment!</div>
@@ -775,6 +782,17 @@ import { Music, MusicStatus, MusicType, CreateMusicDto, UpdateMusicDto, Comment
font-size: 0.9rem; 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 { .image-preview {
margin-top: 0.5rem; margin-top: 0.5rem;
display: flex; display: flex;
@@ -813,6 +831,7 @@ export class MusicListComponent implements OnInit {
musicService = inject(MusicService); musicService = inject(MusicService);
authService = inject(AuthService); authService = inject(AuthService);
commentsService = inject(CommentsService); commentsService = inject(CommentsService);
sanitizeService = inject(SanitizeService);
music = signal<Music[]>([]); music = signal<Music[]>([]);
loading = signal(true); loading = signal(true);
@@ -10,6 +10,7 @@ import { FormsModule } from '@angular/forms';
import { ShowsService } from '../../services/shows.service'; import { ShowsService } from '../../services/shows.service';
import { AuthService } from '../../services/auth.service'; import { AuthService } from '../../services/auth.service';
import { CommentsService } from '../../services/comments.service'; import { CommentsService } from '../../services/comments.service';
import { SanitizeService } from '../../services/sanitize.service';
import { Show, ShowStatus, ShowType, CreateShowDto, UpdateShowDto, Comment } from '@library/shared-types'; import { Show, ShowStatus, ShowType, CreateShowDto, UpdateShowDto, Comment } from '@library/shared-types';
@Component({ @Component({
@@ -278,6 +279,11 @@ import { Show, ShowStatus, ShowType, CreateShowDto, UpdateShowDto, Comment } fro
@if (expandedComments()[show.id]) { @if (expandedComments()[show.id]) {
<div class="comments-container"> <div class="comments-container">
@if (authService.isAuthenticated()) { @if (authService.isAuthenticated()) {
@if (authService.user()?.isBanned) {
<div class="banned-notice">
You have been banned from commenting.
</div>
} @else {
<form (ngSubmit)="addComment(show.id)" class="comment-form"> <form (ngSubmit)="addComment(show.id)" class="comment-form">
<textarea <textarea
[(ngModel)]="newCommentContent[show.id]" [(ngModel)]="newCommentContent[show.id]"
@@ -288,6 +294,7 @@ import { Show, ShowStatus, ShowType, CreateShowDto, UpdateShowDto, Comment } fro
<button type="submit" class="btn btn-primary btn-sm">Post Comment</button> <button type="submit" class="btn btn-primary btn-sm">Post Comment</button>
</form> </form>
} }
}
@if (commentsLoading()[show.id]) { @if (commentsLoading()[show.id]) {
<div class="comments-loading">Loading comments...</div> <div class="comments-loading">Loading comments...</div>
@@ -304,7 +311,7 @@ import { Show, ShowStatus, ShowType, CreateShowDto, UpdateShowDto, Comment } fro
<button (click)="deleteComment(show.id, comment.id)" class="btn btn-danger btn-xs">Delete</button> <button (click)="deleteComment(show.id, comment.id)" class="btn btn-danger btn-xs">Delete</button>
} }
</div> </div>
<div class="comment-content" [innerHTML]="comment.content"></div> <div class="comment-content" [innerHTML]="sanitizeService.sanitizeHtml(comment.content)"></div>
</div> </div>
} @empty { } @empty {
<div class="no-comments">No comments yet. Be the first to comment!</div> <div class="no-comments">No comments yet. Be the first to comment!</div>
@@ -579,6 +586,17 @@ import { Show, ShowStatus, ShowType, CreateShowDto, UpdateShowDto, Comment } fro
font-size: 0.9rem; 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 { .image-preview {
margin-top: 0.5rem; margin-top: 0.5rem;
display: flex; display: flex;
@@ -617,6 +635,7 @@ export class ShowsListComponent implements OnInit {
showsService = inject(ShowsService); showsService = inject(ShowsService);
authService = inject(AuthService); authService = inject(AuthService);
commentsService = inject(CommentsService); commentsService = inject(CommentsService);
sanitizeService = inject(SanitizeService);
shows = signal<Show[]>([]); shows = signal<Show[]>([]);
loading = signal(true); loading = signal(true);
+54 -19
View File
@@ -4,50 +4,85 @@
* @author Naomi Carrigan * @author Naomi Carrigan
*/ */
import { Injectable } from '@angular/core'; import { Injectable, signal, inject } from '@angular/core';
import { HttpClient, HttpHeaders } from '@angular/common/http'; import { HttpClient, HttpHeaders } from '@angular/common/http';
import { Observable } from 'rxjs'; import { Observable, switchMap, tap, of } from 'rxjs';
import { environment } from '../../environments/environment'; import { environment } from '../../environments/environment';
interface CsrfTokenResponse {
csrfToken: string;
}
@Injectable({ @Injectable({
providedIn: 'root' providedIn: 'root'
}) })
export class ApiService { export class ApiService {
private http = inject(HttpClient);
private readonly apiUrl = environment.apiUrl; private readonly apiUrl = environment.apiUrl;
private csrfToken = signal<string | null>(null);
constructor(private http: HttpClient) {}
private getHeaders(): HttpHeaders { private getHeaders(): HttpHeaders {
// Auth token is sent as httpOnly cookie, no need to add manually const headers: Record<string, string> = {
return new HttpHeaders({
'Content-Type': 'application/json' 'Content-Type': 'application/json'
}); };
const token = this.csrfToken();
if (token) {
headers['X-CSRF-Token'] = token;
}
return new HttpHeaders(headers);
}
private ensureCsrfToken(): Observable<string> {
const existingToken = this.csrfToken();
if (existingToken) {
return of(existingToken);
}
return this.http.get<CsrfTokenResponse>(`${this.apiUrl}/auth/csrf-token`, {
withCredentials: true
}).pipe(
tap(response => this.csrfToken.set(response.csrfToken)),
switchMap(response => of(response.csrfToken))
);
} }
get<T>(endpoint: string): Observable<T> { get<T>(endpoint: string): Observable<T> {
return this.http.get<T>(`${this.apiUrl}${endpoint}`, { return this.http.get<T>(`${this.apiUrl}${endpoint}`, {
headers: this.getHeaders(),
withCredentials: true // Important for cookies
});
}
post<T>(endpoint: string, body: any): Observable<T> {
return this.http.post<T>(`${this.apiUrl}${endpoint}`, body, {
headers: this.getHeaders(), headers: this.getHeaders(),
withCredentials: true withCredentials: true
}); });
} }
put<T>(endpoint: string, body: any): Observable<T> { post<T>(endpoint: string, body: unknown): Observable<T> {
return this.http.put<T>(`${this.apiUrl}${endpoint}`, body, { return this.ensureCsrfToken().pipe(
switchMap(() => this.http.post<T>(`${this.apiUrl}${endpoint}`, body, {
headers: this.getHeaders(), headers: this.getHeaders(),
withCredentials: true withCredentials: true
}); }))
);
}
put<T>(endpoint: string, body: unknown): Observable<T> {
return this.ensureCsrfToken().pipe(
switchMap(() => this.http.put<T>(`${this.apiUrl}${endpoint}`, body, {
headers: this.getHeaders(),
withCredentials: true
}))
);
} }
delete<T>(endpoint: string): Observable<T> { delete<T>(endpoint: string): Observable<T> {
return this.http.delete<T>(`${this.apiUrl}${endpoint}`, { return this.ensureCsrfToken().pipe(
switchMap(() => this.http.delete<T>(`${this.apiUrl}${endpoint}`, {
headers: this.getHeaders(),
withCredentials: true withCredentials: true
}); }))
);
}
clearCsrfToken(): void {
this.csrfToken.set(null);
} }
} }
@@ -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<AuditLogResponse> {
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<AuditLogResponse>(url));
}
async getSecurityLogs(page = 1, limit = 50): Promise<AuditLogResponse> {
return firstValueFrom(this.api.get<AuditLogResponse>(`/audit/security?page=${page}&limit=${limit}`));
}
async getUserLogs(userId: string, page = 1, limit = 50): Promise<AuditLogResponse> {
return firstValueFrom(this.api.get<AuditLogResponse>(`/audit/user/${userId}?page=${page}&limit=${limit}`));
}
getActionLabel(action: AuditAction): string {
const labels: Record<string, string> = {
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<string, string> = {
AUTH: 'Authentication',
CONTENT: 'Content',
ADMIN: 'Administration',
SECURITY: 'Security',
};
return labels[category] ?? category;
}
getCategoryColor(category: AuditCategory): string {
const colors: Record<string, string> = {
AUTH: '#3b82f6', // blue
CONTENT: '#10b981', // green
ADMIN: '#8b5cf6', // purple
SECURITY: '#ef4444', // red
};
return colors[category] ?? '#6b7280';
}
}
@@ -40,6 +40,7 @@ export class AuthService {
return this.api.post<{ message: string }>('/auth/logout', {}).pipe( return this.api.post<{ message: string }>('/auth/logout', {}).pipe(
tap(() => { tap(() => {
this.currentUser.set(null); this.currentUser.set(null);
this.api.clearCsrfToken();
this.router.navigate(['/']); this.router.navigate(['/']);
}) })
); );
@@ -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 ?? '');
}
}
@@ -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<User[]> {
return this.api.get<User[]>('/users');
}
banUser(userId: string): Observable<User> {
return this.api.post<User>(`/users/${userId}/ban`, {});
}
unbanUser(userId: string): Observable<User> {
return this.api.post<User>(`/users/${userId}/unban`, {});
}
}
+5 -1
View File
@@ -24,13 +24,17 @@
"@angular/router": "21.1.2", "@angular/router": "21.1.2",
"@fastify/autoload": "6.0.3", "@fastify/autoload": "6.0.3",
"@fastify/cookie": "^11.0.2", "@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/jwt": "^10.0.0",
"@fastify/oauth2": "^8.1.2", "@fastify/oauth2": "^8.1.2",
"@fastify/rate-limit": "^10.3.0",
"@fastify/sensible": "6.0.4", "@fastify/sensible": "6.0.4",
"@fastify/static": "^9.0.0", "@fastify/static": "^9.0.0",
"@prisma/client": "6.19.2", "@prisma/client": "6.19.2",
"dompurify": "^3.3.1", "dompurify": "^3.3.1",
"fastify": "5.2.2", "fastify": "5.7.3",
"fastify-plugin": "5.0.1", "fastify-plugin": "5.0.1",
"jsdom": "^28.0.0", "jsdom": "^28.0.0",
"marked": "^17.0.1", "marked": "^17.0.1",
+80 -22
View File
@@ -32,12 +32,24 @@ importers:
'@fastify/cookie': '@fastify/cookie':
specifier: ^11.0.2 specifier: ^11.0.2
version: 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': '@fastify/jwt':
specifier: ^10.0.0 specifier: ^10.0.0
version: 10.0.0 version: 10.0.0
'@fastify/oauth2': '@fastify/oauth2':
specifier: ^8.1.2 specifier: ^8.1.2
version: 8.1.2 version: 8.1.2
'@fastify/rate-limit':
specifier: ^10.3.0
version: 10.3.0
'@fastify/sensible': '@fastify/sensible':
specifier: 6.0.4 specifier: 6.0.4
version: 6.0.4 version: 6.0.4
@@ -51,8 +63,8 @@ importers:
specifier: ^3.3.1 specifier: ^3.3.1
version: 3.3.1 version: 3.3.1
fastify: fastify:
specifier: 5.2.2 specifier: 5.7.3
version: 5.2.2 version: 5.7.3
fastify-plugin: fastify-plugin:
specifier: 5.0.1 specifier: 5.0.1
version: 5.0.1 version: 5.0.1
@@ -1599,6 +1611,15 @@ packages:
'@fastify/cookie@11.0.2': '@fastify/cookie@11.0.2':
resolution: {integrity: sha512-GWdwdGlgJxyvNv+QcKiGNevSspMQXncjMZ1J8IvuDQk0jvkzgWWZFNC2En3s+nHndZBGV8IbLwOI/sxCZw/mzA==} 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': '@fastify/error@4.2.0':
resolution: {integrity: sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==} resolution: {integrity: sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==}
@@ -1608,6 +1629,9 @@ packages:
'@fastify/forwarded@3.0.1': '@fastify/forwarded@3.0.1':
resolution: {integrity: sha512-JqDochHFqXs3C3Ml3gOY58zM7OqO9ENqPo0UqAjAjH8L01fRZqwX9iLeX34//kiJubF7r2ZQHtBRU36vONbLlw==} resolution: {integrity: sha512-JqDochHFqXs3C3Ml3gOY58zM7OqO9ENqPo0UqAjAjH8L01fRZqwX9iLeX34//kiJubF7r2ZQHtBRU36vONbLlw==}
'@fastify/helmet@13.0.2':
resolution: {integrity: sha512-tO1QMkOfNeCt9l4sG/FiWErH4QMm+RjHzbMTrgew1DYOQ2vb/6M1G2iNABBrD7Xq6dUk+HLzWW8u+rmmhQHifA==}
'@fastify/jwt@10.0.0': '@fastify/jwt@10.0.0':
resolution: {integrity: sha512-2Qka3NiyNNcsfejMUvyzot1T4UYIzzcbkFGDdVyrl344fRZ/WkD6VFXOoXhxe2Pzf3LpJNkoSxUM4Ru4DVgkYA==} resolution: {integrity: sha512-2Qka3NiyNNcsfejMUvyzot1T4UYIzzcbkFGDdVyrl344fRZ/WkD6VFXOoXhxe2Pzf3LpJNkoSxUM4Ru4DVgkYA==}
@@ -1620,6 +1644,9 @@ packages:
'@fastify/proxy-addr@5.1.0': '@fastify/proxy-addr@5.1.0':
resolution: {integrity: sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==} 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': '@fastify/send@4.1.0':
resolution: {integrity: sha512-TMYeQLCBSy2TOFmV95hQWkiTYgC/SEx7vMdV+wnZVX4tt8VBLKzmH8vV9OzJehV0+XBfg+WxPMt5wp+JBUKsVw==} resolution: {integrity: sha512-TMYeQLCBSy2TOFmV95hQWkiTYgC/SEx7vMdV+wnZVX4tt8VBLKzmH8vV9OzJehV0+XBfg+WxPMt5wp+JBUKsVw==}
@@ -5378,8 +5405,8 @@ packages:
fastify-plugin@5.0.1: fastify-plugin@5.0.1:
resolution: {integrity: sha512-HCxs+YnRaWzCl+cWRYFnHmeRFyR5GVnJTAaCJQiYzQSDwK9MgJdyAsuL3nh0EWRCYMgQ5MeziymvmAhUHYHDUQ==} resolution: {integrity: sha512-HCxs+YnRaWzCl+cWRYFnHmeRFyR5GVnJTAaCJQiYzQSDwK9MgJdyAsuL3nh0EWRCYMgQ5MeziymvmAhUHYHDUQ==}
fastify@5.2.2: fastify@5.7.3:
resolution: {integrity: sha512-22T/PnhquWozuFXg3Ish4md5ipsF1Nx1mJ9ulLdZPXSk14WFj/wMlyNB/yll9sQOojKRgOIxT2inK3Xpjg5hyw==} resolution: {integrity: sha512-QHzWSmTNUg9Ba8tNXzb92FTH77K+c8yeQPH80EeSIc9wyZj85jbPisMP0rwmyKv8oJwUFPe1UpN8HkNIXwCnUQ==}
fastparallel@2.4.1: fastparallel@2.4.1:
resolution: {integrity: sha512-qUmhxPgNHmvRjZKBFUNI0oZuuH9OlSIOXmJ98lhKPxMZZ7zS/Fi0wRHOihDSz0R1YiIOjxzOY4bq65YTcdBi2Q==} resolution: {integrity: sha512-qUmhxPgNHmvRjZKBFUNI0oZuuH9OlSIOXmJ98lhKPxMZZ7zS/Fi0wRHOihDSz0R1YiIOjxzOY4bq65YTcdBi2Q==}
@@ -5745,6 +5772,10 @@ packages:
resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==} resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==}
hasBin: true hasBin: true
helmet@8.1.0:
resolution: {integrity: sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==}
engines: {node: '>=18.0.0'}
homedir-polyfill@1.0.3: homedir-polyfill@1.0.3:
resolution: {integrity: sha512-eSmmWE5bZTK2Nou4g0AI3zZ9rswp7GRKoKXS1BLUkvPviOqs4YTN1djQIqrXy9k5gEtdLPy86JjRwsNM9tnDcA==} resolution: {integrity: sha512-eSmmWE5bZTK2Nou4g0AI3zZ9rswp7GRKoKXS1BLUkvPviOqs4YTN1djQIqrXy9k5gEtdLPy86JjRwsNM9tnDcA==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@@ -7298,14 +7329,14 @@ packages:
resolution: {integrity: sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==} resolution: {integrity: sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==}
engines: {node: '>=6'} engines: {node: '>=6'}
pino-abstract-transport@2.0.0: pino-abstract-transport@3.0.0:
resolution: {integrity: sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==} resolution: {integrity: sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==}
pino-std-serializers@7.1.0: pino-std-serializers@7.1.0:
resolution: {integrity: sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==} resolution: {integrity: sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==}
pino@9.14.0: pino@10.3.0:
resolution: {integrity: sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w==} resolution: {integrity: sha512-0GNPNzHXBKw6U/InGe79A3Crzyk9bcSyObF9/Gfo9DLEf5qj5RF50RSjsu0W1rZ6ZqRGdzDFCRBQvi9/rSGPtA==}
hasBin: true hasBin: true
pirates@4.0.7: pirates@4.0.7:
@@ -8090,8 +8121,8 @@ packages:
secure-compare@3.0.1: secure-compare@3.0.1:
resolution: {integrity: sha512-AckIIV90rPDcBcglUwXPF3kg0P0qmPsPXAj6BBEENQE1p5yA1xfmDJzfi1Tappj37Pv2mVbKpL3Z1T+Nn7k1Qw==} resolution: {integrity: sha512-AckIIV90rPDcBcglUwXPF3kg0P0qmPsPXAj6BBEENQE1p5yA1xfmDJzfi1Tappj37Pv2mVbKpL3Z1T+Nn7k1Qw==}
secure-json-parse@3.0.2: secure-json-parse@4.1.0:
resolution: {integrity: sha512-H6nS2o8bWfpFEV6U38sOSjS7bTbdgbCGU9wEM6W14P5H0QOsz94KCusifV44GpHDTu2nqZbuDNhTzu+mjDSw1w==} resolution: {integrity: sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==}
select-hose@2.0.0: select-hose@2.0.0:
resolution: {integrity: sha512-mEugaLK+YfkijB4fx0e6kImuJdCIt2LxCRcbEYPqRGCs4F2ogyfZU5IAZRdjCP8JPq2AtdNoC/Dux63d9Kiryg==} resolution: {integrity: sha512-mEugaLK+YfkijB4fx0e6kImuJdCIt2LxCRcbEYPqRGCs4F2ogyfZU5IAZRdjCP8JPq2AtdNoC/Dux63d9Kiryg==}
@@ -8538,8 +8569,9 @@ packages:
peerDependencies: peerDependencies:
tslib: ^2 tslib: ^2
thread-stream@3.1.0: thread-stream@4.0.0:
resolution: {integrity: sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==} resolution: {integrity: sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==}
engines: {node: '>=20'}
throttleit@1.0.1: throttleit@1.0.1:
resolution: {integrity: sha512-vDZpf9Chs9mAdfY046mcPt8fg5QSZr37hEH4TXYBnDF+izxgrbRGUAAaBvIk/fJm9aOFCGFd1EsNg5AZCbnQCQ==} resolution: {integrity: sha512-vDZpf9Chs9mAdfY046mcPt8fg5QSZr37hEH4TXYBnDF+izxgrbRGUAAaBvIk/fJm9aOFCGFd1EsNg5AZCbnQCQ==}
@@ -11526,6 +11558,19 @@ snapshots:
cookie: 1.1.1 cookie: 1.1.1
fastify-plugin: 5.0.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/error@4.2.0': {}
'@fastify/fast-json-stringify-compiler@5.0.3': '@fastify/fast-json-stringify-compiler@5.0.3':
@@ -11534,6 +11579,11 @@ snapshots:
'@fastify/forwarded@3.0.1': {} '@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': '@fastify/jwt@10.0.0':
dependencies: dependencies:
'@fastify/error': 4.2.0 '@fastify/error': 4.2.0
@@ -11559,6 +11609,12 @@ snapshots:
'@fastify/forwarded': 3.0.1 '@fastify/forwarded': 3.0.1
ipaddr.js: 2.3.0 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': '@fastify/send@4.1.0':
dependencies: dependencies:
'@lukeed/ms': 2.0.2 '@lukeed/ms': 2.0.2
@@ -16392,7 +16448,7 @@ snapshots:
fastify-plugin@5.0.1: {} fastify-plugin@5.0.1: {}
fastify@5.2.2: fastify@5.7.3:
dependencies: dependencies:
'@fastify/ajv-compiler': 4.0.5 '@fastify/ajv-compiler': 4.0.5
'@fastify/error': 4.2.0 '@fastify/error': 4.2.0
@@ -16403,10 +16459,10 @@ snapshots:
fast-json-stringify: 6.2.0 fast-json-stringify: 6.2.0
find-my-way: 9.4.0 find-my-way: 9.4.0
light-my-request: 6.6.0 light-my-request: 6.6.0
pino: 9.14.0 pino: 10.3.0
process-warning: 4.0.1 process-warning: 5.0.0
rfdc: 1.4.1 rfdc: 1.4.1
secure-json-parse: 3.0.2 secure-json-parse: 4.1.0
semver: 7.7.3 semver: 7.7.3
toad-cache: 3.7.0 toad-cache: 3.7.0
@@ -16814,6 +16870,8 @@ snapshots:
he@1.2.0: {} he@1.2.0: {}
helmet@8.1.0: {}
homedir-polyfill@1.0.3: homedir-polyfill@1.0.3:
dependencies: dependencies:
parse-passwd: 1.0.0 parse-passwd: 1.0.0
@@ -18687,25 +18745,25 @@ snapshots:
pify@4.0.1: pify@4.0.1:
optional: true optional: true
pino-abstract-transport@2.0.0: pino-abstract-transport@3.0.0:
dependencies: dependencies:
split2: 4.2.0 split2: 4.2.0
pino-std-serializers@7.1.0: {} pino-std-serializers@7.1.0: {}
pino@9.14.0: pino@10.3.0:
dependencies: dependencies:
'@pinojs/redact': 0.4.0 '@pinojs/redact': 0.4.0
atomic-sleep: 1.0.0 atomic-sleep: 1.0.0
on-exit-leak-free: 2.1.2 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 pino-std-serializers: 7.1.0
process-warning: 5.0.0 process-warning: 5.0.0
quick-format-unescaped: 4.0.4 quick-format-unescaped: 4.0.4
real-require: 0.2.0 real-require: 0.2.0
safe-stable-stringify: 2.5.0 safe-stable-stringify: 2.5.0
sonic-boom: 4.2.0 sonic-boom: 4.2.0
thread-stream: 3.1.0 thread-stream: 4.0.0
pirates@4.0.7: {} pirates@4.0.7: {}
@@ -19516,7 +19574,7 @@ snapshots:
secure-compare@3.0.1: {} secure-compare@3.0.1: {}
secure-json-parse@3.0.2: {} secure-json-parse@4.1.0: {}
select-hose@2.0.0: {} select-hose@2.0.0: {}
@@ -20102,7 +20160,7 @@ snapshots:
dependencies: dependencies:
tslib: 2.8.1 tslib: 2.8.1
thread-stream@3.1.0: thread-stream@4.0.0:
dependencies: dependencies:
real-require: 0.2.0 real-require: 0.2.0
+1
View File
@@ -11,3 +11,4 @@ export * from "./lib/show.types";
export * from "./lib/manga.types"; export * from "./lib/manga.types";
export type * from "./lib/auth.types"; export type * from "./lib/auth.types";
export * from "./lib/comment.types"; export * from "./lib/comment.types";
export * from "./lib/audit.types";
+47
View File
@@ -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;
}
+1
View File
@@ -11,6 +11,7 @@ export interface User {
avatarUrl?: string; avatarUrl?: string;
discordId: string; discordId: string;
isAdmin: boolean; isAdmin: boolean;
isBanned: boolean;
} }
export interface JwtPayload { export interface JwtPayload {