d418c367cb
Security fixes: - Validate theme background image URLs against whitelist pattern - Add board permission check to Socket.IO join-board handler - Disable raw HTML passthrough in markdown parser (XSS prevention) - Add UUID validation on avatar and branding route params (path traversal) - Add security headers (X-Frame-Options, X-Content-Type-Options, Referrer-Policy) - Validate webhook URLs (HTTPS only, block internal addresses) - Add import payload size limit (5MB) - Remove server uptime from health endpoint - Make seed password configurable via SEED_ADMIN_PASSWORD env var - Patch DOMPurify dependency vulnerability via npm audit fix Warning fixes: - Convert $state(prop) to $effect.pre sync pattern (state_referenced_locally) - Add for/id pairings to form labels (a11y_label_has_associated_control) - Add svelte-ignore for intentional autofocus usage (a11y_autofocus) - Add ARIA roles to interactive/overlay divs (a11y_no_static_element_interactions) - Add aria-label to icon-only buttons (a11y_consider_explicit_label) - Add keyboard handler alongside click events (a11y_click_events_have_key_events) - Fix non-reactive fileInput declaration Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
235 lines
6.8 KiB
JavaScript
235 lines
6.8 KiB
JavaScript
import express from 'express';
|
|
import { createServer } from 'http';
|
|
import { Server } from 'socket.io';
|
|
import { PrismaClient } from '@prisma/client';
|
|
import pino from 'pino';
|
|
import cron from 'node-cron';
|
|
import { handler } from './build/handler.js';
|
|
|
|
const logger = pino({ name: 'pounce' });
|
|
|
|
const app = express();
|
|
const server = createServer(app);
|
|
|
|
const io = new Server(server, {
|
|
cors: {
|
|
origin: process.env.ORIGIN || 'http://localhost:3000',
|
|
credentials: true
|
|
}
|
|
});
|
|
|
|
// ─── Prisma client for session validation ───────────────────
|
|
const prisma = new PrismaClient();
|
|
|
|
// ─── Presence tracking ──────────────────────────────────────
|
|
// Map<boardId, Map<socketId, { id, name }>>
|
|
const presence = new Map();
|
|
|
|
function getPresenceList(boardId) {
|
|
const room = presence.get(boardId);
|
|
if (!room) return [];
|
|
return Array.from(room.values());
|
|
}
|
|
|
|
// ─── Cookie parser ──────────────────────────────────────────
|
|
function parseCookies(cookieHeader) {
|
|
const cookies = {};
|
|
if (!cookieHeader) return cookies;
|
|
cookieHeader.split(';').forEach((cookie) => {
|
|
const [name, ...rest] = cookie.trim().split('=');
|
|
cookies[name] = decodeURIComponent(rest.join('='));
|
|
});
|
|
return cookies;
|
|
}
|
|
|
|
// ─── Socket.IO auth middleware ───────────────────────────────
|
|
io.use(async (socket, next) => {
|
|
try {
|
|
const cookies = parseCookies(socket.handshake.headers.cookie);
|
|
const sessionId = cookies['session'];
|
|
if (!sessionId) {
|
|
socket.data.user = null;
|
|
return next();
|
|
}
|
|
|
|
const session = await prisma.session.findUnique({
|
|
where: { id: sessionId },
|
|
include: {
|
|
user: {
|
|
select: { id: true, name: true, avatarUrl: true, tenantId: true }
|
|
}
|
|
}
|
|
});
|
|
|
|
if (session && session.expiresAt > new Date()) {
|
|
socket.data.user = session.user;
|
|
} else {
|
|
socket.data.user = null;
|
|
}
|
|
next();
|
|
} catch (err) {
|
|
logger.error({ err }, 'Socket auth error');
|
|
socket.data.user = null;
|
|
next();
|
|
}
|
|
});
|
|
|
|
// ─── Socket.IO connection handler ────────────────────────────
|
|
io.on('connection', (socket) => {
|
|
const user = socket.data.user;
|
|
logger.info({ socketId: socket.id, user: user?.name ?? 'anon' }, 'Socket connected');
|
|
|
|
// Join user-specific room for notifications
|
|
if (user) {
|
|
socket.join('user:' + user.id);
|
|
}
|
|
|
|
// Track which boards this socket has joined (for cleanup on disconnect)
|
|
const joinedBoards = new Set();
|
|
|
|
socket.on('join-board', async (boardId) => {
|
|
// Validate boardId format (UUID)
|
|
if (typeof boardId !== 'string' || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(boardId)) {
|
|
return;
|
|
}
|
|
|
|
// Check board access permission
|
|
try {
|
|
const board = await prisma.board.findUnique({
|
|
where: { id: boardId },
|
|
select: { visibility: true, members: { select: { userId: true } } }
|
|
});
|
|
if (!board) return;
|
|
if (board.visibility !== 'PUBLIC') {
|
|
if (!user) return;
|
|
const isMember = board.members.some((m) => m.userId === user.id);
|
|
if (!isMember && user.tenantRole !== 'ADMIN' && user.globalRole !== 'SITE_ADMIN') return;
|
|
}
|
|
} catch (err) {
|
|
logger.error({ err, boardId }, 'Board access check failed');
|
|
return;
|
|
}
|
|
|
|
socket.join(`board:${boardId}`);
|
|
joinedBoards.add(boardId);
|
|
|
|
if (user) {
|
|
if (!presence.has(boardId)) presence.set(boardId, new Map());
|
|
presence.get(boardId).set(socket.id, { id: user.id, name: user.name, avatarUrl: user.avatarUrl });
|
|
}
|
|
|
|
// Notify others
|
|
socket.to(`board:${boardId}`).emit('user-joined', {
|
|
user: user ? { id: user.id, name: user.name, avatarUrl: user.avatarUrl } : null,
|
|
socketId: socket.id
|
|
});
|
|
|
|
// Send full presence list to everyone in the room (including the joiner)
|
|
io.to(`board:${boardId}`).emit('presence:update', getPresenceList(boardId));
|
|
});
|
|
|
|
socket.on('leave-board', (boardId) => {
|
|
socket.leave(`board:${boardId}`);
|
|
joinedBoards.delete(boardId);
|
|
|
|
if (presence.has(boardId)) {
|
|
presence.get(boardId).delete(socket.id);
|
|
if (presence.get(boardId).size === 0) presence.delete(boardId);
|
|
}
|
|
|
|
socket.to(`board:${boardId}`).emit('user-left', {
|
|
user: user ? { id: user.id, name: user.name } : null,
|
|
socketId: socket.id
|
|
});
|
|
|
|
// Update presence for remaining
|
|
const room = presence.get(boardId);
|
|
io.to(`board:${boardId}`).emit('presence:update', room ? Array.from(room.values()) : []);
|
|
});
|
|
|
|
socket.on('disconnect', () => {
|
|
logger.info({ socketId: socket.id }, 'Socket disconnected');
|
|
for (const boardId of joinedBoards) {
|
|
if (presence.has(boardId)) {
|
|
presence.get(boardId).delete(socket.id);
|
|
if (presence.get(boardId).size === 0) {
|
|
presence.delete(boardId);
|
|
}
|
|
}
|
|
|
|
socket.to(`board:${boardId}`).emit('user-left', {
|
|
user: user ? { id: user.id, name: user.name } : null,
|
|
socketId: socket.id
|
|
});
|
|
|
|
const room = presence.get(boardId);
|
|
io.to(`board:${boardId}`).emit('presence:update', room ? Array.from(room.values()) : []);
|
|
}
|
|
});
|
|
});
|
|
|
|
// Store io globally so SvelteKit API routes can broadcast via realtime.ts
|
|
globalThis.__socketIO = io;
|
|
|
|
// ─── Due Date Reminder Cron (daily at 8:00 UTC) ────────────
|
|
cron.schedule('0 8 * * *', async () => {
|
|
try {
|
|
const now = new Date();
|
|
const in24h = new Date(now.getTime() + 24 * 60 * 60 * 1000);
|
|
|
|
const cards = await prisma.card.findMany({
|
|
where: {
|
|
archived: false,
|
|
dueReminderSent: false,
|
|
dueDate: { gte: now, lte: in24h }
|
|
},
|
|
include: {
|
|
assignees: { select: { userId: true } },
|
|
column: { select: { board: { select: { id: true, title: true } } } }
|
|
}
|
|
});
|
|
|
|
for (const card of cards) {
|
|
const boardId = card.column.board.id;
|
|
const boardTitle = card.column.board.title;
|
|
|
|
for (const assignee of card.assignees) {
|
|
await prisma.notification.create({
|
|
data: {
|
|
userId: assignee.userId,
|
|
type: 'due_soon',
|
|
title: `"${card.title}" is due soon`,
|
|
body: `Card in ${boardTitle} is due within 24 hours`,
|
|
link: `/boards/${boardId}?card=${card.id}`
|
|
}
|
|
});
|
|
io.to('user:' + assignee.userId).emit('notification:new', {
|
|
type: 'due_soon',
|
|
title: `"${card.title}" is due soon`
|
|
});
|
|
}
|
|
|
|
await prisma.card.update({
|
|
where: { id: card.id },
|
|
data: { dueReminderSent: true }
|
|
});
|
|
}
|
|
|
|
if (cards.length > 0) {
|
|
logger.info({ count: cards.length }, 'Due date reminders sent');
|
|
}
|
|
} catch (err) {
|
|
logger.error({ err }, 'Due date reminder cron failed');
|
|
}
|
|
});
|
|
|
|
// SvelteKit handler
|
|
app.use(handler);
|
|
|
|
const port = process.env.PORT || 3000;
|
|
const host = process.env.HOST || '0.0.0.0';
|
|
|
|
server.listen(port, host, () => {
|
|
logger.info({ host, port }, 'Server running');
|
|
});
|