security hardening, team invites, granular locking, view counts, board subscriptions, scheduled changelog, mentions, recovery codes, accessibility and hover states

This commit is contained in:
2026-03-21 17:37:01 +02:00
parent f07eddf29e
commit 5ba25fb956
142 changed files with 30397 additions and 2287 deletions

View File

@@ -1,17 +1,15 @@
import { FastifyInstance } from "fastify";
import { PrismaClient } from "@prisma/client";
import { z } from "zod";
const prisma = new PrismaClient();
import prisma from "../lib/prisma.js";
const reactionBody = z.object({
emoji: z.string().min(1).max(8),
emoji: z.string().min(1).max(8).regex(/^[\p{Extended_Pictographic}\u{FE0F}\u{200D}]+$/u, "Invalid emoji"),
});
export default async function reactionRoutes(app: FastifyInstance) {
app.post<{ Params: { id: string }; Body: z.infer<typeof reactionBody> }>(
"/comments/:id/reactions",
{ preHandler: [app.requireUser] },
{ preHandler: [app.requireUser], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
async (req, reply) => {
const comment = await prisma.comment.findUnique({ where: { id: req.params.id } });
if (!comment) {
@@ -19,6 +17,19 @@ export default async function reactionRoutes(app: FastifyInstance) {
return;
}
const post = await prisma.post.findUnique({
where: { id: comment.postId },
select: { isThreadLocked: true, board: { select: { isArchived: true } } },
});
if (post?.board?.isArchived) {
reply.status(403).send({ error: "Board is archived" });
return;
}
if (post?.isThreadLocked) {
reply.status(403).send({ error: "Thread is locked" });
return;
}
const { emoji } = reactionBody.parse(req.body);
const existing = await prisma.reaction.findUnique({
@@ -35,6 +46,14 @@ export default async function reactionRoutes(app: FastifyInstance) {
await prisma.reaction.delete({ where: { id: existing.id } });
reply.send({ toggled: false });
} else {
const distinctCount = await prisma.reaction.count({
where: { commentId: comment.id, userId: req.user!.id },
});
if (distinctCount >= 10) {
reply.status(400).send({ error: "Too many reactions" });
return;
}
await prisma.reaction.create({
data: {
emoji,
@@ -49,8 +68,33 @@ export default async function reactionRoutes(app: FastifyInstance) {
app.delete<{ Params: { id: string; emoji: string } }>(
"/comments/:id/reactions/:emoji",
{ preHandler: [app.requireUser] },
{ preHandler: [app.requireUser], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
async (req, reply) => {
const emoji = req.params.emoji;
if (!emoji || emoji.length > 8 || !/^[\p{Extended_Pictographic}\u{FE0F}\u{200D}]+$/u.test(emoji)) {
reply.status(400).send({ error: "Invalid emoji" });
return;
}
const comment = await prisma.comment.findUnique({ where: { id: req.params.id }, select: { id: true, postId: true } });
if (!comment) {
reply.status(404).send({ error: "Comment not found" });
return;
}
const post = await prisma.post.findUnique({
where: { id: comment.postId },
select: { isThreadLocked: true, board: { select: { isArchived: true } } },
});
if (post?.board?.isArchived) {
reply.status(403).send({ error: "Board is archived" });
return;
}
if (post?.isThreadLocked) {
reply.status(403).send({ error: "Thread is locked" });
return;
}
const deleted = await prisma.reaction.deleteMany({
where: {
commentId: req.params.id,