generated from nhcarrigan/template
feat: security and auditing
This commit is contained in:
@@ -152,6 +152,7 @@ model User {
|
||||
email String @unique
|
||||
avatar String?
|
||||
isAdmin Boolean @default(false)
|
||||
isBanned Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
comments Comment[]
|
||||
@@ -177,3 +178,40 @@ model Comment {
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model AuditLog {
|
||||
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||
action AuditAction
|
||||
category AuditCategory
|
||||
userId String? @db.ObjectId
|
||||
targetUserId String? @db.ObjectId
|
||||
resourceType String?
|
||||
resourceId String?
|
||||
details String?
|
||||
userAgent String?
|
||||
success Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
}
|
||||
|
||||
enum AuditAction {
|
||||
LOGIN
|
||||
LOGOUT
|
||||
LOGIN_FAILED
|
||||
COMMENT_CREATE
|
||||
COMMENT_DELETE
|
||||
ENTRY_CREATE
|
||||
ENTRY_UPDATE
|
||||
ENTRY_DELETE
|
||||
USER_BAN
|
||||
USER_UNBAN
|
||||
RATE_LIMIT_EXCEEDED
|
||||
CSRF_VALIDATION_FAILED
|
||||
UNAUTHORIZED_ACCESS
|
||||
}
|
||||
|
||||
enum AuditCategory {
|
||||
AUTH
|
||||
CONTENT
|
||||
ADMIN
|
||||
SECURITY
|
||||
}
|
||||
|
||||
+37
-3
@@ -1,14 +1,48 @@
|
||||
import * as path from 'path';
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { FastifyInstance, FastifyError } from 'fastify';
|
||||
import AutoLoad from '@fastify/autoload';
|
||||
import { AuditService } from './services/audit.service';
|
||||
import { AuditAction, AuditCategory } from '@library/shared-types';
|
||||
|
||||
/* eslint-disable-next-line */
|
||||
export interface AppOptions {}
|
||||
|
||||
export async function app(fastify: FastifyInstance, opts: AppOptions) {
|
||||
// Place here your custom code!
|
||||
// Add global error handler for security event logging
|
||||
fastify.setErrorHandler(async (error: FastifyError, request, reply) => {
|
||||
// Log CSRF validation failures
|
||||
if (error.code === 'FST_CSRF_INVALID_TOKEN' || error.code === 'FST_CSRF_MISSING_SECRET') {
|
||||
await AuditService.log({
|
||||
action: AuditAction.CSRF_VALIDATION_FAILED,
|
||||
category: AuditCategory.SECURITY,
|
||||
details: `CSRF validation failed: ${error.message}, URL: ${request.url}`,
|
||||
success: false,
|
||||
}, request).catch(() => {
|
||||
// Ignore logging errors
|
||||
});
|
||||
}
|
||||
|
||||
// Do not touch the following lines
|
||||
// Log unauthorized access attempts
|
||||
if (error.statusCode === 401 || error.statusCode === 403) {
|
||||
await AuditService.log({
|
||||
action: AuditAction.UNAUTHORIZED_ACCESS,
|
||||
category: AuditCategory.SECURITY,
|
||||
details: `Unauthorized access attempt: ${error.message}, URL: ${request.url}`,
|
||||
success: false,
|
||||
}, request).catch(() => {
|
||||
// Ignore logging errors
|
||||
});
|
||||
}
|
||||
|
||||
// Send the error response (don't leak internal details for server errors)
|
||||
const statusCode = error.statusCode ?? 500;
|
||||
|
||||
reply.status(statusCode).send({
|
||||
statusCode,
|
||||
error: statusCode >= 500 ? "Internal Server Error" : error.name,
|
||||
message: statusCode >= 500 ? "An unexpected error occurred" : error.message,
|
||||
});
|
||||
});
|
||||
|
||||
// This loads all plugins defined in plugins
|
||||
// those should be support plugins that are reused
|
||||
|
||||
@@ -5,17 +5,27 @@
|
||||
*/
|
||||
|
||||
import { FastifyReply, FastifyRequest } from "fastify";
|
||||
import { UserService } from "../services/user.service";
|
||||
|
||||
const userService = new UserService();
|
||||
|
||||
/**
|
||||
* Middleware to check if the authenticated user is an admin.
|
||||
* Must be used after app.authenticate.
|
||||
* Always checks the database to ensure admin status is current.
|
||||
*/
|
||||
export async function adminGuard(
|
||||
request: FastifyRequest,
|
||||
reply: FastifyReply
|
||||
): Promise<void> {
|
||||
const user = request.user as any;
|
||||
if (!user || !user.isAdmin) {
|
||||
const user = request.user as { id: string };
|
||||
|
||||
if (!user?.id) {
|
||||
return reply.code(401).send({ error: "Unauthorized" });
|
||||
}
|
||||
|
||||
const dbUser = await userService.getUserById(user.id);
|
||||
if (!dbUser?.isAdmin) {
|
||||
return reply.code(403).send({ error: "Forbidden: Admin access required" });
|
||||
}
|
||||
}
|
||||
@@ -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,13 +23,34 @@ declare module "@fastify/jwt" {
|
||||
}
|
||||
}
|
||||
|
||||
const getJwtSecret = (): string => {
|
||||
const secret = process.env.JWT_SECRET;
|
||||
if (!secret) {
|
||||
throw new Error("JWT_SECRET environment variable is required");
|
||||
}
|
||||
return secret;
|
||||
};
|
||||
|
||||
const authPlugin: FastifyPluginAsync = async (app) => {
|
||||
const jwtSecret = getJwtSecret();
|
||||
|
||||
// Register cookie plugin with signing secret
|
||||
app.register(fastifyCookie, {
|
||||
secret: jwtSecret,
|
||||
});
|
||||
|
||||
// Register JWT plugin
|
||||
app.register(fastifyJwt, {
|
||||
secret: process.env.JWT_SECRET || "your-secret-key",
|
||||
secret: jwtSecret,
|
||||
sign: {
|
||||
algorithm: "HS256",
|
||||
},
|
||||
verify: {
|
||||
algorithms: ["HS256"],
|
||||
},
|
||||
cookie: {
|
||||
cookieName: "auth-token",
|
||||
signed: false,
|
||||
signed: true,
|
||||
},
|
||||
formatUser: (payload: { sub: string; email?: string; username: string; isAdmin: boolean }) => {
|
||||
return {
|
||||
@@ -41,9 +62,6 @@ const authPlugin: FastifyPluginAsync = async (app) => {
|
||||
},
|
||||
});
|
||||
|
||||
// Register cookie plugin
|
||||
app.register(fastifyCookie);
|
||||
|
||||
// Register Discord OAuth2
|
||||
app.register(fastifyOauth2, {
|
||||
name: "oauth2Discord",
|
||||
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -5,10 +5,12 @@
|
||||
*/
|
||||
|
||||
import { FastifyPluginAsync } from "fastify";
|
||||
import { Art, CreateArtDto, UpdateArtDto, Comment, CreateCommentDto } from "@library/shared-types";
|
||||
import { Art, CreateArtDto, UpdateArtDto, Comment, CreateCommentDto, AuditAction, AuditCategory } from "@library/shared-types";
|
||||
import { ArtService } from "../../services/art.service";
|
||||
import { CommentService } from "../../services/comment.service";
|
||||
import { AuditService } from "../../services/audit.service";
|
||||
import { adminGuard } from "../../middleware/admin-guard";
|
||||
import { bannedGuard } from "../../middleware/banned-guard";
|
||||
|
||||
const artRoutes: FastifyPluginAsync = async (app) => {
|
||||
const artService = new ArtService();
|
||||
@@ -39,9 +41,18 @@ const artRoutes: FastifyPluginAsync = async (app) => {
|
||||
"/",
|
||||
{
|
||||
preValidation: [app.authenticate, adminGuard],
|
||||
preHandler: [app.csrfProtection],
|
||||
},
|
||||
async (request) => {
|
||||
return artService.createArt(request.body);
|
||||
const art = await artService.createArt(request.body);
|
||||
await AuditService.logFromRequest(request, {
|
||||
action: AuditAction.ENTRY_CREATE,
|
||||
category: AuditCategory.CONTENT,
|
||||
resourceType: "art",
|
||||
resourceId: art.id,
|
||||
details: `Created art: ${art.title}`,
|
||||
});
|
||||
return art;
|
||||
}
|
||||
);
|
||||
|
||||
@@ -56,10 +67,21 @@ const artRoutes: FastifyPluginAsync = async (app) => {
|
||||
"/:id",
|
||||
{
|
||||
preValidation: [app.authenticate, adminGuard],
|
||||
preHandler: [app.csrfProtection],
|
||||
},
|
||||
async (request) => {
|
||||
const { id } = request.params;
|
||||
return artService.updateArt(id, request.body);
|
||||
const art = await artService.updateArt(id, request.body);
|
||||
if (art) {
|
||||
await AuditService.logFromRequest(request, {
|
||||
action: AuditAction.ENTRY_UPDATE,
|
||||
category: AuditCategory.CONTENT,
|
||||
resourceType: "art",
|
||||
resourceId: id,
|
||||
details: `Updated art: ${art.title}`,
|
||||
});
|
||||
}
|
||||
return art;
|
||||
}
|
||||
);
|
||||
|
||||
@@ -70,10 +92,18 @@ const artRoutes: FastifyPluginAsync = async (app) => {
|
||||
"/:id",
|
||||
{
|
||||
preValidation: [app.authenticate, adminGuard],
|
||||
preHandler: [app.csrfProtection],
|
||||
},
|
||||
async (request) => {
|
||||
const { id } = request.params;
|
||||
await artService.deleteArt(id);
|
||||
await AuditService.logFromRequest(request, {
|
||||
action: AuditAction.ENTRY_DELETE,
|
||||
category: AuditCategory.CONTENT,
|
||||
resourceType: "art",
|
||||
resourceId: id,
|
||||
details: `Deleted art with ID: ${id}`,
|
||||
});
|
||||
return { success: true };
|
||||
}
|
||||
);
|
||||
@@ -95,12 +125,21 @@ const artRoutes: FastifyPluginAsync = async (app) => {
|
||||
app.post<{ Params: { id: string }; Body: CreateCommentDto; Reply: Comment }>(
|
||||
"/:id/comments",
|
||||
{
|
||||
preValidation: [app.authenticate],
|
||||
preValidation: [app.authenticate, bannedGuard],
|
||||
preHandler: [app.csrfProtection],
|
||||
},
|
||||
async (request) => {
|
||||
const { id } = request.params;
|
||||
const userId = request.user.id;
|
||||
return commentService.createCommentForArt(id, userId, request.body);
|
||||
const comment = await commentService.createCommentForArt(id, userId, request.body);
|
||||
await AuditService.logFromRequest(request, {
|
||||
action: AuditAction.COMMENT_CREATE,
|
||||
category: AuditCategory.CONTENT,
|
||||
resourceType: "art",
|
||||
resourceId: id,
|
||||
details: `Added comment to art`,
|
||||
});
|
||||
return comment;
|
||||
}
|
||||
);
|
||||
|
||||
@@ -111,10 +150,18 @@ const artRoutes: FastifyPluginAsync = async (app) => {
|
||||
"/:id/comments/:commentId",
|
||||
{
|
||||
preValidation: [app.authenticate, adminGuard],
|
||||
preHandler: [app.csrfProtection],
|
||||
},
|
||||
async (request) => {
|
||||
const { commentId } = request.params;
|
||||
const { id, commentId } = request.params;
|
||||
await commentService.deleteComment(commentId);
|
||||
await AuditService.logFromRequest(request, {
|
||||
action: AuditAction.COMMENT_DELETE,
|
||||
category: AuditCategory.ADMIN,
|
||||
resourceType: "art",
|
||||
resourceId: id,
|
||||
details: `Deleted comment ${commentId} from art`,
|
||||
});
|
||||
return { success: true };
|
||||
}
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
@@ -1,6 +1,7 @@
|
||||
import { FastifyPluginAsync } from "fastify";
|
||||
import { AuthService } from "../../services/auth.service";
|
||||
import { AuthResponse } from "@library/shared-types";
|
||||
import { AuditService } from "../../services/audit.service";
|
||||
import { AuthResponse, AuditAction, AuditCategory } from "@library/shared-types";
|
||||
|
||||
const authRoutes: FastifyPluginAsync = async (app) => {
|
||||
const authService = new AuthService(app);
|
||||
@@ -33,7 +34,16 @@ const authRoutes: FastifyPluginAsync = async (app) => {
|
||||
// Generate JWT
|
||||
const jwt = await authService.generateToken(user);
|
||||
|
||||
// Set cookie and redirect to frontend
|
||||
// Log successful login
|
||||
await AuditService.log({
|
||||
action: AuditAction.LOGIN,
|
||||
category: AuditCategory.AUTH,
|
||||
userId: user.id,
|
||||
details: `User ${user.username} logged in via Discord`,
|
||||
success: true,
|
||||
}, request);
|
||||
|
||||
// Set signed cookie and redirect to frontend
|
||||
reply
|
||||
.setCookie("auth-token", jwt, {
|
||||
path: "/",
|
||||
@@ -41,13 +51,22 @@ const authRoutes: FastifyPluginAsync = async (app) => {
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
sameSite: "lax",
|
||||
maxAge: 7 * 24 * 60 * 60, // 7 days
|
||||
signed: true,
|
||||
})
|
||||
.redirect("/"); // Redirect to root since API serves frontend
|
||||
} catch (error) {
|
||||
// Log failed login attempt
|
||||
await AuditService.log({
|
||||
action: AuditAction.LOGIN_FAILED,
|
||||
category: AuditCategory.SECURITY,
|
||||
details: error instanceof Error ? error.message : String(error),
|
||||
success: false,
|
||||
}, request);
|
||||
|
||||
app.log.error({ err: error }, "Auth callback error");
|
||||
reply
|
||||
.code(401)
|
||||
.send({ error: "Authentication failed", details: error instanceof Error ? error.message : String(error) });
|
||||
.send({ error: "Authentication failed" });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -59,8 +78,14 @@ const authRoutes: FastifyPluginAsync = async (app) => {
|
||||
{
|
||||
preValidation: [app.authenticate],
|
||||
},
|
||||
async (request) => {
|
||||
const user = request.user as any;
|
||||
async (request, reply) => {
|
||||
const jwtUser = request.user as { id: string };
|
||||
const user = await authService.getUserById(jwtUser.id);
|
||||
|
||||
if (!user) {
|
||||
return reply.code(404).send({ error: "User not found" });
|
||||
}
|
||||
|
||||
const token = await authService.generateToken(user);
|
||||
|
||||
return {
|
||||
@@ -74,12 +99,38 @@ const authRoutes: FastifyPluginAsync = async (app) => {
|
||||
* Logout.
|
||||
*/
|
||||
app.post("/logout", async (request, reply) => {
|
||||
// Try to get user ID from JWT if available
|
||||
try {
|
||||
await request.jwtVerify();
|
||||
const user = request.user as { id?: string; username?: string };
|
||||
if (user?.id) {
|
||||
await AuditService.log({
|
||||
action: AuditAction.LOGOUT,
|
||||
category: AuditCategory.AUTH,
|
||||
userId: user.id,
|
||||
details: `User ${user.username ?? "unknown"} logged out`,
|
||||
success: true,
|
||||
}, request);
|
||||
}
|
||||
} catch {
|
||||
// User wasn't authenticated, just proceed with logout
|
||||
}
|
||||
|
||||
reply
|
||||
.clearCookie("auth-token", {
|
||||
path: "/",
|
||||
signed: true,
|
||||
})
|
||||
.send({ message: "Logged out successfully" });
|
||||
});
|
||||
|
||||
/**
|
||||
* Get CSRF token for state-changing requests.
|
||||
*/
|
||||
app.get("/csrf-token", async (request, reply) => {
|
||||
const token = reply.generateCsrf();
|
||||
return { csrfToken: token };
|
||||
});
|
||||
};
|
||||
|
||||
export default authRoutes;
|
||||
@@ -5,10 +5,12 @@
|
||||
*/
|
||||
|
||||
import { FastifyPluginAsync } from "fastify";
|
||||
import { Book, CreateBookDto, UpdateBookDto, Comment, CreateCommentDto } from "@library/shared-types";
|
||||
import { Book, CreateBookDto, UpdateBookDto, Comment, CreateCommentDto, AuditAction, AuditCategory } from "@library/shared-types";
|
||||
import { BookService } from "../../services/book.service";
|
||||
import { CommentService } from "../../services/comment.service";
|
||||
import { AuditService } from "../../services/audit.service";
|
||||
import { adminGuard } from "../../middleware/admin-guard";
|
||||
import { bannedGuard } from "../../middleware/banned-guard";
|
||||
|
||||
const booksRoutes: FastifyPluginAsync = async (app) => {
|
||||
const bookService = new BookService();
|
||||
@@ -39,9 +41,18 @@ const booksRoutes: FastifyPluginAsync = async (app) => {
|
||||
"/",
|
||||
{
|
||||
preValidation: [app.authenticate, adminGuard],
|
||||
preHandler: [app.csrfProtection],
|
||||
},
|
||||
async (request) => {
|
||||
return bookService.createBook(request.body);
|
||||
const book = await bookService.createBook(request.body);
|
||||
await AuditService.logFromRequest(request, {
|
||||
action: AuditAction.ENTRY_CREATE,
|
||||
category: AuditCategory.CONTENT,
|
||||
resourceType: "book",
|
||||
resourceId: book.id,
|
||||
details: `Created book: ${book.title}`,
|
||||
});
|
||||
return book;
|
||||
}
|
||||
);
|
||||
|
||||
@@ -56,10 +67,21 @@ const booksRoutes: FastifyPluginAsync = async (app) => {
|
||||
"/:id",
|
||||
{
|
||||
preValidation: [app.authenticate, adminGuard],
|
||||
preHandler: [app.csrfProtection],
|
||||
},
|
||||
async (request) => {
|
||||
const { id } = request.params;
|
||||
return bookService.updateBook(id, request.body);
|
||||
const book = await bookService.updateBook(id, request.body);
|
||||
if (book) {
|
||||
await AuditService.logFromRequest(request, {
|
||||
action: AuditAction.ENTRY_UPDATE,
|
||||
category: AuditCategory.CONTENT,
|
||||
resourceType: "book",
|
||||
resourceId: id,
|
||||
details: `Updated book: ${book.title}`,
|
||||
});
|
||||
}
|
||||
return book;
|
||||
}
|
||||
);
|
||||
|
||||
@@ -70,10 +92,18 @@ const booksRoutes: FastifyPluginAsync = async (app) => {
|
||||
"/:id",
|
||||
{
|
||||
preValidation: [app.authenticate, adminGuard],
|
||||
preHandler: [app.csrfProtection],
|
||||
},
|
||||
async (request) => {
|
||||
const { id } = request.params;
|
||||
await bookService.deleteBook(id);
|
||||
await AuditService.logFromRequest(request, {
|
||||
action: AuditAction.ENTRY_DELETE,
|
||||
category: AuditCategory.CONTENT,
|
||||
resourceType: "book",
|
||||
resourceId: id,
|
||||
details: `Deleted book with ID: ${id}`,
|
||||
});
|
||||
return { success: true };
|
||||
}
|
||||
);
|
||||
@@ -95,12 +125,21 @@ const booksRoutes: FastifyPluginAsync = async (app) => {
|
||||
app.post<{ Params: { id: string }; Body: CreateCommentDto; Reply: Comment }>(
|
||||
"/:id/comments",
|
||||
{
|
||||
preValidation: [app.authenticate],
|
||||
preValidation: [app.authenticate, bannedGuard],
|
||||
preHandler: [app.csrfProtection],
|
||||
},
|
||||
async (request) => {
|
||||
const { id } = request.params;
|
||||
const userId = request.user.id;
|
||||
return commentService.createCommentForBook(id, userId, request.body);
|
||||
const comment = await commentService.createCommentForBook(id, userId, request.body);
|
||||
await AuditService.logFromRequest(request, {
|
||||
action: AuditAction.COMMENT_CREATE,
|
||||
category: AuditCategory.CONTENT,
|
||||
resourceType: "book",
|
||||
resourceId: id,
|
||||
details: `Added comment to book`,
|
||||
});
|
||||
return comment;
|
||||
}
|
||||
);
|
||||
|
||||
@@ -111,10 +150,18 @@ const booksRoutes: FastifyPluginAsync = async (app) => {
|
||||
"/:id/comments/:commentId",
|
||||
{
|
||||
preValidation: [app.authenticate, adminGuard],
|
||||
preHandler: [app.csrfProtection],
|
||||
},
|
||||
async (request) => {
|
||||
const { commentId } = request.params;
|
||||
const { id, commentId } = request.params;
|
||||
await commentService.deleteComment(commentId);
|
||||
await AuditService.logFromRequest(request, {
|
||||
action: AuditAction.COMMENT_DELETE,
|
||||
category: AuditCategory.ADMIN,
|
||||
resourceType: "book",
|
||||
resourceId: id,
|
||||
details: `Deleted comment ${commentId} from book`,
|
||||
});
|
||||
return { success: true };
|
||||
}
|
||||
);
|
||||
|
||||
@@ -5,10 +5,12 @@
|
||||
*/
|
||||
|
||||
import { FastifyPluginAsync } from "fastify";
|
||||
import { Game, CreateGameDto, UpdateGameDto, Comment, CreateCommentDto } from "@library/shared-types";
|
||||
import { Game, CreateGameDto, UpdateGameDto, Comment, CreateCommentDto, AuditAction, AuditCategory } from "@library/shared-types";
|
||||
import { GameService } from "../../services/game.service";
|
||||
import { CommentService } from "../../services/comment.service";
|
||||
import { AuditService } from "../../services/audit.service";
|
||||
import { adminGuard } from "../../middleware/admin-guard";
|
||||
import { bannedGuard } from "../../middleware/banned-guard";
|
||||
|
||||
const gamesRoutes: FastifyPluginAsync = async (app) => {
|
||||
const gameService = new GameService();
|
||||
@@ -33,9 +35,18 @@ const gamesRoutes: FastifyPluginAsync = async (app) => {
|
||||
"/",
|
||||
{
|
||||
preValidation: [app.authenticate, adminGuard],
|
||||
preHandler: [app.csrfProtection],
|
||||
},
|
||||
async (request) => {
|
||||
return gameService.createGame(request.body);
|
||||
const game = await gameService.createGame(request.body);
|
||||
await AuditService.logFromRequest(request, {
|
||||
action: AuditAction.ENTRY_CREATE,
|
||||
category: AuditCategory.CONTENT,
|
||||
resourceType: "game",
|
||||
resourceId: game.id,
|
||||
details: `Created game: ${game.title}`,
|
||||
});
|
||||
return game;
|
||||
}
|
||||
);
|
||||
|
||||
@@ -48,10 +59,21 @@ const gamesRoutes: FastifyPluginAsync = async (app) => {
|
||||
"/:id",
|
||||
{
|
||||
preValidation: [app.authenticate, adminGuard],
|
||||
preHandler: [app.csrfProtection],
|
||||
},
|
||||
async (request) => {
|
||||
const { id } = request.params;
|
||||
return gameService.updateGame(id, request.body);
|
||||
const game = await gameService.updateGame(id, request.body);
|
||||
if (game) {
|
||||
await AuditService.logFromRequest(request, {
|
||||
action: AuditAction.ENTRY_UPDATE,
|
||||
category: AuditCategory.CONTENT,
|
||||
resourceType: "game",
|
||||
resourceId: id,
|
||||
details: `Updated game: ${game.title}`,
|
||||
});
|
||||
}
|
||||
return game;
|
||||
}
|
||||
);
|
||||
|
||||
@@ -60,10 +82,18 @@ const gamesRoutes: FastifyPluginAsync = async (app) => {
|
||||
"/:id",
|
||||
{
|
||||
preValidation: [app.authenticate, adminGuard],
|
||||
preHandler: [app.csrfProtection],
|
||||
},
|
||||
async (request) => {
|
||||
const { id } = request.params;
|
||||
await gameService.deleteGame(id);
|
||||
await AuditService.logFromRequest(request, {
|
||||
action: AuditAction.ENTRY_DELETE,
|
||||
category: AuditCategory.CONTENT,
|
||||
resourceType: "game",
|
||||
resourceId: id,
|
||||
details: `Deleted game with ID: ${id}`,
|
||||
});
|
||||
return { success: true };
|
||||
}
|
||||
);
|
||||
@@ -81,12 +111,21 @@ const gamesRoutes: FastifyPluginAsync = async (app) => {
|
||||
app.post<{ Params: { id: string }; Body: CreateCommentDto; Reply: Comment }>(
|
||||
"/:id/comments",
|
||||
{
|
||||
preValidation: [app.authenticate],
|
||||
preValidation: [app.authenticate, bannedGuard],
|
||||
preHandler: [app.csrfProtection],
|
||||
},
|
||||
async (request) => {
|
||||
const { id } = request.params;
|
||||
const userId = request.user.id;
|
||||
return commentService.createCommentForGame(id, userId, request.body);
|
||||
const comment = await commentService.createCommentForGame(id, userId, request.body);
|
||||
await AuditService.logFromRequest(request, {
|
||||
action: AuditAction.COMMENT_CREATE,
|
||||
category: AuditCategory.CONTENT,
|
||||
resourceType: "game",
|
||||
resourceId: id,
|
||||
details: `Added comment to game`,
|
||||
});
|
||||
return comment;
|
||||
}
|
||||
);
|
||||
|
||||
@@ -95,10 +134,18 @@ const gamesRoutes: FastifyPluginAsync = async (app) => {
|
||||
"/:id/comments/:commentId",
|
||||
{
|
||||
preValidation: [app.authenticate, adminGuard],
|
||||
preHandler: [app.csrfProtection],
|
||||
},
|
||||
async (request) => {
|
||||
const { commentId } = request.params;
|
||||
const { id, commentId } = request.params;
|
||||
await commentService.deleteComment(commentId);
|
||||
await AuditService.logFromRequest(request, {
|
||||
action: AuditAction.COMMENT_DELETE,
|
||||
category: AuditCategory.ADMIN,
|
||||
resourceType: "game",
|
||||
resourceId: id,
|
||||
details: `Deleted comment ${commentId} from game`,
|
||||
});
|
||||
return { success: true };
|
||||
}
|
||||
);
|
||||
|
||||
@@ -5,10 +5,12 @@
|
||||
*/
|
||||
|
||||
import { FastifyPluginAsync } from "fastify";
|
||||
import { Manga, CreateMangaDto, UpdateMangaDto, Comment, CreateCommentDto } from "@library/shared-types";
|
||||
import { Manga, CreateMangaDto, UpdateMangaDto, Comment, CreateCommentDto, AuditAction, AuditCategory } from "@library/shared-types";
|
||||
import { MangaService } from "../../services/manga.service";
|
||||
import { CommentService } from "../../services/comment.service";
|
||||
import { AuditService } from "../../services/audit.service";
|
||||
import { adminGuard } from "../../middleware/admin-guard";
|
||||
import { bannedGuard } from "../../middleware/banned-guard";
|
||||
|
||||
const mangaRoutes: FastifyPluginAsync = async (app) => {
|
||||
const mangaService = new MangaService();
|
||||
@@ -30,9 +32,18 @@ const mangaRoutes: FastifyPluginAsync = async (app) => {
|
||||
"/",
|
||||
{
|
||||
preValidation: [app.authenticate, adminGuard],
|
||||
preHandler: [app.csrfProtection],
|
||||
},
|
||||
async (request) => {
|
||||
return mangaService.createManga(request.body);
|
||||
const manga = await mangaService.createManga(request.body);
|
||||
await AuditService.logFromRequest(request, {
|
||||
action: AuditAction.ENTRY_CREATE,
|
||||
category: AuditCategory.CONTENT,
|
||||
resourceType: "manga",
|
||||
resourceId: manga.id,
|
||||
details: `Created manga: ${manga.title}`,
|
||||
});
|
||||
return manga;
|
||||
}
|
||||
);
|
||||
|
||||
@@ -44,10 +55,21 @@ const mangaRoutes: FastifyPluginAsync = async (app) => {
|
||||
"/:id",
|
||||
{
|
||||
preValidation: [app.authenticate, adminGuard],
|
||||
preHandler: [app.csrfProtection],
|
||||
},
|
||||
async (request) => {
|
||||
const { id } = request.params;
|
||||
return mangaService.updateManga(id, request.body);
|
||||
const manga = await mangaService.updateManga(id, request.body);
|
||||
if (manga) {
|
||||
await AuditService.logFromRequest(request, {
|
||||
action: AuditAction.ENTRY_UPDATE,
|
||||
category: AuditCategory.CONTENT,
|
||||
resourceType: "manga",
|
||||
resourceId: id,
|
||||
details: `Updated manga: ${manga.title}`,
|
||||
});
|
||||
}
|
||||
return manga;
|
||||
}
|
||||
);
|
||||
|
||||
@@ -55,10 +77,18 @@ const mangaRoutes: FastifyPluginAsync = async (app) => {
|
||||
"/:id",
|
||||
{
|
||||
preValidation: [app.authenticate, adminGuard],
|
||||
preHandler: [app.csrfProtection],
|
||||
},
|
||||
async (request) => {
|
||||
const { id } = request.params;
|
||||
await mangaService.deleteManga(id);
|
||||
await AuditService.logFromRequest(request, {
|
||||
action: AuditAction.ENTRY_DELETE,
|
||||
category: AuditCategory.CONTENT,
|
||||
resourceType: "manga",
|
||||
resourceId: id,
|
||||
details: `Deleted manga with ID: ${id}`,
|
||||
});
|
||||
return { success: true };
|
||||
}
|
||||
);
|
||||
@@ -74,12 +104,21 @@ const mangaRoutes: FastifyPluginAsync = async (app) => {
|
||||
app.post<{ Params: { id: string }; Body: CreateCommentDto; Reply: Comment }>(
|
||||
"/:id/comments",
|
||||
{
|
||||
preValidation: [app.authenticate],
|
||||
preValidation: [app.authenticate, bannedGuard],
|
||||
preHandler: [app.csrfProtection],
|
||||
},
|
||||
async (request) => {
|
||||
const { id } = request.params;
|
||||
const userId = request.user.id;
|
||||
return commentService.createCommentForManga(id, userId, request.body);
|
||||
const comment = await commentService.createCommentForManga(id, userId, request.body);
|
||||
await AuditService.logFromRequest(request, {
|
||||
action: AuditAction.COMMENT_CREATE,
|
||||
category: AuditCategory.CONTENT,
|
||||
resourceType: "manga",
|
||||
resourceId: id,
|
||||
details: `Added comment to manga`,
|
||||
});
|
||||
return comment;
|
||||
}
|
||||
);
|
||||
|
||||
@@ -87,10 +126,18 @@ const mangaRoutes: FastifyPluginAsync = async (app) => {
|
||||
"/:id/comments/:commentId",
|
||||
{
|
||||
preValidation: [app.authenticate, adminGuard],
|
||||
preHandler: [app.csrfProtection],
|
||||
},
|
||||
async (request) => {
|
||||
const { commentId } = request.params;
|
||||
const { id, commentId } = request.params;
|
||||
await commentService.deleteComment(commentId);
|
||||
await AuditService.logFromRequest(request, {
|
||||
action: AuditAction.COMMENT_DELETE,
|
||||
category: AuditCategory.ADMIN,
|
||||
resourceType: "manga",
|
||||
resourceId: id,
|
||||
details: `Deleted comment ${commentId} from manga`,
|
||||
});
|
||||
return { success: true };
|
||||
}
|
||||
);
|
||||
|
||||
@@ -5,10 +5,12 @@
|
||||
*/
|
||||
|
||||
import { FastifyPluginAsync } from "fastify";
|
||||
import { Music, CreateMusicDto, UpdateMusicDto, Comment, CreateCommentDto } from "@library/shared-types";
|
||||
import { Music, CreateMusicDto, UpdateMusicDto, Comment, CreateCommentDto, AuditAction, AuditCategory } from "@library/shared-types";
|
||||
import { MusicService } from "../../services/music.service";
|
||||
import { CommentService } from "../../services/comment.service";
|
||||
import { AuditService } from "../../services/audit.service";
|
||||
import { adminGuard } from "../../middleware/admin-guard";
|
||||
import { bannedGuard } from "../../middleware/banned-guard";
|
||||
|
||||
const musicRoutes: FastifyPluginAsync = async (app) => {
|
||||
const musicService = new MusicService();
|
||||
@@ -39,9 +41,18 @@ const musicRoutes: FastifyPluginAsync = async (app) => {
|
||||
"/",
|
||||
{
|
||||
preValidation: [app.authenticate, adminGuard],
|
||||
preHandler: [app.csrfProtection],
|
||||
},
|
||||
async (request) => {
|
||||
return musicService.createMusic(request.body);
|
||||
const music = await musicService.createMusic(request.body);
|
||||
await AuditService.logFromRequest(request, {
|
||||
action: AuditAction.ENTRY_CREATE,
|
||||
category: AuditCategory.CONTENT,
|
||||
resourceType: "music",
|
||||
resourceId: music.id,
|
||||
details: `Created music: ${music.title}`,
|
||||
});
|
||||
return music;
|
||||
}
|
||||
);
|
||||
|
||||
@@ -56,10 +67,21 @@ const musicRoutes: FastifyPluginAsync = async (app) => {
|
||||
"/:id",
|
||||
{
|
||||
preValidation: [app.authenticate, adminGuard],
|
||||
preHandler: [app.csrfProtection],
|
||||
},
|
||||
async (request) => {
|
||||
const { id } = request.params;
|
||||
return musicService.updateMusic(id, request.body);
|
||||
const music = await musicService.updateMusic(id, request.body);
|
||||
if (music) {
|
||||
await AuditService.logFromRequest(request, {
|
||||
action: AuditAction.ENTRY_UPDATE,
|
||||
category: AuditCategory.CONTENT,
|
||||
resourceType: "music",
|
||||
resourceId: id,
|
||||
details: `Updated music: ${music.title}`,
|
||||
});
|
||||
}
|
||||
return music;
|
||||
}
|
||||
);
|
||||
|
||||
@@ -70,10 +92,18 @@ const musicRoutes: FastifyPluginAsync = async (app) => {
|
||||
"/:id",
|
||||
{
|
||||
preValidation: [app.authenticate, adminGuard],
|
||||
preHandler: [app.csrfProtection],
|
||||
},
|
||||
async (request) => {
|
||||
const { id } = request.params;
|
||||
await musicService.deleteMusic(id);
|
||||
await AuditService.logFromRequest(request, {
|
||||
action: AuditAction.ENTRY_DELETE,
|
||||
category: AuditCategory.CONTENT,
|
||||
resourceType: "music",
|
||||
resourceId: id,
|
||||
details: `Deleted music with ID: ${id}`,
|
||||
});
|
||||
return { success: true };
|
||||
}
|
||||
);
|
||||
@@ -95,12 +125,21 @@ const musicRoutes: FastifyPluginAsync = async (app) => {
|
||||
app.post<{ Params: { id: string }; Body: CreateCommentDto; Reply: Comment }>(
|
||||
"/:id/comments",
|
||||
{
|
||||
preValidation: [app.authenticate],
|
||||
preValidation: [app.authenticate, bannedGuard],
|
||||
preHandler: [app.csrfProtection],
|
||||
},
|
||||
async (request) => {
|
||||
const { id } = request.params;
|
||||
const userId = request.user.id;
|
||||
return commentService.createCommentForMusic(id, userId, request.body);
|
||||
const comment = await commentService.createCommentForMusic(id, userId, request.body);
|
||||
await AuditService.logFromRequest(request, {
|
||||
action: AuditAction.COMMENT_CREATE,
|
||||
category: AuditCategory.CONTENT,
|
||||
resourceType: "music",
|
||||
resourceId: id,
|
||||
details: `Added comment to music`,
|
||||
});
|
||||
return comment;
|
||||
}
|
||||
);
|
||||
|
||||
@@ -111,10 +150,18 @@ const musicRoutes: FastifyPluginAsync = async (app) => {
|
||||
"/:id/comments/:commentId",
|
||||
{
|
||||
preValidation: [app.authenticate, adminGuard],
|
||||
preHandler: [app.csrfProtection],
|
||||
},
|
||||
async (request) => {
|
||||
const { commentId } = request.params;
|
||||
const { id, commentId } = request.params;
|
||||
await commentService.deleteComment(commentId);
|
||||
await AuditService.logFromRequest(request, {
|
||||
action: AuditAction.COMMENT_DELETE,
|
||||
category: AuditCategory.ADMIN,
|
||||
resourceType: "music",
|
||||
resourceId: id,
|
||||
details: `Deleted comment ${commentId} from music`,
|
||||
});
|
||||
return { success: true };
|
||||
}
|
||||
);
|
||||
|
||||
@@ -5,10 +5,12 @@
|
||||
*/
|
||||
|
||||
import { FastifyPluginAsync } from "fastify";
|
||||
import { Show, CreateShowDto, UpdateShowDto, Comment, CreateCommentDto } from "@library/shared-types";
|
||||
import { Show, CreateShowDto, UpdateShowDto, Comment, CreateCommentDto, AuditAction, AuditCategory } from "@library/shared-types";
|
||||
import { ShowService } from "../../services/show.service";
|
||||
import { CommentService } from "../../services/comment.service";
|
||||
import { AuditService } from "../../services/audit.service";
|
||||
import { adminGuard } from "../../middleware/admin-guard";
|
||||
import { bannedGuard } from "../../middleware/banned-guard";
|
||||
|
||||
const showsRoutes: FastifyPluginAsync = async (app) => {
|
||||
const showService = new ShowService();
|
||||
@@ -30,9 +32,18 @@ const showsRoutes: FastifyPluginAsync = async (app) => {
|
||||
"/",
|
||||
{
|
||||
preValidation: [app.authenticate, adminGuard],
|
||||
preHandler: [app.csrfProtection],
|
||||
},
|
||||
async (request) => {
|
||||
return showService.createShow(request.body);
|
||||
const show = await showService.createShow(request.body);
|
||||
await AuditService.logFromRequest(request, {
|
||||
action: AuditAction.ENTRY_CREATE,
|
||||
category: AuditCategory.CONTENT,
|
||||
resourceType: "show",
|
||||
resourceId: show.id,
|
||||
details: `Created show: ${show.title}`,
|
||||
});
|
||||
return show;
|
||||
}
|
||||
);
|
||||
|
||||
@@ -44,10 +55,21 @@ const showsRoutes: FastifyPluginAsync = async (app) => {
|
||||
"/:id",
|
||||
{
|
||||
preValidation: [app.authenticate, adminGuard],
|
||||
preHandler: [app.csrfProtection],
|
||||
},
|
||||
async (request) => {
|
||||
const { id } = request.params;
|
||||
return showService.updateShow(id, request.body);
|
||||
const show = await showService.updateShow(id, request.body);
|
||||
if (show) {
|
||||
await AuditService.logFromRequest(request, {
|
||||
action: AuditAction.ENTRY_UPDATE,
|
||||
category: AuditCategory.CONTENT,
|
||||
resourceType: "show",
|
||||
resourceId: id,
|
||||
details: `Updated show: ${show.title}`,
|
||||
});
|
||||
}
|
||||
return show;
|
||||
}
|
||||
);
|
||||
|
||||
@@ -55,10 +77,18 @@ const showsRoutes: FastifyPluginAsync = async (app) => {
|
||||
"/:id",
|
||||
{
|
||||
preValidation: [app.authenticate, adminGuard],
|
||||
preHandler: [app.csrfProtection],
|
||||
},
|
||||
async (request) => {
|
||||
const { id } = request.params;
|
||||
await showService.deleteShow(id);
|
||||
await AuditService.logFromRequest(request, {
|
||||
action: AuditAction.ENTRY_DELETE,
|
||||
category: AuditCategory.CONTENT,
|
||||
resourceType: "show",
|
||||
resourceId: id,
|
||||
details: `Deleted show with ID: ${id}`,
|
||||
});
|
||||
return { success: true };
|
||||
}
|
||||
);
|
||||
@@ -74,12 +104,21 @@ const showsRoutes: FastifyPluginAsync = async (app) => {
|
||||
app.post<{ Params: { id: string }; Body: CreateCommentDto; Reply: Comment }>(
|
||||
"/:id/comments",
|
||||
{
|
||||
preValidation: [app.authenticate],
|
||||
preValidation: [app.authenticate, bannedGuard],
|
||||
preHandler: [app.csrfProtection],
|
||||
},
|
||||
async (request) => {
|
||||
const { id } = request.params;
|
||||
const userId = request.user.id;
|
||||
return commentService.createCommentForShow(id, userId, request.body);
|
||||
const comment = await commentService.createCommentForShow(id, userId, request.body);
|
||||
await AuditService.logFromRequest(request, {
|
||||
action: AuditAction.COMMENT_CREATE,
|
||||
category: AuditCategory.CONTENT,
|
||||
resourceType: "show",
|
||||
resourceId: id,
|
||||
details: `Added comment to show`,
|
||||
});
|
||||
return comment;
|
||||
}
|
||||
);
|
||||
|
||||
@@ -87,10 +126,18 @@ const showsRoutes: FastifyPluginAsync = async (app) => {
|
||||
"/:id/comments/:commentId",
|
||||
{
|
||||
preValidation: [app.authenticate, adminGuard],
|
||||
preHandler: [app.csrfProtection],
|
||||
},
|
||||
async (request) => {
|
||||
const { commentId } = request.params;
|
||||
const { id, commentId } = request.params;
|
||||
await commentService.deleteComment(commentId);
|
||||
await AuditService.logFromRequest(request, {
|
||||
action: AuditAction.COMMENT_DELETE,
|
||||
category: AuditCategory.ADMIN,
|
||||
resourceType: "show",
|
||||
resourceId: id,
|
||||
details: `Deleted comment ${commentId} from show`,
|
||||
});
|
||||
return { success: true };
|
||||
}
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
@@ -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 });
|
||||
},
|
||||
};
|
||||
@@ -30,6 +30,29 @@ export class AuthService {
|
||||
return this.app.jwt.verify(token) as JwtPayload;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user by ID from database.
|
||||
*/
|
||||
async getUserById(id: string): Promise<User | null> {
|
||||
const dbUser = await this.prisma.user.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
if (!dbUser) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: dbUser.id,
|
||||
discordId: dbUser.discordId,
|
||||
username: dbUser.username,
|
||||
email: dbUser.email,
|
||||
avatarUrl: dbUser.avatar || undefined,
|
||||
isAdmin: dbUser.isAdmin,
|
||||
isBanned: dbUser.isBanned,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create or update user from Discord OAuth data.
|
||||
*/
|
||||
@@ -64,6 +87,7 @@ export class AuthService {
|
||||
email: dbUser.email,
|
||||
avatarUrl: dbUser.avatar || undefined,
|
||||
isAdmin: dbUser.isAdmin,
|
||||
isBanned: dbUser.isBanned,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,4 +9,5 @@ export { GameService } from "./game.service";
|
||||
export { BookService } from "./book.service";
|
||||
export { MusicService } from "./music.service";
|
||||
export { ShowService } from "./show.service";
|
||||
export { MangaService } from "./manga.service";
|
||||
export { MangaService } from "./manga.service";
|
||||
export { AuditService } from "./audit.service";
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user