/** * @copyright 2026 NHCarrigan * @license Naomi's Public License * @author Naomi Carrigan */ import { Comment, CreateCommentDto } from "@library/shared-types"; import { prisma } from "../lib/prisma"; import createDOMPurify from "dompurify"; import { JSDOM } from "jsdom"; import { marked } from "marked"; const window = new JSDOM("").window; const DOMPurify = createDOMPurify(window); // Add hook to sanitise links - prevent javascript: URLs and add security attributes DOMPurify.addHook("afterSanitizeAttributes", (node) => { if (node.tagName === "A") { const href = node.getAttribute("href") || ""; // Block javascript:, data:, and vbscript: URLs if (/^(javascript|data|vbscript):/i.test(href)) { node.removeAttribute("href"); } else { // Add security attributes to external links node.setAttribute("target", "_blank"); node.setAttribute("rel", "noopener noreferrer nofollow"); } } }); export class CommentService { private prisma = prisma; constructor() {} private sanitizeMarkdown(content: string): string { const html = marked.parse(content, { async: false }) as string; return DOMPurify.sanitize(html, { ALLOWED_TAGS: [ "p", "br", "strong", "em", "b", "i", "u", "s", "strike", "h1", "h2", "h3", "h4", "h5", "h6", "ul", "ol", "li", "blockquote", "code", "pre", "a", "hr", ], ALLOWED_ATTR: ["href", "target", "rel"], ALLOW_DATA_ATTR: false, ADD_ATTR: ["target", "rel"], FORCE_BODY: true, }); } private mapComment(comment: any): Comment { return { id: comment.id, content: comment.content, rawContent: comment.rawContent || undefined, userId: comment.userId, user: { id: comment.user.id, username: comment.user.username, avatar: comment.user.avatar || undefined, inDiscord: comment.user.inDiscord, isVip: comment.user.isVip, isMod: comment.user.isMod, isStaff: comment.user.isStaff, }, gameId: comment.gameId || undefined, bookId: comment.bookId || undefined, musicId: comment.musicId || undefined, artId: comment.artId || undefined, showId: comment.showId || undefined, mangaId: comment.mangaId || undefined, createdAt: comment.createdAt, updatedAt: comment.updatedAt, }; } async getCommentsForGame(gameId: string): Promise { const comments = await this.prisma.comment.findMany({ where: { gameId }, include: { user: true }, orderBy: { createdAt: "desc" }, }); return comments.map((c) => this.mapComment(c)); } async getCommentsForBook(bookId: string): Promise { const comments = await this.prisma.comment.findMany({ where: { bookId }, include: { user: true }, orderBy: { createdAt: "desc" }, }); return comments.map((c) => this.mapComment(c)); } async getCommentsForMusic(musicId: string): Promise { const comments = await this.prisma.comment.findMany({ where: { musicId }, include: { user: true }, orderBy: { createdAt: "desc" }, }); return comments.map((c) => this.mapComment(c)); } async createCommentForGame( gameId: string, userId: string, data: CreateCommentDto ): Promise { const sanitizedContent = this.sanitizeMarkdown(data.content); const comment = await this.prisma.comment.create({ data: { content: sanitizedContent, rawContent: data.content, userId, gameId, }, include: { user: true }, }); return this.mapComment(comment); } async createCommentForBook( bookId: string, userId: string, data: CreateCommentDto ): Promise { const sanitizedContent = this.sanitizeMarkdown(data.content); const comment = await this.prisma.comment.create({ data: { content: sanitizedContent, rawContent: data.content, userId, bookId, }, include: { user: true }, }); return this.mapComment(comment); } async createCommentForMusic( musicId: string, userId: string, data: CreateCommentDto ): Promise { const sanitizedContent = this.sanitizeMarkdown(data.content); const comment = await this.prisma.comment.create({ data: { content: sanitizedContent, rawContent: data.content, userId, musicId, }, include: { user: true }, }); return this.mapComment(comment); } async getCommentsForArt(artId: string): Promise { const comments = await this.prisma.comment.findMany({ where: { artId }, include: { user: true }, orderBy: { createdAt: "desc" }, }); return comments.map((c) => this.mapComment(c)); } async createCommentForArt( artId: string, userId: string, data: CreateCommentDto ): Promise { const sanitizedContent = this.sanitizeMarkdown(data.content); const comment = await this.prisma.comment.create({ data: { content: sanitizedContent, rawContent: data.content, userId, artId, }, include: { user: true }, }); return this.mapComment(comment); } async getCommentsForShow(showId: string): Promise { const comments = await this.prisma.comment.findMany({ where: { showId }, include: { user: true }, orderBy: { createdAt: "desc" }, }); return comments.map((c) => this.mapComment(c)); } async createCommentForShow( showId: string, userId: string, data: CreateCommentDto ): Promise { const sanitizedContent = this.sanitizeMarkdown(data.content); const comment = await this.prisma.comment.create({ data: { content: sanitizedContent, rawContent: data.content, userId, showId, }, include: { user: true }, }); return this.mapComment(comment); } async getCommentsForManga(mangaId: string): Promise { const comments = await this.prisma.comment.findMany({ where: { mangaId }, include: { user: true }, orderBy: { createdAt: "desc" }, }); return comments.map((c) => this.mapComment(c)); } async createCommentForManga( mangaId: string, userId: string, data: CreateCommentDto ): Promise { const sanitizedContent = this.sanitizeMarkdown(data.content); const comment = await this.prisma.comment.create({ data: { content: sanitizedContent, rawContent: data.content, userId, mangaId, }, include: { user: true }, }); return this.mapComment(comment); } async getCommentById(commentId: string) { return this.prisma.comment.findUnique({ where: { id: commentId }, include: { user: true }, }); } async updateComment( commentId: string, content: string ): Promise { const sanitizedContent = this.sanitizeMarkdown(content); const comment = await this.prisma.comment.update({ where: { id: commentId }, data: { content: sanitizedContent, rawContent: content, }, include: { user: true }, }); return this.mapComment(comment); } async deleteComment(commentId: string): Promise { await this.prisma.comment.delete({ where: { id: commentId }, }); } async verifyCommentOwnership( commentId: string, resourceType: "game" | "book" | "music" | "art" | "show" | "manga", resourceId: string ): Promise<{ exists: boolean; comment?: { userId: string } }> { const fieldMap = { game: "gameId", book: "bookId", music: "musicId", art: "artId", show: "showId", manga: "mangaId", }; const comment = await this.prisma.comment.findFirst({ where: { id: commentId, [fieldMap[resourceType]]: resourceId, }, select: { userId: true }, }); return comment ? { exists: true, comment } : { exists: false }; } }