Security audit: fix 25 findings across API, nginx, and Docker
Addresses data exposure, access control, input validation, infrastructure hardening, and supply chain security issues identified during audit. Key changes: - Strip internal fields from public campaign/profile/comment endpoints - Restrict docs routes to CONTENT_ROLES, provisioning to SUPER_ADMIN - Add SSE connection limits, social middleware fail-closed behavior - Bind all non-nginx ports to 127.0.0.1, pin container image versions - Add CSP header, conditional HSTS, token redaction in nginx logs - Validate nav URLs, calendar schemas, video tracking batch events - Reject default admin password placeholder, add SSRF protocol checks - Exclude .env from Code Server, enforce RC admin password in compose - Add Zod validation for achievement grant/revoke, webhook secret header - Fix path traversal prefix attack, add calendar token expiry Bunker Admin
This commit is contained in:
parent
bdb672c7ad
commit
c192c04c79
@ -33,7 +33,11 @@ const envSchema = z.object({
|
||||
|
||||
// Initial Super Admin (auto-created during database seeding)
|
||||
INITIAL_ADMIN_EMAIL: z.string().email().default('admin@cmlite.org'),
|
||||
INITIAL_ADMIN_PASSWORD: z.string().min(12).default('REQUIRED_STRONG_PASSWORD_CHANGE_THIS'),
|
||||
INITIAL_ADMIN_PASSWORD: z.string().min(12).default('REQUIRED_STRONG_PASSWORD_CHANGE_THIS')
|
||||
.refine(
|
||||
(val) => val !== 'REQUIRED_STRONG_PASSWORD_CHANGE_THIS',
|
||||
{ message: 'INITIAL_ADMIN_PASSWORD must be changed from the default placeholder value' },
|
||||
),
|
||||
|
||||
// SMTP
|
||||
SMTP_HOST: z.string().default('mailhog-changemaker'),
|
||||
|
||||
@ -28,13 +28,13 @@ const recurrenceRuleSchema = z.object({
|
||||
export const createItemSchema = z.object({
|
||||
layerId: z.string().min(1),
|
||||
title: z.string().min(1).max(200),
|
||||
description: z.string().optional(),
|
||||
description: z.string().max(5000).optional(),
|
||||
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Date must be YYYY-MM-DD'),
|
||||
startTime: z.string().regex(/^\d{2}:\d{2}$/, 'Start time must be HH:MM'),
|
||||
endTime: z.string().regex(/^\d{2}:\d{2}$/, 'End time must be HH:MM'),
|
||||
isAllDay: z.boolean().optional(),
|
||||
itemType: z.enum(['EVENT', 'TIME_BLOCK', 'REMINDER']),
|
||||
location: z.string().optional(),
|
||||
location: z.string().max(500).optional(),
|
||||
color: z.string().regex(/^#[0-9a-fA-F]{6}$/).optional(),
|
||||
visibility: z.enum(['PRIVATE', 'FRIENDS', 'PUBLIC']).optional(),
|
||||
busyStatus: z.enum(['BUSY', 'TENTATIVE', 'FREE']).optional(),
|
||||
@ -45,13 +45,13 @@ export const createItemSchema = z.object({
|
||||
|
||||
export const updateItemSchema = z.object({
|
||||
title: z.string().min(1).max(200).optional(),
|
||||
description: z.string().nullable().optional(),
|
||||
description: z.string().max(5000).nullable().optional(),
|
||||
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Date must be YYYY-MM-DD').optional(),
|
||||
startTime: z.string().regex(/^\d{2}:\d{2}$/, 'Start time must be HH:MM').optional(),
|
||||
endTime: z.string().regex(/^\d{2}:\d{2}$/, 'End time must be HH:MM').optional(),
|
||||
isAllDay: z.boolean().optional(),
|
||||
itemType: z.enum(['EVENT', 'TIME_BLOCK', 'REMINDER']).optional(),
|
||||
location: z.string().nullable().optional(),
|
||||
location: z.string().max(500).nullable().optional(),
|
||||
color: z.string().regex(/^#[0-9a-fA-F]{6}$/).nullable().optional(),
|
||||
visibility: z.enum(['PRIVATE', 'FRIENDS', 'PUBLIC']).nullable().optional(),
|
||||
busyStatus: z.enum(['BUSY', 'TENTATIVE', 'FREE']).optional(),
|
||||
|
||||
@ -484,6 +484,14 @@ export const feedService = {
|
||||
|
||||
if (!exportToken) return null;
|
||||
|
||||
// Enforce 1-year expiry on export tokens (soft check — no DB column needed)
|
||||
const TOKEN_MAX_AGE_MS = 365 * 24 * 60 * 60 * 1000; // 1 year
|
||||
if (Date.now() - exportToken.createdAt.getTime() > TOKEN_MAX_AGE_MS) {
|
||||
// Token expired — clean it up and return null
|
||||
await prisma.calendarExportToken.delete({ where: { id: exportToken.id } }).catch(() => {});
|
||||
return null;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const pastLimit = new Date(now);
|
||||
pastLimit.setMonth(pastLimit.getMonth() - 1);
|
||||
|
||||
@ -32,7 +32,8 @@ function hashFilePath(path: string): string {
|
||||
function safeResolve(relativePath: string): string {
|
||||
const normalized = normalize(relativePath).replace(/^(\.\.(\/|\\|$))+/, '');
|
||||
const resolved = pathResolve(DOCS_ROOT, normalized);
|
||||
if (!resolved.startsWith(DOCS_ROOT)) {
|
||||
// Use DOCS_ROOT + sep to prevent prefix attacks (e.g., /mkdocs/docs-evil matching /mkdocs/docs)
|
||||
if (resolved !== DOCS_ROOT && !resolved.startsWith(DOCS_ROOT + '/')) {
|
||||
throw new PathTraversalError();
|
||||
}
|
||||
return resolved;
|
||||
|
||||
@ -21,9 +21,10 @@ router.use(requireNonTemp);
|
||||
|
||||
// Removed duplicated isServiceOnline - now using shared utility from utils/health-check.ts
|
||||
|
||||
// GET /api/docs/status — check MkDocs and Code Server availability
|
||||
// GET /api/docs/status — check MkDocs and Code Server availability (content editors only)
|
||||
router.get(
|
||||
'/status',
|
||||
requireRole(...CONTENT_ROLES),
|
||||
async (_req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const [mkdocsOnline, codeServerOnline, siteServerOnline] = await Promise.all([
|
||||
@ -44,9 +45,10 @@ router.get(
|
||||
},
|
||||
);
|
||||
|
||||
// GET /api/docs/config — return public-facing port numbers for iframe URLs
|
||||
// GET /api/docs/config — return public-facing port numbers for iframe URLs (content editors only)
|
||||
router.get(
|
||||
'/config',
|
||||
requireRole(...CONTENT_ROLES),
|
||||
async (_req: Request, res: Response, _next: NextFunction) => {
|
||||
res.json({
|
||||
codeServerPort: env.CODE_SERVER_PORT,
|
||||
@ -58,9 +60,10 @@ router.get(
|
||||
|
||||
// --- MkDocs Config Endpoints ---
|
||||
|
||||
// GET /api/docs/mkdocs-config — read raw mkdocs.yml content
|
||||
// GET /api/docs/mkdocs-config — read raw mkdocs.yml content (content editors only)
|
||||
router.get(
|
||||
'/mkdocs-config',
|
||||
requireRole(...CONTENT_ROLES),
|
||||
async (_req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const content = await mkdocsConfigService.readConfig();
|
||||
@ -113,9 +116,10 @@ router.post(
|
||||
|
||||
// --- Header Builder ---
|
||||
|
||||
// GET /api/docs/header-config — read header nav bar config
|
||||
// GET /api/docs/header-config — read header nav bar config (content editors only)
|
||||
router.get(
|
||||
'/header-config',
|
||||
requireRole(...CONTENT_ROLES),
|
||||
async (_req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const config = await headerBuilderService.readConfig();
|
||||
@ -205,9 +209,10 @@ router.post(
|
||||
|
||||
// --- File Management Endpoints ---
|
||||
|
||||
// GET /api/docs/files — list file tree
|
||||
// GET /api/docs/files — list file tree (content editors only)
|
||||
router.get(
|
||||
'/files',
|
||||
requireRole(...CONTENT_ROLES),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
cm_docs_operations.inc({ operation: 'list' });
|
||||
@ -223,9 +228,10 @@ router.get(
|
||||
},
|
||||
);
|
||||
|
||||
// GET /api/docs/files/search — search files by name/path (for command palette)
|
||||
// GET /api/docs/files/search — search files by name/path (content editors only)
|
||||
router.get(
|
||||
'/files/search',
|
||||
requireRole(...CONTENT_ROLES),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const search = String(req.query['search'] ?? req.query['q'] ?? '').trim();
|
||||
@ -265,9 +271,10 @@ router.post(
|
||||
},
|
||||
);
|
||||
|
||||
// GET /api/docs/files/* — read file content
|
||||
// GET /api/docs/files/* — read file content (content editors only)
|
||||
router.get(
|
||||
'/files/*',
|
||||
requireRole(...CONTENT_ROLES),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
cm_docs_operations.inc({ operation: 'read' });
|
||||
|
||||
@ -3,7 +3,11 @@ import { z } from 'zod';
|
||||
export const headerNavItemSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
label: z.string().min(1).max(50),
|
||||
path: z.string().min(1).max(500),
|
||||
path: z.string().min(1).max(500)
|
||||
.refine(
|
||||
(v) => !/^(javascript|data|vbscript):/i.test(v),
|
||||
'Dangerous URL scheme not allowed',
|
||||
),
|
||||
icon: z.string().max(50).optional(),
|
||||
enabled: z.boolean(),
|
||||
order: z.number().int().min(0),
|
||||
|
||||
@ -56,6 +56,41 @@ const campaignSelect = {
|
||||
},
|
||||
} satisfies Prisma.CampaignSelect;
|
||||
|
||||
/** Public-facing select — strips admin-only fields (emails, internal IDs, moderation notes) */
|
||||
const publicCampaignSelect = {
|
||||
id: true,
|
||||
slug: true,
|
||||
title: true,
|
||||
description: true,
|
||||
emailSubject: true,
|
||||
emailBody: true,
|
||||
callToAction: true,
|
||||
coverPhoto: true,
|
||||
coverVideoId: true,
|
||||
status: true,
|
||||
allowSmtpEmail: true,
|
||||
allowMailtoLink: true,
|
||||
collectUserInfo: true,
|
||||
showEmailCount: true,
|
||||
showCallCount: true,
|
||||
allowEmailEditing: true,
|
||||
allowCustomRecipients: true,
|
||||
showResponseWall: true,
|
||||
highlightCampaign: true,
|
||||
targetGovernmentLevels: true,
|
||||
createdByUserName: true,
|
||||
isUserGenerated: true,
|
||||
moderationStatus: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
_count: {
|
||||
select: {
|
||||
emails: true,
|
||||
responses: true,
|
||||
},
|
||||
},
|
||||
} satisfies Prisma.CampaignSelect;
|
||||
|
||||
function generateSlug(title: string): string {
|
||||
return title
|
||||
.toLowerCase()
|
||||
@ -224,7 +259,7 @@ export const campaignsService = {
|
||||
async findActiveCampaigns() {
|
||||
return prisma.campaign.findMany({
|
||||
where: { status: 'ACTIVE' },
|
||||
select: campaignSelect,
|
||||
select: publicCampaignSelect,
|
||||
orderBy: [
|
||||
{ highlightCampaign: 'desc' },
|
||||
{ createdAt: 'desc' },
|
||||
@ -235,7 +270,7 @@ export const campaignsService = {
|
||||
async findBySlugPublic(slug: string) {
|
||||
const campaign = await prisma.campaign.findUnique({
|
||||
where: { slug },
|
||||
select: campaignSelect,
|
||||
select: publicCampaignSelect,
|
||||
});
|
||||
|
||||
if (!campaign) {
|
||||
|
||||
@ -15,7 +15,8 @@ router.post(
|
||||
'/webhook',
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const secret = req.query.secret as string;
|
||||
// Accept secret from header (preferred) or query param (legacy fallback)
|
||||
const secret = (req.headers['x-webhook-secret'] as string) || (req.query.secret as string);
|
||||
if (!env.LISTMONK_WEBHOOK_SECRET || secret !== env.LISTMONK_WEBHOOK_SECRET) {
|
||||
res.status(403).json({ error: 'Invalid webhook secret' });
|
||||
return;
|
||||
|
||||
@ -75,7 +75,7 @@ export async function commentsRoutes(fastify: FastifyInstance) {
|
||||
user: comment.user
|
||||
? {
|
||||
id: comment.user.id,
|
||||
name: comment.user.name || comment.user.email,
|
||||
name: comment.user.name || 'Anonymous',
|
||||
}
|
||||
: null,
|
||||
}));
|
||||
@ -229,7 +229,7 @@ export async function commentsRoutes(fastify: FastifyInstance) {
|
||||
user: newComment.user
|
||||
? {
|
||||
id: newComment.user.id,
|
||||
name: newComment.user.name || newComment.user.email,
|
||||
name: newComment.user.name || 'Anonymous',
|
||||
}
|
||||
: null,
|
||||
};
|
||||
@ -253,7 +253,7 @@ export async function commentsRoutes(fastify: FastifyInstance) {
|
||||
select: { filename: true },
|
||||
});
|
||||
|
||||
const commenterName = newComment.user?.name || newComment.user?.email || 'Someone';
|
||||
const commenterName = newComment.user?.name || 'Someone';
|
||||
const contentPreview = content.trim().length > 80
|
||||
? content.trim().substring(0, 80) + '...'
|
||||
: content.trim();
|
||||
|
||||
@ -186,6 +186,23 @@ export async function videoTrackingRoutes(fastify: FastifyInstance) {
|
||||
}
|
||||
|
||||
try {
|
||||
// Validate each event's data against per-type schemas before processing
|
||||
for (const event of events) {
|
||||
switch (event.type) {
|
||||
case 'view':
|
||||
recordViewSchema.parse(event.data);
|
||||
break;
|
||||
case 'event':
|
||||
recordEventSchema.parse(event.data);
|
||||
break;
|
||||
case 'heartbeat':
|
||||
updateWatchTimeSchema.parse(event.data);
|
||||
break;
|
||||
default:
|
||||
return reply.code(400).send({ message: `Unknown event type: ${event.type}` });
|
||||
}
|
||||
}
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
events.map(async (event) => {
|
||||
switch (event.type) {
|
||||
|
||||
@ -130,14 +130,20 @@ router.post('/test-connection', async (req, res) => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate URL format
|
||||
// Validate URL format and protocol
|
||||
let parsedUrl: URL;
|
||||
try {
|
||||
new URL(url);
|
||||
parsedUrl = new URL(url);
|
||||
} catch {
|
||||
res.status(400).json({ error: { message: 'Invalid URL format', code: 'VALIDATION_ERROR' } });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!['http:', 'https:'].includes(parsedUrl.protocol)) {
|
||||
res.status(400).json({ error: { message: 'Only http and https URLs are allowed', code: 'VALIDATION_ERROR' } });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await TermuxClient.testConnection(url, apiKey);
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
@ -173,10 +179,14 @@ router.post('/save-config', async (req, res) => {
|
||||
if (typeof smsTailscaleDeviceId === 'string') update.smsTailscaleDeviceId = smsTailscaleDeviceId;
|
||||
if (typeof smsTailscaleDeviceName === 'string') update.smsTailscaleDeviceName = smsTailscaleDeviceName;
|
||||
|
||||
// Validate URL format if provided
|
||||
// Validate URL format and protocol if provided
|
||||
if (typeof smsTermuxApiUrl === 'string' && smsTermuxApiUrl) {
|
||||
try {
|
||||
new URL(smsTermuxApiUrl);
|
||||
const parsed = new URL(smsTermuxApiUrl);
|
||||
if (!['http:', 'https:'].includes(parsed.protocol)) {
|
||||
res.status(400).json({ error: { message: 'Only http and https URLs are allowed', code: 'VALIDATION_ERROR' } });
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
res.status(400).json({ error: { message: 'Invalid Termux API URL format', code: 'VALIDATION_ERROR' } });
|
||||
return;
|
||||
|
||||
@ -22,11 +22,20 @@ router.get('/', async (req: Request, res: Response) => {
|
||||
}
|
||||
});
|
||||
|
||||
/** GET /api/social/groups/:id — group detail with members */
|
||||
/** GET /api/social/groups/:id — group detail with members (membership required) */
|
||||
router.get('/:id', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const groupId = req.params.id as string;
|
||||
const userId = req.user!.id;
|
||||
const result = await groupService.getGroupDetail(groupId);
|
||||
|
||||
// Verify the requesting user is a member of this group
|
||||
const isMember = result.members.some((m: any) => m.userId === userId);
|
||||
if (!isMember) {
|
||||
res.status(404).json({ error: { message: 'Group not found', code: 'NOT_FOUND' } });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json(result);
|
||||
} catch (err: any) {
|
||||
res.status(err.statusCode || 500).json({ error: { message: err.message } });
|
||||
|
||||
@ -7,7 +7,8 @@ import { blockService } from './block.service';
|
||||
import { messagingService } from './messaging.service';
|
||||
import { checkSocialEnabled } from './social.middleware';
|
||||
|
||||
const PROFILE_SELECT = {
|
||||
/** Own profile — includes email */
|
||||
const OWN_PROFILE_SELECT = {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
@ -15,6 +16,14 @@ const PROFILE_SELECT = {
|
||||
createdAt: true,
|
||||
} as const;
|
||||
|
||||
/** Other users' profiles — email stripped to prevent harvesting */
|
||||
const PROFILE_SELECT = {
|
||||
id: true,
|
||||
name: true,
|
||||
role: true,
|
||||
createdAt: true,
|
||||
} as const;
|
||||
|
||||
const router = Router();
|
||||
router.use(authenticate);
|
||||
router.use(checkSocialEnabled);
|
||||
@ -25,7 +34,7 @@ router.get('/me', async (req: Request, res: Response) => {
|
||||
const userId = req.user!.id;
|
||||
|
||||
const [user, friendCount, pendingCount, privacy] = await Promise.all([
|
||||
prisma.user.findUnique({ where: { id: userId }, select: PROFILE_SELECT }),
|
||||
prisma.user.findUnique({ where: { id: userId }, select: OWN_PROFILE_SELECT }),
|
||||
prisma.friendship.count({
|
||||
where: {
|
||||
OR: [
|
||||
|
||||
@ -1,9 +1,15 @@
|
||||
import { Router } from 'express';
|
||||
import { z } from 'zod';
|
||||
import { socialAdminService } from './social-admin.service';
|
||||
import { requireRole } from '../../middleware/rbac.middleware';
|
||||
import { SOCIAL_ROLES } from '../../utils/roles';
|
||||
import { logger } from '../../utils/logger';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Self-contained auth guard (defense-in-depth — parent also applies requireRole)
|
||||
router.use(requireRole(...SOCIAL_ROLES));
|
||||
|
||||
/** GET /api/social/admin/stats — Dashboard overview */
|
||||
router.get('/stats', async (_req, res) => {
|
||||
try {
|
||||
@ -68,14 +74,19 @@ router.post('/blocks/:id/remove', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
const achievementSchema = z.object({
|
||||
userId: z.string().min(1).max(100),
|
||||
achievementId: z.string().min(1).max(100),
|
||||
});
|
||||
|
||||
/** POST /api/social/admin/achievements/grant — Grant achievement */
|
||||
router.post('/achievements/grant', async (req, res) => {
|
||||
try {
|
||||
const { userId, achievementId } = req.body;
|
||||
if (!userId || !achievementId) {
|
||||
return res.status(400).json({ error: 'userId and achievementId are required' });
|
||||
const parsed = achievementSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({ error: 'Valid userId and achievementId are required' });
|
||||
}
|
||||
const result = await socialAdminService.grantAchievement(userId, achievementId);
|
||||
const result = await socialAdminService.grantAchievement(parsed.data.userId, parsed.data.achievementId);
|
||||
res.json(result);
|
||||
} catch (err: unknown) {
|
||||
const statusCode = (err as { statusCode?: number }).statusCode ?? 500;
|
||||
@ -87,11 +98,11 @@ router.post('/achievements/grant', async (req, res) => {
|
||||
/** POST /api/social/admin/achievements/revoke — Revoke achievement */
|
||||
router.post('/achievements/revoke', async (req, res) => {
|
||||
try {
|
||||
const { userId, achievementId } = req.body;
|
||||
if (!userId || !achievementId) {
|
||||
return res.status(400).json({ error: 'userId and achievementId are required' });
|
||||
const parsed = achievementSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({ error: 'Valid userId and achievementId are required' });
|
||||
}
|
||||
await socialAdminService.revokeAchievement(userId, achievementId);
|
||||
await socialAdminService.revokeAchievement(parsed.data.userId, parsed.data.achievementId);
|
||||
res.json({ success: true });
|
||||
} catch (err: unknown) {
|
||||
const statusCode = (err as { statusCode?: number }).statusCode ?? 500;
|
||||
|
||||
@ -11,6 +11,7 @@ export async function checkSocialEnabled(req: Request, res: Response, next: Next
|
||||
}
|
||||
next();
|
||||
} catch {
|
||||
next();
|
||||
// Fail closed — if we can't check the feature flag, deny access
|
||||
res.status(503).json({ error: { message: 'Service temporarily unavailable', code: 'SERVICE_UNAVAILABLE' } });
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,8 +1,12 @@
|
||||
import { Router } from 'express';
|
||||
import { checkSocialEnabled } from './social.middleware';
|
||||
import { requireRole } from '../../middleware/rbac.middleware';
|
||||
import { SOCIAL_ROLES } from '../../utils/roles';
|
||||
import { sseService } from './sse.service';
|
||||
import { presenceService } from './presence.service';
|
||||
|
||||
const MAX_SSE_CONNECTIONS_PER_USER = 5;
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.use(checkSocialEnabled);
|
||||
@ -11,6 +15,13 @@ router.use(checkSocialEnabled);
|
||||
router.get('/', (req, res) => {
|
||||
const userId = req.user!.id;
|
||||
|
||||
// Enforce per-user connection limit to prevent resource exhaustion
|
||||
const existingCount = sseService.getConnectionCountForUser?.(userId) ?? 0;
|
||||
if (existingCount >= MAX_SSE_CONNECTIONS_PER_USER) {
|
||||
res.status(429).json({ error: { message: 'Too many SSE connections', code: 'TOO_MANY_CONNECTIONS' } });
|
||||
return;
|
||||
}
|
||||
|
||||
// Set SSE headers
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
@ -48,8 +59,8 @@ router.get('/online-friends', async (req, res, next) => {
|
||||
}
|
||||
});
|
||||
|
||||
/** GET /api/social/sse/status — SSE service status */
|
||||
router.get('/status', (_req, res) => {
|
||||
/** GET /api/social/sse/status — SSE service status (admin only) */
|
||||
router.get('/status', requireRole(...SOCIAL_ROLES), (_req, res) => {
|
||||
res.json({
|
||||
connections: sseService.getConnectionCount(),
|
||||
connectedUsers: sseService.getConnectedUserIds().length,
|
||||
|
||||
@ -117,6 +117,11 @@ class SSEService {
|
||||
return count;
|
||||
}
|
||||
|
||||
/** Get connection count for a specific user */
|
||||
getConnectionCountForUser(userId: string): number {
|
||||
return this.clients.get(userId)?.length ?? 0;
|
||||
}
|
||||
|
||||
/** Close all connections (graceful shutdown) */
|
||||
closeAll() {
|
||||
this.stopHeartbeat();
|
||||
|
||||
@ -1,13 +1,12 @@
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import { authenticate } from '../../middleware/auth.middleware';
|
||||
import { requireRole } from '../../middleware/rbac.middleware';
|
||||
import { ADMIN_ROLES } from '../../utils/roles';
|
||||
import { userProvisioningService } from '../../services/user-provisioning/provisioning.service';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.use(authenticate);
|
||||
router.use(requireRole(...ADMIN_ROLES));
|
||||
router.use(requireRole('SUPER_ADMIN'));
|
||||
|
||||
// POST /api/users/provisioning/sync — bulk sync all users (static route BEFORE :id params)
|
||||
router.post(
|
||||
|
||||
@ -15,8 +15,8 @@ services:
|
||||
container_name: changemaker-v2-api
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${API_PORT:-4000}:4000"
|
||||
- "${LISTMONK_PROXY_PORT:-9002}:9002"
|
||||
- "127.0.0.1:${API_PORT:-4000}:4000"
|
||||
- "127.0.0.1:${LISTMONK_PROXY_PORT:-9002}:9002"
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-q", "--spider", "http://localhost:4000/api/health"]
|
||||
interval: 15s
|
||||
@ -69,7 +69,7 @@ services:
|
||||
- VAULTWARDEN_EMBED_PORT=${VAULTWARDEN_EMBED_PORT:-8890}
|
||||
- ROCKETCHAT_URL=${ROCKETCHAT_URL:-http://rocketchat-changemaker:3000}
|
||||
- ROCKETCHAT_ADMIN_USER=${ROCKETCHAT_ADMIN_USER:-rcadmin}
|
||||
- ROCKETCHAT_ADMIN_PASSWORD=${ROCKETCHAT_ADMIN_PASSWORD:-changeme}
|
||||
- ROCKETCHAT_ADMIN_PASSWORD=${ROCKETCHAT_ADMIN_PASSWORD:?ROCKETCHAT_ADMIN_PASSWORD must be set in .env}
|
||||
- ROCKETCHAT_EMBED_PORT=${ROCKETCHAT_EMBED_PORT:-8891}
|
||||
- ENABLE_CHAT=${ENABLE_CHAT:-false}
|
||||
- GANCIO_URL=${GANCIO_URL:-http://gancio-changemaker:13120}
|
||||
@ -129,7 +129,7 @@ services:
|
||||
container_name: changemaker-media-api
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${MEDIA_API_PORT:-4100}:4100"
|
||||
- "127.0.0.1:${MEDIA_API_PORT:-4100}:4100"
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1:4100/health"]
|
||||
interval: 15s
|
||||
@ -178,7 +178,7 @@ services:
|
||||
container_name: changemaker-v2-admin
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${ADMIN_PORT:-3000}:3000"
|
||||
- "127.0.0.1:${ADMIN_PORT:-3000}:3000"
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1:3000/"]
|
||||
interval: 30s
|
||||
@ -266,11 +266,11 @@ services:
|
||||
|
||||
# NocoDB v2 — pointed at v2 PostgreSQL as read-only data browser
|
||||
nocodb-v2:
|
||||
image: nocodb/nocodb:latest
|
||||
image: nocodb/nocodb:0.301.3
|
||||
container_name: changemaker-v2-nocodb
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${NOCODB_V2_PORT:-8091}:8080"
|
||||
- "127.0.0.1:${NOCODB_V2_PORT:-8091}:8080"
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-q", "--spider", "http://localhost:8080/api/v1/health"]
|
||||
interval: 30s
|
||||
@ -352,11 +352,11 @@ services:
|
||||
|
||||
# Listmonk — Email marketing (kept as Docker image, controlled via REST API)
|
||||
listmonk-app:
|
||||
image: listmonk/listmonk:latest
|
||||
image: listmonk/listmonk:v6.0.0
|
||||
container_name: listmonk-app
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${LISTMONK_PORT:-9001}:9000"
|
||||
- "127.0.0.1:${LISTMONK_PORT:-9001}:9000"
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-q", "--spider", "http://localhost:9000/"]
|
||||
interval: 30s
|
||||
@ -487,9 +487,16 @@ services:
|
||||
volumes:
|
||||
- ./configs/code-server/.config:/home/coder/.config
|
||||
- ./configs/code-server/.local:/home/coder/.local
|
||||
- .:/home/coder/project
|
||||
- ./api:/home/coder/project/api
|
||||
- ./admin:/home/coder/project/admin
|
||||
- ./nginx:/home/coder/project/nginx
|
||||
- ./configs:/home/coder/project/configs
|
||||
- ./scripts:/home/coder/project/scripts
|
||||
- ./mkdocs:/home/coder/project/mkdocs
|
||||
- ./docker-compose.yml:/home/coder/project/docker-compose.yml
|
||||
# NOTE: .env intentionally excluded — secrets must not be accessible via Code Server
|
||||
ports:
|
||||
- "${CODE_SERVER_PORT:-8888}:8080"
|
||||
- "127.0.0.1:${CODE_SERVER_PORT:-8888}:8080"
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- changemaker-lite
|
||||
@ -505,7 +512,7 @@ services:
|
||||
- ./scripts/mkdocs-entrypoint.sh:/scripts/mkdocs-entrypoint.sh:ro
|
||||
user: "${USER_ID:-1000}:${GROUP_ID:-1000}"
|
||||
ports:
|
||||
- "${MKDOCS_PORT:-4003}:8000"
|
||||
- "127.0.0.1:${MKDOCS_PORT:-4003}:8000"
|
||||
environment:
|
||||
- SITE_URL=${BASE_DOMAIN:-https://cmlite.org}
|
||||
- ADMIN_PORT=${ADMIN_PORT:-3000}
|
||||
@ -524,7 +531,7 @@ services:
|
||||
|
||||
# MkDocs built site — Nginx static server
|
||||
mkdocs-site-server:
|
||||
image: lscr.io/linuxserver/nginx:latest
|
||||
image: lscr.io/linuxserver/nginx:1.28.2
|
||||
container_name: mkdocs-site-server-changemaker
|
||||
environment:
|
||||
- PUID=${USER_ID:-1000}
|
||||
@ -534,7 +541,7 @@ services:
|
||||
- ./mkdocs/site:/config/www
|
||||
- ./configs/mkdocs-site/default.conf:/config/nginx/site-confs/default.conf
|
||||
ports:
|
||||
- "${MKDOCS_SITE_SERVER_PORT:-4004}:80"
|
||||
- "127.0.0.1:${MKDOCS_SITE_SERVER_PORT:-4004}:80"
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- changemaker-lite
|
||||
@ -545,7 +552,7 @@ services:
|
||||
container_name: n8n-changemaker
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${N8N_PORT:-5678}:5678"
|
||||
- "127.0.0.1:${N8N_PORT:-5678}:5678"
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-q", "--spider", "http://localhost:5678/healthz"]
|
||||
interval: 30s
|
||||
@ -571,10 +578,10 @@ services:
|
||||
|
||||
# Homepage dashboard
|
||||
homepage:
|
||||
image: ghcr.io/gethomepage/homepage:latest
|
||||
image: ghcr.io/gethomepage/homepage:v0.7.2
|
||||
container_name: homepage-changemaker
|
||||
ports:
|
||||
- "${HOMEPAGE_PORT:-3010}:3000"
|
||||
- "127.0.0.1:${HOMEPAGE_PORT:-3010}:3000"
|
||||
volumes:
|
||||
- ./configs/homepage:/app/config
|
||||
- ./assets/icons:/app/public/icons
|
||||
@ -624,8 +631,8 @@ services:
|
||||
- /etc/timezone:/etc/timezone:ro
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
ports:
|
||||
- "${GITEA_WEB_PORT:-3030}:3000"
|
||||
- "${GITEA_SSH_PORT:-2222}:22"
|
||||
- "127.0.0.1:${GITEA_WEB_PORT:-3030}:3000"
|
||||
- "127.0.0.1:${GITEA_SSH_PORT:-2222}:22"
|
||||
depends_on:
|
||||
- gitea-db
|
||||
networks:
|
||||
@ -652,21 +659,21 @@ services:
|
||||
|
||||
# Mini QR — QR code generator
|
||||
mini-qr:
|
||||
image: ghcr.io/lyqht/mini-qr:latest
|
||||
image: ghcr.io/lyqht/mini-qr:v0.26.0
|
||||
container_name: mini-qr
|
||||
ports:
|
||||
- "${MINI_QR_PORT:-8089}:8080"
|
||||
- "127.0.0.1:${MINI_QR_PORT:-8089}:8080"
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- changemaker-lite
|
||||
|
||||
# Excalidraw — Collaborative whiteboard
|
||||
excalidraw:
|
||||
image: kiliandeca/excalidraw:latest
|
||||
image: kiliandeca/excalidraw:sha-e42a510
|
||||
container_name: excalidraw-changemaker
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${EXCALIDRAW_PORT:-8090}:80"
|
||||
- "127.0.0.1:${EXCALIDRAW_PORT:-8090}:80"
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-q", "--spider", "http://localhost:80/"]
|
||||
interval: 30s
|
||||
@ -680,11 +687,11 @@ services:
|
||||
|
||||
# Vaultwarden — Password manager (Bitwarden-compatible)
|
||||
vaultwarden:
|
||||
image: vaultwarden/server:latest
|
||||
image: vaultwarden/server:1.35.4
|
||||
container_name: vaultwarden-changemaker
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${VAULTWARDEN_PORT:-8445}:80"
|
||||
- "127.0.0.1:${VAULTWARDEN_PORT:-8445}:80"
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-sf", "http://localhost:80/alive"]
|
||||
interval: 30s
|
||||
@ -714,7 +721,7 @@ services:
|
||||
# Uses the admin panel API to send an invitation email (lands in MailHog or real SMTP).
|
||||
# Safe to re-run (Vaultwarden ignores duplicate invites for existing users). Exits 0 on success.
|
||||
vaultwarden-init:
|
||||
image: alpine/curl:latest
|
||||
image: alpine/curl:8.11.1
|
||||
container_name: vaultwarden-init
|
||||
depends_on:
|
||||
vaultwarden:
|
||||
@ -850,14 +857,14 @@ services:
|
||||
|
||||
# Gancio — Event management platform (uses shared PostgreSQL)
|
||||
gancio:
|
||||
image: cisti/gancio:latest
|
||||
image: cisti/gancio:1.28.2
|
||||
container_name: gancio-changemaker
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
v2-postgres:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- "${GANCIO_PORT:-8092}:13120"
|
||||
- "127.0.0.1:${GANCIO_PORT:-8092}:13120"
|
||||
healthcheck:
|
||||
test: ["CMD", "node", "-e", "require('http').get('http://localhost:13120/', r => process.exit(r.statusCode < 400 ? 0 : 1)).on('error', () => process.exit(1))"]
|
||||
interval: 30s
|
||||
@ -1023,7 +1030,7 @@ services:
|
||||
jitsi-prosody:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- "${JVB_PORT:-10000}:10000/udp"
|
||||
- "127.0.0.1:${JVB_PORT:-10000}:10000/udp"
|
||||
environment:
|
||||
- XMPP_DOMAIN=meet.jitsi
|
||||
- XMPP_AUTH_DOMAIN=auth.meet.jitsi
|
||||
@ -1041,10 +1048,10 @@ services:
|
||||
|
||||
# MailHog — Email testing (dev)
|
||||
mailhog:
|
||||
image: mailhog/mailhog:latest
|
||||
image: mailhog/mailhog:v1.0.1
|
||||
container_name: mailhog-changemaker
|
||||
ports:
|
||||
- "${MAILHOG_WEB_PORT:-8025}:8025"
|
||||
- "127.0.0.1:${MAILHOG_WEB_PORT:-8025}:8025"
|
||||
# SMTP port 1025 is only exposed on the Docker network (containers connect via mailhog-changemaker:1025)
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
@ -1075,7 +1082,7 @@ services:
|
||||
|
||||
# Docker socket proxy — read-only access for container status monitoring
|
||||
docker-socket-proxy:
|
||||
image: tecnativa/docker-socket-proxy:latest
|
||||
image: tecnativa/docker-socket-proxy:0.4.2
|
||||
container_name: docker-socket-proxy
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
@ -1096,14 +1103,14 @@ services:
|
||||
# =========================================================================
|
||||
|
||||
prometheus:
|
||||
image: prom/prometheus:latest
|
||||
image: prom/prometheus:v3.10.0
|
||||
container_name: prometheus-changemaker
|
||||
command:
|
||||
- '--config.file=/etc/prometheus/prometheus.yml'
|
||||
- '--storage.tsdb.path=/prometheus'
|
||||
- '--storage.tsdb.retention.time=30d'
|
||||
ports:
|
||||
- "${PROMETHEUS_PORT:-9090}:9090"
|
||||
- "127.0.0.1:${PROMETHEUS_PORT:-9090}:9090"
|
||||
volumes:
|
||||
- ./configs/prometheus:/etc/prometheus
|
||||
- prometheus-data:/prometheus
|
||||
@ -1114,10 +1121,10 @@ services:
|
||||
- monitoring
|
||||
|
||||
grafana:
|
||||
image: grafana/grafana:latest
|
||||
image: grafana/grafana:12.3.0
|
||||
container_name: grafana-changemaker
|
||||
ports:
|
||||
- "${GRAFANA_PORT:-3001}:3000"
|
||||
- "127.0.0.1:${GRAFANA_PORT:-3001}:3000"
|
||||
environment:
|
||||
- GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_ADMIN_PASSWORD:?GRAFANA_ADMIN_PASSWORD must be set in .env}
|
||||
- GF_USERS_ALLOW_SIGN_UP=false
|
||||
@ -1137,10 +1144,10 @@ services:
|
||||
- monitoring
|
||||
|
||||
cadvisor:
|
||||
image: gcr.io/cadvisor/cadvisor:latest
|
||||
image: gcr.io/cadvisor/cadvisor:v0.55.1
|
||||
container_name: cadvisor-changemaker
|
||||
ports:
|
||||
- "${CADVISOR_PORT:-8080}:8080"
|
||||
- "127.0.0.1:${CADVISOR_PORT:-8080}:8080"
|
||||
volumes:
|
||||
- /:/rootfs:ro
|
||||
- /var/run:/var/run:ro
|
||||
@ -1148,6 +1155,7 @@ services:
|
||||
- /var/lib/docker/:/var/lib/docker:ro
|
||||
- /dev/disk/:/dev/disk:ro
|
||||
privileged: true
|
||||
read_only: true
|
||||
devices:
|
||||
- /dev/kmsg
|
||||
restart: always
|
||||
@ -1157,10 +1165,10 @@ services:
|
||||
- monitoring
|
||||
|
||||
node-exporter:
|
||||
image: prom/node-exporter:latest
|
||||
image: prom/node-exporter:v1.10.2
|
||||
container_name: node-exporter-changemaker
|
||||
ports:
|
||||
- "${NODE_EXPORTER_PORT:-9100}:9100"
|
||||
- "127.0.0.1:${NODE_EXPORTER_PORT:-9100}:9100"
|
||||
command:
|
||||
- '--path.rootfs=/host'
|
||||
- '--path.procfs=/host/proc'
|
||||
@ -1177,10 +1185,10 @@ services:
|
||||
- monitoring
|
||||
|
||||
redis-exporter:
|
||||
image: oliver006/redis_exporter:latest
|
||||
image: oliver006/redis_exporter:v1.81.0
|
||||
container_name: redis-exporter-changemaker
|
||||
ports:
|
||||
- "${REDIS_EXPORTER_PORT:-9121}:9121"
|
||||
- "127.0.0.1:${REDIS_EXPORTER_PORT:-9121}:9121"
|
||||
environment:
|
||||
- REDIS_ADDR=redis://redis-changemaker:6379
|
||||
- REDIS_PASSWORD=${REDIS_PASSWORD}
|
||||
@ -1193,10 +1201,10 @@ services:
|
||||
- monitoring
|
||||
|
||||
alertmanager:
|
||||
image: prom/alertmanager:latest
|
||||
image: prom/alertmanager:v0.31.1
|
||||
container_name: alertmanager-changemaker
|
||||
ports:
|
||||
- "${ALERTMANAGER_PORT:-9093}:9093"
|
||||
- "127.0.0.1:${ALERTMANAGER_PORT:-9093}:9093"
|
||||
volumes:
|
||||
- ./configs/alertmanager:/etc/alertmanager
|
||||
- alertmanager-data:/alertmanager
|
||||
@ -1210,10 +1218,10 @@ services:
|
||||
- monitoring
|
||||
|
||||
gotify:
|
||||
image: gotify/server:latest
|
||||
image: gotify/server:v2.9.0
|
||||
container_name: gotify-changemaker
|
||||
ports:
|
||||
- "${GOTIFY_PORT:-8889}:80"
|
||||
- "127.0.0.1:${GOTIFY_PORT:-8889}:80"
|
||||
environment:
|
||||
- GOTIFY_DEFAULTUSER_NAME=${GOTIFY_ADMIN_USER:-admin}
|
||||
- GOTIFY_DEFAULTUSER_PASS=${GOTIFY_ADMIN_PASSWORD:?GOTIFY_ADMIN_PASSWORD must be set in .env}
|
||||
|
||||
@ -10,7 +10,14 @@ http {
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
|
||||
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
|
||||
# Redact sensitive query parameters (token, secret) from access logs
|
||||
map $request_uri $redacted_request {
|
||||
~^(?P<path>[^?]*)\?(?P<args>.*token=[^&]*) "$path?<token-redacted>";
|
||||
~^(?P<path>[^?]*)\?(?P<args>.*secret=[^&]*) "$path?<secret-redacted>";
|
||||
default $request_uri;
|
||||
}
|
||||
|
||||
log_format main '$remote_addr - $remote_user [$time_local] "$request_method $redacted_request $server_protocol" '
|
||||
'$status $body_bytes_sent "$http_referer" '
|
||||
'"$http_user_agent" "$http_x_forwarded_for"';
|
||||
|
||||
@ -32,11 +39,17 @@ http {
|
||||
gzip_comp_level 6;
|
||||
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript image/svg+xml;
|
||||
|
||||
# Only send HSTS when the request arrived over HTTPS (via Pangolin tunnel)
|
||||
map $http_x_forwarded_proto $hsts_header {
|
||||
https "max-age=31536000; includeSubDomains";
|
||||
default "";
|
||||
}
|
||||
|
||||
# Security headers (applied globally — X-Frame-Options set per server block)
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||
add_header Strict-Transport-Security $hsts_header always;
|
||||
add_header Permissions-Policy "geolocation=(self), microphone=(), camera=()" always;
|
||||
|
||||
# Docker internal DNS — enables runtime resolution so nginx starts
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user