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:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user