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;
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,14 @@ export const appRoutes: Route[] = [
|
||||
path: 'manga',
|
||||
loadComponent: () => import('./components/manga/manga-list.component').then(m => m.MangaListComponent)
|
||||
},
|
||||
{
|
||||
path: 'admin/users',
|
||||
loadComponent: () => import('./components/admin/admin-users.component').then(m => m.AdminUsersComponent)
|
||||
},
|
||||
{
|
||||
path: 'admin/audit',
|
||||
loadComponent: () => import('./components/admin/admin-audit.component').then(m => m.AdminAuditComponent)
|
||||
},
|
||||
{
|
||||
path: '**',
|
||||
redirectTo: ''
|
||||
|
||||
@@ -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 { AuthService } from '../../services/auth.service';
|
||||
import { CommentsService } from '../../services/comments.service';
|
||||
import { SanitizeService } from '../../services/sanitize.service';
|
||||
import { Art, CreateArtDto, UpdateArtDto, Comment } from '@library/shared-types';
|
||||
|
||||
@Component({
|
||||
@@ -210,15 +211,21 @@ import { Art, CreateArtDto, UpdateArtDto, Comment } from '@library/shared-types'
|
||||
@if (expandedComments()[art.id]) {
|
||||
<div class="comments-container">
|
||||
@if (authService.isAuthenticated()) {
|
||||
<form (ngSubmit)="addComment(art.id)" class="comment-form">
|
||||
<textarea
|
||||
[(ngModel)]="newCommentContent[art.id]"
|
||||
name="comment"
|
||||
placeholder="Add a comment (Markdown supported)..."
|
||||
rows="2"
|
||||
></textarea>
|
||||
<button type="submit" class="btn btn-primary btn-sm">Post Comment</button>
|
||||
</form>
|
||||
@if (authService.user()?.isBanned) {
|
||||
<div class="banned-notice">
|
||||
You have been banned from commenting.
|
||||
</div>
|
||||
} @else {
|
||||
<form (ngSubmit)="addComment(art.id)" class="comment-form">
|
||||
<textarea
|
||||
[(ngModel)]="newCommentContent[art.id]"
|
||||
name="comment"
|
||||
placeholder="Add a comment (Markdown supported)..."
|
||||
rows="2"
|
||||
></textarea>
|
||||
<button type="submit" class="btn btn-primary btn-sm">Post Comment</button>
|
||||
</form>
|
||||
}
|
||||
}
|
||||
|
||||
@if (commentsLoading()[art.id]) {
|
||||
@@ -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>
|
||||
}
|
||||
</div>
|
||||
<div class="comment-content" [innerHTML]="comment.content"></div>
|
||||
<div class="comment-content" [innerHTML]="sanitizeService.sanitizeHtml(comment.content)"></div>
|
||||
</div>
|
||||
} @empty {
|
||||
<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);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.banned-notice {
|
||||
background: #fef2f2;
|
||||
border: 1px solid #fecaca;
|
||||
color: #991b1b;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 4px;
|
||||
text-align: center;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
`]
|
||||
})
|
||||
export class ArtGalleryComponent implements OnInit {
|
||||
artService = inject(ArtService);
|
||||
authService = inject(AuthService);
|
||||
commentsService = inject(CommentsService);
|
||||
sanitizeService = inject(SanitizeService);
|
||||
|
||||
artPieces = signal<Art[]>([]);
|
||||
loading = signal(true);
|
||||
|
||||
@@ -10,6 +10,7 @@ import { FormsModule } from '@angular/forms';
|
||||
import { BooksService } from '../../services/books.service';
|
||||
import { AuthService } from '../../services/auth.service';
|
||||
import { CommentsService } from '../../services/comments.service';
|
||||
import { SanitizeService } from '../../services/sanitize.service';
|
||||
import { Book, BookStatus, CreateBookDto, UpdateBookDto, Comment } from '@library/shared-types';
|
||||
|
||||
@Component({
|
||||
@@ -317,15 +318,21 @@ import { Book, BookStatus, CreateBookDto, UpdateBookDto, Comment } from '@librar
|
||||
@if (expandedComments()[book.id]) {
|
||||
<div class="comments-container">
|
||||
@if (authService.isAuthenticated()) {
|
||||
<form (ngSubmit)="addComment(book.id)" class="comment-form">
|
||||
<textarea
|
||||
[(ngModel)]="newCommentContent[book.id]"
|
||||
name="comment"
|
||||
placeholder="Add a comment (Markdown supported)..."
|
||||
rows="2"
|
||||
></textarea>
|
||||
<button type="submit" class="btn btn-primary btn-sm">Post Comment</button>
|
||||
</form>
|
||||
@if (authService.user()?.isBanned) {
|
||||
<div class="banned-notice">
|
||||
You have been banned from commenting.
|
||||
</div>
|
||||
} @else {
|
||||
<form (ngSubmit)="addComment(book.id)" class="comment-form">
|
||||
<textarea
|
||||
[(ngModel)]="newCommentContent[book.id]"
|
||||
name="comment"
|
||||
placeholder="Add a comment (Markdown supported)..."
|
||||
rows="2"
|
||||
></textarea>
|
||||
<button type="submit" class="btn btn-primary btn-sm">Post Comment</button>
|
||||
</form>
|
||||
}
|
||||
}
|
||||
|
||||
@if (commentsLoading()[book.id]) {
|
||||
@@ -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>
|
||||
}
|
||||
</div>
|
||||
<div class="comment-content" [innerHTML]="comment.content"></div>
|
||||
<div class="comment-content" [innerHTML]="sanitizeService.sanitizeHtml(comment.content)"></div>
|
||||
</div>
|
||||
} @empty {
|
||||
<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;
|
||||
}
|
||||
|
||||
.banned-notice {
|
||||
background: #fef2f2;
|
||||
border: 1px solid #fecaca;
|
||||
color: #991b1b;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 4px;
|
||||
text-align: center;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.image-preview {
|
||||
margin-top: 0.5rem;
|
||||
display: flex;
|
||||
@@ -741,6 +759,7 @@ export class BooksListComponent implements OnInit {
|
||||
booksService = inject(BooksService);
|
||||
authService = inject(AuthService);
|
||||
commentsService = inject(CommentsService);
|
||||
sanitizeService = inject(SanitizeService);
|
||||
|
||||
books = signal<Book[]>([]);
|
||||
loading = signal(true);
|
||||
|
||||
@@ -10,6 +10,7 @@ import { FormsModule } from '@angular/forms';
|
||||
import { GamesService } from '../../services/games.service';
|
||||
import { AuthService } from '../../services/auth.service';
|
||||
import { CommentsService } from '../../services/comments.service';
|
||||
import { SanitizeService } from '../../services/sanitize.service';
|
||||
import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment } from '@library/shared-types';
|
||||
|
||||
@Component({
|
||||
@@ -282,15 +283,21 @@ import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment } from '@librar
|
||||
@if (expandedComments()[game.id]) {
|
||||
<div class="comments-container">
|
||||
@if (authService.isAuthenticated()) {
|
||||
<form (ngSubmit)="addComment(game.id)" class="comment-form">
|
||||
<textarea
|
||||
[(ngModel)]="newCommentContent[game.id]"
|
||||
name="comment"
|
||||
placeholder="Add a comment (Markdown supported)..."
|
||||
rows="2"
|
||||
></textarea>
|
||||
<button type="submit" class="btn btn-primary btn-sm">Post Comment</button>
|
||||
</form>
|
||||
@if (authService.user()?.isBanned) {
|
||||
<div class="banned-notice">
|
||||
You have been banned from commenting.
|
||||
</div>
|
||||
} @else {
|
||||
<form (ngSubmit)="addComment(game.id)" class="comment-form">
|
||||
<textarea
|
||||
[(ngModel)]="newCommentContent[game.id]"
|
||||
name="comment"
|
||||
placeholder="Add a comment (Markdown supported)..."
|
||||
rows="2"
|
||||
></textarea>
|
||||
<button type="submit" class="btn btn-primary btn-sm">Post Comment</button>
|
||||
</form>
|
||||
}
|
||||
}
|
||||
|
||||
@if (commentsLoading()[game.id]) {
|
||||
@@ -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>
|
||||
}
|
||||
</div>
|
||||
<div class="comment-content" [innerHTML]="comment.content"></div>
|
||||
<div class="comment-content" [innerHTML]="sanitizeService.sanitizeHtml(comment.content)"></div>
|
||||
</div>
|
||||
} @empty {
|
||||
<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;
|
||||
}
|
||||
|
||||
.banned-notice {
|
||||
background: #fef2f2;
|
||||
border: 1px solid #fecaca;
|
||||
color: #991b1b;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 4px;
|
||||
text-align: center;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.image-preview {
|
||||
margin-top: 0.5rem;
|
||||
display: flex;
|
||||
@@ -620,6 +638,7 @@ export class GamesListComponent implements OnInit {
|
||||
gamesService = inject(GamesService);
|
||||
authService = inject(AuthService);
|
||||
commentsService = inject(CommentsService);
|
||||
sanitizeService = inject(SanitizeService);
|
||||
|
||||
games = signal<Game[]>([]);
|
||||
loading = signal(true);
|
||||
|
||||
@@ -33,7 +33,8 @@ import { AuthService } from '../../services/auth.service';
|
||||
@if (authService.user(); as user) {
|
||||
<span class="welcome">Welcome, {{ user.username }}!</span>
|
||||
@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>
|
||||
} @else {
|
||||
@@ -111,6 +112,14 @@ import { AuthService } from '../../services/auth.service';
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.8rem;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.admin-badge:hover {
|
||||
background-color: var(--witch-plum);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.btn {
|
||||
|
||||
@@ -10,6 +10,7 @@ import { FormsModule } from '@angular/forms';
|
||||
import { MangaService } from '../../services/manga.service';
|
||||
import { AuthService } from '../../services/auth.service';
|
||||
import { CommentsService } from '../../services/comments.service';
|
||||
import { SanitizeService } from '../../services/sanitize.service';
|
||||
import { Manga, MangaStatus, CreateMangaDto, UpdateMangaDto, Comment } from '@library/shared-types';
|
||||
|
||||
@Component({
|
||||
@@ -282,15 +283,21 @@ import { Manga, MangaStatus, CreateMangaDto, UpdateMangaDto, Comment } from '@li
|
||||
@if (expandedComments()[manga.id]) {
|
||||
<div class="comments-container">
|
||||
@if (authService.isAuthenticated()) {
|
||||
<form (ngSubmit)="addComment(manga.id)" class="comment-form">
|
||||
<textarea
|
||||
[(ngModel)]="newCommentContent[manga.id]"
|
||||
name="comment"
|
||||
placeholder="Add a comment (Markdown supported)..."
|
||||
rows="2"
|
||||
></textarea>
|
||||
<button type="submit" class="btn btn-primary btn-sm">Post Comment</button>
|
||||
</form>
|
||||
@if (authService.user()?.isBanned) {
|
||||
<div class="banned-notice">
|
||||
You have been banned from commenting.
|
||||
</div>
|
||||
} @else {
|
||||
<form (ngSubmit)="addComment(manga.id)" class="comment-form">
|
||||
<textarea
|
||||
[(ngModel)]="newCommentContent[manga.id]"
|
||||
name="comment"
|
||||
placeholder="Add a comment (Markdown supported)..."
|
||||
rows="2"
|
||||
></textarea>
|
||||
<button type="submit" class="btn btn-primary btn-sm">Post Comment</button>
|
||||
</form>
|
||||
}
|
||||
}
|
||||
|
||||
@if (commentsLoading()[manga.id]) {
|
||||
@@ -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>
|
||||
}
|
||||
</div>
|
||||
<div class="comment-content" [innerHTML]="comment.content"></div>
|
||||
<div class="comment-content" [innerHTML]="sanitizeService.sanitizeHtml(comment.content)"></div>
|
||||
</div>
|
||||
} @empty {
|
||||
<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;
|
||||
}
|
||||
|
||||
.banned-notice {
|
||||
background: #fef2f2;
|
||||
border: 1px solid #fecaca;
|
||||
color: #991b1b;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 4px;
|
||||
text-align: center;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.image-preview {
|
||||
margin-top: 0.5rem;
|
||||
display: flex;
|
||||
@@ -622,6 +640,7 @@ export class MangaListComponent implements OnInit {
|
||||
mangaService = inject(MangaService);
|
||||
authService = inject(AuthService);
|
||||
commentsService = inject(CommentsService);
|
||||
sanitizeService = inject(SanitizeService);
|
||||
|
||||
mangaList = signal<Manga[]>([]);
|
||||
loading = signal(true);
|
||||
|
||||
@@ -10,6 +10,7 @@ import { FormsModule } from '@angular/forms';
|
||||
import { MusicService } from '../../services/music.service';
|
||||
import { AuthService } from '../../services/auth.service';
|
||||
import { CommentsService } from '../../services/comments.service';
|
||||
import { SanitizeService } from '../../services/sanitize.service';
|
||||
import { Music, MusicStatus, MusicType, CreateMusicDto, UpdateMusicDto, Comment } from '@library/shared-types';
|
||||
|
||||
@Component({
|
||||
@@ -355,15 +356,21 @@ import { Music, MusicStatus, MusicType, CreateMusicDto, UpdateMusicDto, Comment
|
||||
@if (expandedComments()[music.id]) {
|
||||
<div class="comments-container">
|
||||
@if (authService.isAuthenticated()) {
|
||||
<form (ngSubmit)="addComment(music.id)" class="comment-form">
|
||||
<textarea
|
||||
[(ngModel)]="newCommentContent[music.id]"
|
||||
name="comment"
|
||||
placeholder="Add a comment (Markdown supported)..."
|
||||
rows="2"
|
||||
></textarea>
|
||||
<button type="submit" class="btn btn-primary btn-sm">Post Comment</button>
|
||||
</form>
|
||||
@if (authService.user()?.isBanned) {
|
||||
<div class="banned-notice">
|
||||
You have been banned from commenting.
|
||||
</div>
|
||||
} @else {
|
||||
<form (ngSubmit)="addComment(music.id)" class="comment-form">
|
||||
<textarea
|
||||
[(ngModel)]="newCommentContent[music.id]"
|
||||
name="comment"
|
||||
placeholder="Add a comment (Markdown supported)..."
|
||||
rows="2"
|
||||
></textarea>
|
||||
<button type="submit" class="btn btn-primary btn-sm">Post Comment</button>
|
||||
</form>
|
||||
}
|
||||
}
|
||||
|
||||
@if (commentsLoading()[music.id]) {
|
||||
@@ -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>
|
||||
}
|
||||
</div>
|
||||
<div class="comment-content" [innerHTML]="comment.content"></div>
|
||||
<div class="comment-content" [innerHTML]="sanitizeService.sanitizeHtml(comment.content)"></div>
|
||||
</div>
|
||||
} @empty {
|
||||
<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;
|
||||
}
|
||||
|
||||
.banned-notice {
|
||||
background: #fef2f2;
|
||||
border: 1px solid #fecaca;
|
||||
color: #991b1b;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 4px;
|
||||
text-align: center;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.image-preview {
|
||||
margin-top: 0.5rem;
|
||||
display: flex;
|
||||
@@ -813,6 +831,7 @@ export class MusicListComponent implements OnInit {
|
||||
musicService = inject(MusicService);
|
||||
authService = inject(AuthService);
|
||||
commentsService = inject(CommentsService);
|
||||
sanitizeService = inject(SanitizeService);
|
||||
|
||||
music = signal<Music[]>([]);
|
||||
loading = signal(true);
|
||||
|
||||
@@ -10,6 +10,7 @@ import { FormsModule } from '@angular/forms';
|
||||
import { ShowsService } from '../../services/shows.service';
|
||||
import { AuthService } from '../../services/auth.service';
|
||||
import { CommentsService } from '../../services/comments.service';
|
||||
import { SanitizeService } from '../../services/sanitize.service';
|
||||
import { Show, ShowStatus, ShowType, CreateShowDto, UpdateShowDto, Comment } from '@library/shared-types';
|
||||
|
||||
@Component({
|
||||
@@ -278,15 +279,21 @@ import { Show, ShowStatus, ShowType, CreateShowDto, UpdateShowDto, Comment } fro
|
||||
@if (expandedComments()[show.id]) {
|
||||
<div class="comments-container">
|
||||
@if (authService.isAuthenticated()) {
|
||||
<form (ngSubmit)="addComment(show.id)" class="comment-form">
|
||||
<textarea
|
||||
[(ngModel)]="newCommentContent[show.id]"
|
||||
name="comment"
|
||||
placeholder="Add a comment (Markdown supported)..."
|
||||
rows="2"
|
||||
></textarea>
|
||||
<button type="submit" class="btn btn-primary btn-sm">Post Comment</button>
|
||||
</form>
|
||||
@if (authService.user()?.isBanned) {
|
||||
<div class="banned-notice">
|
||||
You have been banned from commenting.
|
||||
</div>
|
||||
} @else {
|
||||
<form (ngSubmit)="addComment(show.id)" class="comment-form">
|
||||
<textarea
|
||||
[(ngModel)]="newCommentContent[show.id]"
|
||||
name="comment"
|
||||
placeholder="Add a comment (Markdown supported)..."
|
||||
rows="2"
|
||||
></textarea>
|
||||
<button type="submit" class="btn btn-primary btn-sm">Post Comment</button>
|
||||
</form>
|
||||
}
|
||||
}
|
||||
|
||||
@if (commentsLoading()[show.id]) {
|
||||
@@ -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>
|
||||
}
|
||||
</div>
|
||||
<div class="comment-content" [innerHTML]="comment.content"></div>
|
||||
<div class="comment-content" [innerHTML]="sanitizeService.sanitizeHtml(comment.content)"></div>
|
||||
</div>
|
||||
} @empty {
|
||||
<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;
|
||||
}
|
||||
|
||||
.banned-notice {
|
||||
background: #fef2f2;
|
||||
border: 1px solid #fecaca;
|
||||
color: #991b1b;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 4px;
|
||||
text-align: center;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.image-preview {
|
||||
margin-top: 0.5rem;
|
||||
display: flex;
|
||||
@@ -617,6 +635,7 @@ export class ShowsListComponent implements OnInit {
|
||||
showsService = inject(ShowsService);
|
||||
authService = inject(AuthService);
|
||||
commentsService = inject(CommentsService);
|
||||
sanitizeService = inject(SanitizeService);
|
||||
|
||||
shows = signal<Show[]>([]);
|
||||
loading = signal(true);
|
||||
|
||||
@@ -4,50 +4,85 @@
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Injectable, signal, inject } from '@angular/core';
|
||||
import { HttpClient, HttpHeaders } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
import { Observable, switchMap, tap, of } from 'rxjs';
|
||||
import { environment } from '../../environments/environment';
|
||||
|
||||
interface CsrfTokenResponse {
|
||||
csrfToken: string;
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class ApiService {
|
||||
private http = inject(HttpClient);
|
||||
private readonly apiUrl = environment.apiUrl;
|
||||
|
||||
constructor(private http: HttpClient) {}
|
||||
private csrfToken = signal<string | null>(null);
|
||||
|
||||
private getHeaders(): HttpHeaders {
|
||||
// Auth token is sent as httpOnly cookie, no need to add manually
|
||||
return new HttpHeaders({
|
||||
const headers: Record<string, string> = {
|
||||
'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> {
|
||||
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(),
|
||||
withCredentials: true
|
||||
});
|
||||
}
|
||||
|
||||
put<T>(endpoint: string, body: any): Observable<T> {
|
||||
return this.http.put<T>(`${this.apiUrl}${endpoint}`, body, {
|
||||
headers: this.getHeaders(),
|
||||
withCredentials: true
|
||||
});
|
||||
post<T>(endpoint: string, body: unknown): Observable<T> {
|
||||
return this.ensureCsrfToken().pipe(
|
||||
switchMap(() => this.http.post<T>(`${this.apiUrl}${endpoint}`, body, {
|
||||
headers: this.getHeaders(),
|
||||
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> {
|
||||
return this.http.delete<T>(`${this.apiUrl}${endpoint}`, {
|
||||
withCredentials: true
|
||||
});
|
||||
return this.ensureCsrfToken().pipe(
|
||||
switchMap(() => this.http.delete<T>(`${this.apiUrl}${endpoint}`, {
|
||||
headers: this.getHeaders(),
|
||||
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(
|
||||
tap(() => {
|
||||
this.currentUser.set(null);
|
||||
this.api.clearCsrfToken();
|
||||
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
@@ -24,13 +24,17 @@
|
||||
"@angular/router": "21.1.2",
|
||||
"@fastify/autoload": "6.0.3",
|
||||
"@fastify/cookie": "^11.0.2",
|
||||
"@fastify/cors": "^11.0.0",
|
||||
"@fastify/csrf-protection": "^7.1.0",
|
||||
"@fastify/helmet": "^13.0.2",
|
||||
"@fastify/jwt": "^10.0.0",
|
||||
"@fastify/oauth2": "^8.1.2",
|
||||
"@fastify/rate-limit": "^10.3.0",
|
||||
"@fastify/sensible": "6.0.4",
|
||||
"@fastify/static": "^9.0.0",
|
||||
"@prisma/client": "6.19.2",
|
||||
"dompurify": "^3.3.1",
|
||||
"fastify": "5.2.2",
|
||||
"fastify": "5.7.3",
|
||||
"fastify-plugin": "5.0.1",
|
||||
"jsdom": "^28.0.0",
|
||||
"marked": "^17.0.1",
|
||||
|
||||
Generated
+80
-22
@@ -32,12 +32,24 @@ importers:
|
||||
'@fastify/cookie':
|
||||
specifier: ^11.0.2
|
||||
version: 11.0.2
|
||||
'@fastify/cors':
|
||||
specifier: ^11.0.0
|
||||
version: 11.2.0
|
||||
'@fastify/csrf-protection':
|
||||
specifier: ^7.1.0
|
||||
version: 7.1.0
|
||||
'@fastify/helmet':
|
||||
specifier: ^13.0.2
|
||||
version: 13.0.2
|
||||
'@fastify/jwt':
|
||||
specifier: ^10.0.0
|
||||
version: 10.0.0
|
||||
'@fastify/oauth2':
|
||||
specifier: ^8.1.2
|
||||
version: 8.1.2
|
||||
'@fastify/rate-limit':
|
||||
specifier: ^10.3.0
|
||||
version: 10.3.0
|
||||
'@fastify/sensible':
|
||||
specifier: 6.0.4
|
||||
version: 6.0.4
|
||||
@@ -51,8 +63,8 @@ importers:
|
||||
specifier: ^3.3.1
|
||||
version: 3.3.1
|
||||
fastify:
|
||||
specifier: 5.2.2
|
||||
version: 5.2.2
|
||||
specifier: 5.7.3
|
||||
version: 5.7.3
|
||||
fastify-plugin:
|
||||
specifier: 5.0.1
|
||||
version: 5.0.1
|
||||
@@ -1599,6 +1611,15 @@ packages:
|
||||
'@fastify/cookie@11.0.2':
|
||||
resolution: {integrity: sha512-GWdwdGlgJxyvNv+QcKiGNevSspMQXncjMZ1J8IvuDQk0jvkzgWWZFNC2En3s+nHndZBGV8IbLwOI/sxCZw/mzA==}
|
||||
|
||||
'@fastify/cors@11.2.0':
|
||||
resolution: {integrity: sha512-LbLHBuSAdGdSFZYTLVA3+Ch2t+sA6nq3Ejc6XLAKiQ6ViS2qFnvicpj0htsx03FyYeLs04HfRNBsz/a8SvbcUw==}
|
||||
|
||||
'@fastify/csrf-protection@7.1.0':
|
||||
resolution: {integrity: sha512-I2TDd4SRRYQivKCMHdB/8py+CPO9DT0e63lh4DO8MDCJh8NROq8HD/iO0IjYtwhsD3bZhr0cBXsFdfPvyTmzNw==}
|
||||
|
||||
'@fastify/csrf@8.0.1':
|
||||
resolution: {integrity: sha512-dAmCrdfJ3CV/A/hHHK/rRBjjLRRSIltgJB0BxiVfbhr/31G6fgF8l2I8evtH8mjS5kTIvd0JOh7MOA3HA6eYDw==}
|
||||
|
||||
'@fastify/error@4.2.0':
|
||||
resolution: {integrity: sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==}
|
||||
|
||||
@@ -1608,6 +1629,9 @@ packages:
|
||||
'@fastify/forwarded@3.0.1':
|
||||
resolution: {integrity: sha512-JqDochHFqXs3C3Ml3gOY58zM7OqO9ENqPo0UqAjAjH8L01fRZqwX9iLeX34//kiJubF7r2ZQHtBRU36vONbLlw==}
|
||||
|
||||
'@fastify/helmet@13.0.2':
|
||||
resolution: {integrity: sha512-tO1QMkOfNeCt9l4sG/FiWErH4QMm+RjHzbMTrgew1DYOQ2vb/6M1G2iNABBrD7Xq6dUk+HLzWW8u+rmmhQHifA==}
|
||||
|
||||
'@fastify/jwt@10.0.0':
|
||||
resolution: {integrity: sha512-2Qka3NiyNNcsfejMUvyzot1T4UYIzzcbkFGDdVyrl344fRZ/WkD6VFXOoXhxe2Pzf3LpJNkoSxUM4Ru4DVgkYA==}
|
||||
|
||||
@@ -1620,6 +1644,9 @@ packages:
|
||||
'@fastify/proxy-addr@5.1.0':
|
||||
resolution: {integrity: sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==}
|
||||
|
||||
'@fastify/rate-limit@10.3.0':
|
||||
resolution: {integrity: sha512-eIGkG9XKQs0nyynatApA3EVrojHOuq4l6fhB4eeCk4PIOeadvOJz9/4w3vGI44Go17uaXOWEcPkaD8kuKm7g6Q==}
|
||||
|
||||
'@fastify/send@4.1.0':
|
||||
resolution: {integrity: sha512-TMYeQLCBSy2TOFmV95hQWkiTYgC/SEx7vMdV+wnZVX4tt8VBLKzmH8vV9OzJehV0+XBfg+WxPMt5wp+JBUKsVw==}
|
||||
|
||||
@@ -5378,8 +5405,8 @@ packages:
|
||||
fastify-plugin@5.0.1:
|
||||
resolution: {integrity: sha512-HCxs+YnRaWzCl+cWRYFnHmeRFyR5GVnJTAaCJQiYzQSDwK9MgJdyAsuL3nh0EWRCYMgQ5MeziymvmAhUHYHDUQ==}
|
||||
|
||||
fastify@5.2.2:
|
||||
resolution: {integrity: sha512-22T/PnhquWozuFXg3Ish4md5ipsF1Nx1mJ9ulLdZPXSk14WFj/wMlyNB/yll9sQOojKRgOIxT2inK3Xpjg5hyw==}
|
||||
fastify@5.7.3:
|
||||
resolution: {integrity: sha512-QHzWSmTNUg9Ba8tNXzb92FTH77K+c8yeQPH80EeSIc9wyZj85jbPisMP0rwmyKv8oJwUFPe1UpN8HkNIXwCnUQ==}
|
||||
|
||||
fastparallel@2.4.1:
|
||||
resolution: {integrity: sha512-qUmhxPgNHmvRjZKBFUNI0oZuuH9OlSIOXmJ98lhKPxMZZ7zS/Fi0wRHOihDSz0R1YiIOjxzOY4bq65YTcdBi2Q==}
|
||||
@@ -5745,6 +5772,10 @@ packages:
|
||||
resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==}
|
||||
hasBin: true
|
||||
|
||||
helmet@8.1.0:
|
||||
resolution: {integrity: sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
homedir-polyfill@1.0.3:
|
||||
resolution: {integrity: sha512-eSmmWE5bZTK2Nou4g0AI3zZ9rswp7GRKoKXS1BLUkvPviOqs4YTN1djQIqrXy9k5gEtdLPy86JjRwsNM9tnDcA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -7298,14 +7329,14 @@ packages:
|
||||
resolution: {integrity: sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
pino-abstract-transport@2.0.0:
|
||||
resolution: {integrity: sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==}
|
||||
pino-abstract-transport@3.0.0:
|
||||
resolution: {integrity: sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==}
|
||||
|
||||
pino-std-serializers@7.1.0:
|
||||
resolution: {integrity: sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==}
|
||||
|
||||
pino@9.14.0:
|
||||
resolution: {integrity: sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w==}
|
||||
pino@10.3.0:
|
||||
resolution: {integrity: sha512-0GNPNzHXBKw6U/InGe79A3Crzyk9bcSyObF9/Gfo9DLEf5qj5RF50RSjsu0W1rZ6ZqRGdzDFCRBQvi9/rSGPtA==}
|
||||
hasBin: true
|
||||
|
||||
pirates@4.0.7:
|
||||
@@ -8090,8 +8121,8 @@ packages:
|
||||
secure-compare@3.0.1:
|
||||
resolution: {integrity: sha512-AckIIV90rPDcBcglUwXPF3kg0P0qmPsPXAj6BBEENQE1p5yA1xfmDJzfi1Tappj37Pv2mVbKpL3Z1T+Nn7k1Qw==}
|
||||
|
||||
secure-json-parse@3.0.2:
|
||||
resolution: {integrity: sha512-H6nS2o8bWfpFEV6U38sOSjS7bTbdgbCGU9wEM6W14P5H0QOsz94KCusifV44GpHDTu2nqZbuDNhTzu+mjDSw1w==}
|
||||
secure-json-parse@4.1.0:
|
||||
resolution: {integrity: sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==}
|
||||
|
||||
select-hose@2.0.0:
|
||||
resolution: {integrity: sha512-mEugaLK+YfkijB4fx0e6kImuJdCIt2LxCRcbEYPqRGCs4F2ogyfZU5IAZRdjCP8JPq2AtdNoC/Dux63d9Kiryg==}
|
||||
@@ -8538,8 +8569,9 @@ packages:
|
||||
peerDependencies:
|
||||
tslib: ^2
|
||||
|
||||
thread-stream@3.1.0:
|
||||
resolution: {integrity: sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==}
|
||||
thread-stream@4.0.0:
|
||||
resolution: {integrity: sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
throttleit@1.0.1:
|
||||
resolution: {integrity: sha512-vDZpf9Chs9mAdfY046mcPt8fg5QSZr37hEH4TXYBnDF+izxgrbRGUAAaBvIk/fJm9aOFCGFd1EsNg5AZCbnQCQ==}
|
||||
@@ -11526,6 +11558,19 @@ snapshots:
|
||||
cookie: 1.1.1
|
||||
fastify-plugin: 5.0.1
|
||||
|
||||
'@fastify/cors@11.2.0':
|
||||
dependencies:
|
||||
fastify-plugin: 5.0.1
|
||||
toad-cache: 3.7.0
|
||||
|
||||
'@fastify/csrf-protection@7.1.0':
|
||||
dependencies:
|
||||
'@fastify/csrf': 8.0.1
|
||||
'@fastify/error': 4.2.0
|
||||
fastify-plugin: 5.0.1
|
||||
|
||||
'@fastify/csrf@8.0.1': {}
|
||||
|
||||
'@fastify/error@4.2.0': {}
|
||||
|
||||
'@fastify/fast-json-stringify-compiler@5.0.3':
|
||||
@@ -11534,6 +11579,11 @@ snapshots:
|
||||
|
||||
'@fastify/forwarded@3.0.1': {}
|
||||
|
||||
'@fastify/helmet@13.0.2':
|
||||
dependencies:
|
||||
fastify-plugin: 5.0.1
|
||||
helmet: 8.1.0
|
||||
|
||||
'@fastify/jwt@10.0.0':
|
||||
dependencies:
|
||||
'@fastify/error': 4.2.0
|
||||
@@ -11559,6 +11609,12 @@ snapshots:
|
||||
'@fastify/forwarded': 3.0.1
|
||||
ipaddr.js: 2.3.0
|
||||
|
||||
'@fastify/rate-limit@10.3.0':
|
||||
dependencies:
|
||||
'@lukeed/ms': 2.0.2
|
||||
fastify-plugin: 5.0.1
|
||||
toad-cache: 3.7.0
|
||||
|
||||
'@fastify/send@4.1.0':
|
||||
dependencies:
|
||||
'@lukeed/ms': 2.0.2
|
||||
@@ -16392,7 +16448,7 @@ snapshots:
|
||||
|
||||
fastify-plugin@5.0.1: {}
|
||||
|
||||
fastify@5.2.2:
|
||||
fastify@5.7.3:
|
||||
dependencies:
|
||||
'@fastify/ajv-compiler': 4.0.5
|
||||
'@fastify/error': 4.2.0
|
||||
@@ -16403,10 +16459,10 @@ snapshots:
|
||||
fast-json-stringify: 6.2.0
|
||||
find-my-way: 9.4.0
|
||||
light-my-request: 6.6.0
|
||||
pino: 9.14.0
|
||||
process-warning: 4.0.1
|
||||
pino: 10.3.0
|
||||
process-warning: 5.0.0
|
||||
rfdc: 1.4.1
|
||||
secure-json-parse: 3.0.2
|
||||
secure-json-parse: 4.1.0
|
||||
semver: 7.7.3
|
||||
toad-cache: 3.7.0
|
||||
|
||||
@@ -16814,6 +16870,8 @@ snapshots:
|
||||
|
||||
he@1.2.0: {}
|
||||
|
||||
helmet@8.1.0: {}
|
||||
|
||||
homedir-polyfill@1.0.3:
|
||||
dependencies:
|
||||
parse-passwd: 1.0.0
|
||||
@@ -18687,25 +18745,25 @@ snapshots:
|
||||
pify@4.0.1:
|
||||
optional: true
|
||||
|
||||
pino-abstract-transport@2.0.0:
|
||||
pino-abstract-transport@3.0.0:
|
||||
dependencies:
|
||||
split2: 4.2.0
|
||||
|
||||
pino-std-serializers@7.1.0: {}
|
||||
|
||||
pino@9.14.0:
|
||||
pino@10.3.0:
|
||||
dependencies:
|
||||
'@pinojs/redact': 0.4.0
|
||||
atomic-sleep: 1.0.0
|
||||
on-exit-leak-free: 2.1.2
|
||||
pino-abstract-transport: 2.0.0
|
||||
pino-abstract-transport: 3.0.0
|
||||
pino-std-serializers: 7.1.0
|
||||
process-warning: 5.0.0
|
||||
quick-format-unescaped: 4.0.4
|
||||
real-require: 0.2.0
|
||||
safe-stable-stringify: 2.5.0
|
||||
sonic-boom: 4.2.0
|
||||
thread-stream: 3.1.0
|
||||
thread-stream: 4.0.0
|
||||
|
||||
pirates@4.0.7: {}
|
||||
|
||||
@@ -19516,7 +19574,7 @@ snapshots:
|
||||
|
||||
secure-compare@3.0.1: {}
|
||||
|
||||
secure-json-parse@3.0.2: {}
|
||||
secure-json-parse@4.1.0: {}
|
||||
|
||||
select-hose@2.0.0: {}
|
||||
|
||||
@@ -20102,7 +20160,7 @@ snapshots:
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
thread-stream@3.1.0:
|
||||
thread-stream@4.0.0:
|
||||
dependencies:
|
||||
real-require: 0.2.0
|
||||
|
||||
|
||||
@@ -10,4 +10,5 @@ export * from "./lib/art.types";
|
||||
export * from "./lib/show.types";
|
||||
export * from "./lib/manga.types";
|
||||
export type * from "./lib/auth.types";
|
||||
export * from "./lib/comment.types";
|
||||
export * from "./lib/comment.types";
|
||||
export * from "./lib/audit.types";
|
||||
@@ -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;
|
||||
}
|
||||
@@ -11,6 +11,7 @@ export interface User {
|
||||
avatarUrl?: string;
|
||||
discordId: string;
|
||||
isAdmin: boolean;
|
||||
isBanned: boolean;
|
||||
}
|
||||
|
||||
export interface JwtPayload {
|
||||
|
||||
Reference in New Issue
Block a user