initial project setup

Fastify + Prisma backend, React + Vite frontend, Docker deployment.
Multi-board feedback platform with anonymous cookie auth, passkey
upgrade path, ALTCHA spam protection, plugin system, and full
privacy-first architecture.
This commit is contained in:
2026-03-19 18:05:16 +02:00
commit f07eddf29e
77 changed files with 7031 additions and 0 deletions

View File

@@ -0,0 +1,102 @@
import { FastifyInstance, FastifyRequest, FastifyReply } from "fastify";
import fp from "fastify-plugin";
import jwt from "jsonwebtoken";
import { PrismaClient, User } from "@prisma/client";
import { hashToken } from "../services/encryption.js";
import { config } from "../config.js";
declare module "fastify" {
interface FastifyRequest {
user?: User;
adminId?: string;
}
}
const prisma = new PrismaClient();
async function authPlugin(app: FastifyInstance) {
app.decorateRequest("user", undefined);
app.decorateRequest("adminId", undefined);
app.decorate("requireUser", async (req: FastifyRequest, reply: FastifyReply) => {
// try cookie auth first
const token = req.cookies?.echoboard_token;
if (token) {
const hash = hashToken(token);
const user = await prisma.user.findUnique({ where: { tokenHash: hash } });
if (user) {
req.user = user;
return;
}
}
// try bearer token (passkey sessions)
const authHeader = req.headers.authorization;
if (authHeader?.startsWith("Bearer ")) {
try {
const decoded = jwt.verify(authHeader.slice(7), config.JWT_SECRET) as { sub: string; type: string };
if (decoded.type === "passkey") {
const user = await prisma.user.findUnique({ where: { id: decoded.sub } });
if (user) {
req.user = user;
return;
}
}
} catch {
// invalid token
}
}
reply.status(401).send({ error: "Not authenticated" });
});
app.decorate("optionalUser", async (req: FastifyRequest) => {
const token = req.cookies?.echoboard_token;
if (token) {
const hash = hashToken(token);
const user = await prisma.user.findUnique({ where: { tokenHash: hash } });
if (user) req.user = user;
return;
}
const authHeader = req.headers.authorization;
if (authHeader?.startsWith("Bearer ")) {
try {
const decoded = jwt.verify(authHeader.slice(7), config.JWT_SECRET) as { sub: string; type: string };
if (decoded.type === "passkey") {
const user = await prisma.user.findUnique({ where: { id: decoded.sub } });
if (user) req.user = user;
}
} catch {
// invalid
}
}
});
app.decorate("requireAdmin", async (req: FastifyRequest, reply: FastifyReply) => {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith("Bearer ")) {
reply.status(401).send({ error: "Admin token required" });
return;
}
try {
const decoded = jwt.verify(authHeader.slice(7), config.JWT_SECRET) as { sub: string; type: string };
if (decoded.type !== "admin") {
reply.status(403).send({ error: "Admin access required" });
return;
}
req.adminId = decoded.sub;
} catch {
reply.status(401).send({ error: "Invalid admin token" });
}
});
}
declare module "fastify" {
interface FastifyInstance {
requireUser: (req: FastifyRequest, reply: FastifyReply) => Promise<void>;
optionalUser: (req: FastifyRequest) => Promise<void>;
requireAdmin: (req: FastifyRequest, reply: FastifyReply) => Promise<void>;
}
}
export default fp(authPlugin, { name: "auth" });

View File

@@ -0,0 +1,28 @@
import { FastifyInstance } from "fastify";
import fp from "fastify-plugin";
async function securityPlugin(app: FastifyInstance) {
app.addHook("onSend", async (_req, reply) => {
reply.header("Content-Security-Policy", [
"default-src 'self'",
"script-src 'self'",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data:",
"font-src 'self'",
"connect-src 'self'",
"frame-ancestors 'none'",
"base-uri 'self'",
"form-action 'self'",
].join("; "));
reply.header("Referrer-Policy", "no-referrer");
reply.header("X-Content-Type-Options", "nosniff");
reply.header("X-Frame-Options", "DENY");
reply.header("Strict-Transport-Security", "max-age=31536000; includeSubDomains");
reply.header("Permissions-Policy", "camera=(), microphone=(), geolocation=()");
reply.header("X-DNS-Prefetch-Control", "off");
reply.header("Cross-Origin-Opener-Policy", "same-origin");
reply.header("Cross-Origin-Resource-Policy", "same-origin");
});
}
export default fp(securityPlugin, { name: "security" });