## Security (red-team audit 2026-04-12) Public data exposure (P0): - Public map converted to server-side heatmap, 2-decimal (~1.1km) bucketing, no addresses/support-levels/sign-info returned - Petition signers endpoint strips displayName/signerComment/geoCity/geoCountry - Petition public-stats drops recentSigners entirely - Response wall strips userComment + submittedByName - Campaign createdByUserEmail + moderation fields gated to SUPER_ADMIN Access control (P1): - Campaign findById/update/delete/email-stats enforce owner === req.user.id (SUPER_ADMIN bypasses), return 404 to avoid enumeration - GPS tracking session route restricted to session owner or SUPER_ADMIN - Canvass volunteer stats restricted to self or SUPER_ADMIN - People household endpoints restricted to INFLUENCE + MAP roles (was ADMIN*) - CCP upgrade.service.ts + certificate.service.ts gate user-controlled shell inputs (branch, path, slug, SAN hostname) behind regex validators Token security (P2): - Query-param JWT auth replaced with HMAC-signed short-lived URLs (utils/signed-url.ts + /api/media/sign endpoint); legacy ?token= removed from media streaming, photos, chat-notifications, and social SSE - GITEA_SSO_SECRET + SERVICE_PASSWORD_SALT now REQUIRED (min 32 chars); JWT_ACCESS_SECRET fallback removed — BREAKING for existing deployments - Refresh tokens bound to device fingerprint (UA + /24 IP) via `df` JWT claim; mismatch revokes all user sessions - Refresh expiry reduced 7d → 24h - Refresh/logout via request body removed — httpOnly cookie only - Password-reset + verification-resend rate limits now keyed on (IP, email) composite to prevent both IP rotation and email enumeration Defense-in-depth (P3): - DOMPurify sanitization applied to GrapesJS landing page HTML/CSS - /api/health?detailed=true disk-space leak removed - Password-reset/verification token log lines no longer include userId ## Deployment - docker-compose.yml + docker-compose.prod.yml: media-api now receives GITEA_SSO_SECRET + SERVICE_PASSWORD_SALT; empty fallbacks removed - CCP templates/env.hbs adds both new secrets; refresh expiry → 24h - CCP secret-generator.ts generates giteaSsoSecret + servicePasswordSalt - leaflet.heat added to admin/package.json for heatmap rendering ## Operator action required on existing installs Run `./config.sh` once (idempotent — only fills empty values) or manually add GITEA_SSO_SECRET + SERVICE_PASSWORD_SALT to .env via `openssl rand -hex 32`. Startup fails with a clear Zod error otherwise. See SECURITY_REDTEAM_2026-04-12.md for full audit and verification matrix. ## Other Includes in-flight CCP work: instance schema tweaks, agent server updates, health service, tunnel service, DEV_WORKFLOW doc updates, and new migration dropping composeProject uniqueness. Bunker Admin
197 lines
8.4 KiB
TypeScript
197 lines
8.4 KiB
TypeScript
import Fastify from 'fastify';
|
|
import cors from '@fastify/cors';
|
|
import multipart from '@fastify/multipart';
|
|
import { env } from './config/env';
|
|
import { logger } from './utils/logger';
|
|
import { videosRoutes } from './modules/media/routes/videos.routes';
|
|
import { videoStreamingRoutes } from './modules/media/routes/video-streaming.routes';
|
|
import { reactionsRoutes } from './modules/media/routes/reactions.routes';
|
|
import { publicRoutes } from './modules/media/routes/public.routes';
|
|
import { chatStreamRoutes } from './modules/media/routes/chat-stream.routes';
|
|
import { commentsRoutes } from './modules/media/routes/comments.routes';
|
|
import { uploadRoutes } from './modules/media/routes/upload.routes';
|
|
import { videoActionsRoutes } from './modules/media/routes/video-actions.routes';
|
|
import { videoScheduleRoutes } from './modules/media/routes/video-schedule.routes';
|
|
import { videoTrackingRoutes } from './modules/media/routes/video-tracking.routes';
|
|
import { commentAdminRoutes } from './modules/media/routes/comment-admin.routes';
|
|
import { chatNotificationsRoutes } from './modules/media/routes/chat-notifications.routes';
|
|
import { chatThreadsRoutes } from './modules/media/routes/chat-threads.routes';
|
|
import { userProfileRoutes } from './modules/media/routes/user-profile.routes';
|
|
import { shortsRoutes } from './modules/media/routes/shorts.routes';
|
|
import { upvoteRoutes } from './modules/media/routes/upvote.routes';
|
|
import { videoScheduleQueueService } from './services/video-schedule-queue.service';
|
|
import { videoFetchQueueService } from './services/video-fetch-queue.service';
|
|
import { fetchRoutes } from './modules/media/routes/fetch.routes';
|
|
import { playlistsPublicRoutes } from './modules/media/routes/playlists-public.routes';
|
|
import { playlistsUserRoutes } from './modules/media/routes/playlists-user.routes';
|
|
import { playlistsAdminRoutes } from './modules/media/routes/playlists-admin.routes';
|
|
import { photosRoutes } from './modules/media/routes/photos.routes';
|
|
import { photoUploadRoutes } from './modules/media/routes/photo-upload.routes';
|
|
import { photoAlbumsRoutes } from './modules/media/routes/photo-albums.routes';
|
|
import { photosPublicRoutes } from './modules/media/routes/photos-public.routes';
|
|
import { photoEngagementRoutes } from './modules/media/routes/photo-engagement.routes';
|
|
import { documentUploadRoutes } from './modules/media/routes/document-upload.routes';
|
|
import { documentsRoutes } from './modules/media/routes/documents.routes';
|
|
import { mediaErrorHandler } from './modules/media/middleware/error-handler';
|
|
|
|
// Add BigInt serialization support for Prisma BigInt fields
|
|
// This converts BigInt values to strings when JSON.stringify() is called
|
|
(BigInt.prototype as any).toJSON = function() {
|
|
return this.toString();
|
|
};
|
|
|
|
const fastify = Fastify({
|
|
logger: {
|
|
level: env.NODE_ENV === 'production' ? 'info' : 'debug',
|
|
},
|
|
maxParamLength: 500,
|
|
trustProxy: true,
|
|
});
|
|
|
|
fastify.setErrorHandler(mediaErrorHandler);
|
|
|
|
// Graceful shutdown handler
|
|
process.on('SIGTERM', async () => {
|
|
logger.info('SIGTERM received, shutting down gracefully...');
|
|
await videoScheduleQueueService.close();
|
|
await videoFetchQueueService.close();
|
|
fastify.close(() => {
|
|
logger.info('Media API server closed');
|
|
process.exit(0);
|
|
});
|
|
});
|
|
|
|
// Global error handlers
|
|
process.on('unhandledRejection', (reason, promise) => {
|
|
logger.error('Unhandled Promise Rejection in Media API', { reason: JSON.stringify(reason), promise: JSON.stringify(promise) });
|
|
});
|
|
|
|
process.on('uncaughtException', (error) => {
|
|
logger.error('Uncaught Exception in Media API', { error: error instanceof Error ? error.message : JSON.stringify(error) });
|
|
fastify.close(() => {
|
|
process.exit(1);
|
|
});
|
|
});
|
|
|
|
// Start server
|
|
const start = async () => {
|
|
try {
|
|
// CORS configuration — allow admin app + MkDocs docs site
|
|
const allowedOrigins = env.CORS_ORIGINS.split(',').map(o => o.trim());
|
|
|
|
// Auto-add MkDocs origins so video cards/players work in docs
|
|
const mkdocsOrigin = `http://localhost:${env.MKDOCS_PORT || 4003}`;
|
|
if (!allowedOrigins.includes(mkdocsOrigin)) {
|
|
allowedOrigins.push(mkdocsOrigin);
|
|
}
|
|
// Also allow the docs subdomain in production (docs.domain.org)
|
|
for (const origin of [...allowedOrigins]) {
|
|
const match = origin.match(/^(https?:\/\/)app\./);
|
|
if (match) {
|
|
const docsOrigin = origin.replace(/^(https?:\/\/)app\./, '$1docs.');
|
|
if (!allowedOrigins.includes(docsOrigin)) {
|
|
allowedOrigins.push(docsOrigin);
|
|
}
|
|
}
|
|
}
|
|
|
|
await fastify.register(cors, {
|
|
origin: (origin, cb) => {
|
|
// Allow requests with no origin (mobile apps, curl, etc.)
|
|
if (!origin) {
|
|
cb(null, true);
|
|
return;
|
|
}
|
|
|
|
// Check if origin is in allowed list
|
|
if (allowedOrigins.includes(origin)) {
|
|
cb(null, true);
|
|
} else {
|
|
cb(new Error('CORS not allowed'), false);
|
|
}
|
|
},
|
|
credentials: true,
|
|
});
|
|
|
|
// Multipart support for file uploads (10GB limit)
|
|
await fastify.register(multipart, {
|
|
limits: {
|
|
fileSize: env.MAX_UPLOAD_SIZE_GB * 1024 * 1024 * 1024,
|
|
},
|
|
});
|
|
|
|
// Health check
|
|
fastify.get('/health', async () => {
|
|
return {
|
|
status: 'ok',
|
|
timestamp: new Date().toISOString(),
|
|
service: 'media-api'
|
|
};
|
|
});
|
|
|
|
// Register routes
|
|
await fastify.register(videosRoutes, { prefix: '/api/videos' });
|
|
await fastify.register(videoStreamingRoutes, { prefix: '/api/videos' });
|
|
await fastify.register(uploadRoutes, { prefix: '/api/videos' });
|
|
await fastify.register(videoActionsRoutes, { prefix: '/api/videos' });
|
|
await fastify.register(videoScheduleRoutes, { prefix: '/api/videos' });
|
|
await fastify.register(videoTrackingRoutes, { prefix: '/api/track' });
|
|
await fastify.register(reactionsRoutes, { prefix: '/api/reactions' });
|
|
await fastify.register(publicRoutes, { prefix: '/api' });
|
|
await fastify.register(commentsRoutes, { prefix: '/api' });
|
|
await fastify.register(chatStreamRoutes, { prefix: '/api' });
|
|
await fastify.register(commentAdminRoutes, { prefix: '/api/media' });
|
|
await fastify.register(chatNotificationsRoutes, { prefix: '/api/media' });
|
|
// Signed URL generation (replaces ?token=JWT pattern, 2026-04-12).
|
|
const { signRoutes } = await import('./modules/media/routes/sign.routes');
|
|
await fastify.register(signRoutes, { prefix: '/api/media' });
|
|
await fastify.register(chatThreadsRoutes, { prefix: '/api/media' });
|
|
await fastify.register(userProfileRoutes, { prefix: '/api/media' });
|
|
await fastify.register(fetchRoutes, { prefix: '/api/videos' });
|
|
await fastify.register(shortsRoutes, { prefix: '/api' });
|
|
await fastify.register(upvoteRoutes, { prefix: '/api' });
|
|
await fastify.register(playlistsPublicRoutes, { prefix: '/api/playlists' });
|
|
await fastify.register(playlistsUserRoutes, { prefix: '/api/playlists' });
|
|
await fastify.register(playlistsAdminRoutes, { prefix: '/api/media' });
|
|
|
|
// Photo gallery routes
|
|
await fastify.register(photosRoutes, { prefix: '/api/photos' });
|
|
await fastify.register(photoUploadRoutes, { prefix: '/api/photos' });
|
|
await fastify.register(photoAlbumsRoutes, { prefix: '/api/albums' });
|
|
await fastify.register(photosPublicRoutes, { prefix: '/api' });
|
|
await fastify.register(photoEngagementRoutes, { prefix: '/api' });
|
|
|
|
// Document routes (PDFs, docx, etc. for volunteer resources)
|
|
await fastify.register(documentUploadRoutes, { prefix: '/api/documents' });
|
|
await fastify.register(documentsRoutes, { prefix: '/api/documents' });
|
|
|
|
// 404 handler for unmatched routes
|
|
fastify.setNotFoundHandler((_request, reply) => {
|
|
reply.status(404).send({ error: { message: 'Route not found', code: 'NOT_FOUND' } });
|
|
});
|
|
|
|
const port = env.MEDIA_API_PORT;
|
|
const host = '0.0.0.0';
|
|
|
|
await fastify.listen({ port, host });
|
|
logger.info(`Media API listening on http://${host}:${port}`);
|
|
|
|
// Start video schedule queue worker
|
|
videoScheduleQueueService.startWorker();
|
|
logger.info('Video schedule queue worker initialized');
|
|
|
|
// Start video fetch queue worker
|
|
videoFetchQueueService.startWorker();
|
|
logger.info('Video fetch queue worker initialized');
|
|
|
|
if (env.ENABLE_MEDIA_FEATURES !== 'true') {
|
|
logger.warn('Media features are disabled (ENABLE_MEDIA_FEATURES=false)');
|
|
}
|
|
} catch (err) {
|
|
logger.error('Media API startup error', { error: err instanceof Error ? err.message : JSON.stringify(err) });
|
|
process.exit(1);
|
|
}
|
|
};
|
|
|
|
start();
|