diff --git a/.env.example b/.env.example
index f772f687..a47e2331 100644
--- a/.env.example
+++ b/.env.example
@@ -344,6 +344,9 @@ ENABLE_CHAT=false
ROCKETCHAT_ADMIN_USER=rcadmin
ROCKETCHAT_ADMIN_PASSWORD=REQUIRED_STRONG_PASSWORD_CHANGE_THIS
ROCKETCHAT_URL=http://rocketchat-changemaker:3000
+# MongoDB credentials for Rocket.Chat (required — MongoDB runs with --auth)
+MONGO_ROOT_USER=rocketchat
+MONGO_ROOT_PASSWORD=REQUIRED_STRONG_PASSWORD_CHANGE_THIS
# --- Gancio (Event Management) ---
# Uses shared PostgreSQL (database: gancio, auto-created by init-gancio-db.sh)
diff --git a/admin/src/components/chat/ChatPanel.tsx b/admin/src/components/chat/ChatPanel.tsx
index 58c44cbb..529cf35f 100644
--- a/admin/src/components/chat/ChatPanel.tsx
+++ b/admin/src/components/chat/ChatPanel.tsx
@@ -48,10 +48,11 @@ export default function ChatPanel({ panel, leftOffset }: Props) {
if (!rcAuthToken || !iframeRef.current?.contentWindow) return;
const sendToken = () => {
- if (!iframeRef.current?.contentWindow) return;
+ if (!iframeRef.current?.contentWindow || !rcServiceUrl) return;
+ const targetOrigin = new URL(rcServiceUrl).origin;
iframeRef.current.contentWindow.postMessage(
{ event: 'login-with-token', loginToken: rcAuthToken },
- '*',
+ targetOrigin,
);
};
diff --git a/admin/src/pages/LoginPage.tsx b/admin/src/pages/LoginPage.tsx
index 7c441be8..e52cec23 100644
--- a/admin/src/pages/LoginPage.tsx
+++ b/admin/src/pages/LoginPage.tsx
@@ -30,7 +30,9 @@ export default function LoginPage() {
const [forgotSent, setForgotSent] = useState(false);
const [resendLoading, setResendLoading] = useState(false);
- const redirectTo = searchParams.get('redirect');
+ const rawRedirect = searchParams.get('redirect');
+ // Validate redirect is a safe relative path (prevents open redirect attacks)
+ const redirectTo = rawRedirect && rawRedirect.startsWith('/') && !rawRedirect.startsWith('//') ? rawRedirect : null;
const refCode = searchParams.get('ref') || '';
const showRegister = settings?.enablePublicRegistration !== false;
diff --git a/admin/src/pages/RocketChatPage.tsx b/admin/src/pages/RocketChatPage.tsx
index 1b940b8d..306e7caa 100644
--- a/admin/src/pages/RocketChatPage.tsx
+++ b/admin/src/pages/RocketChatPage.tsx
@@ -78,10 +78,11 @@ export default function RocketChatPage() {
if (!authToken || !iframeRef.current?.contentWindow) return;
const sendToken = () => {
- if (!iframeRef.current?.contentWindow) return;
+ if (!iframeRef.current?.contentWindow || !serviceUrl) return;
+ const targetOrigin = new URL(serviceUrl).origin;
iframeRef.current.contentWindow.postMessage(
{ event: 'login-with-token', loginToken: authToken },
- '*',
+ targetOrigin,
);
};
diff --git a/admin/src/pages/public/TicketedEventDetailPage.tsx b/admin/src/pages/public/TicketedEventDetailPage.tsx
index ad5f11a7..9de3e50e 100644
--- a/admin/src/pages/public/TicketedEventDetailPage.tsx
+++ b/admin/src/pages/public/TicketedEventDetailPage.tsx
@@ -9,6 +9,7 @@ import {
} from '@ant-design/icons';
import axios from 'axios';
import dayjs from 'dayjs';
+import DOMPurify from 'dompurify';
import { useParams, useNavigate, useSearchParams } from 'react-router-dom';
import { useAuthStore } from '@/stores/auth.store';
@@ -262,7 +263,7 @@ export default function TicketedEventDetailPage() {
{/* Description */}
{event.richDescription ? (
-
+
) : event.description ? (
diff --git a/admin/src/pages/volunteer/VolunteerChatPage.tsx b/admin/src/pages/volunteer/VolunteerChatPage.tsx
index 8b3d14d1..886d6fd1 100644
--- a/admin/src/pages/volunteer/VolunteerChatPage.tsx
+++ b/admin/src/pages/volunteer/VolunteerChatPage.tsx
@@ -68,10 +68,18 @@ export default function VolunteerChatPage() {
const sendToken = () => {
if (!iframeRef.current?.contentWindow) return;
- iframeRef.current.contentWindow.postMessage(
- { event: 'login-with-token', loginToken: authToken },
- '*',
- );
+ // Derive target origin from the Rocket.Chat service URL for security
+ try {
+ if (!rcConfig?.subdomain || !rcConfig?.domain || !rcConfig?.embedPort) return;
+ const rcUrl = buildServiceUrl(rcConfig.subdomain, rcConfig.domain, rcConfig.embedPort);
+ const targetOrigin = rcUrl ? new URL(rcUrl).origin : '*';
+ iframeRef.current.contentWindow.postMessage(
+ { event: 'login-with-token', loginToken: authToken },
+ targetOrigin,
+ );
+ } catch {
+ // Fallback: don't send if we can't determine origin
+ }
};
sendToken();
diff --git a/api/src/config/env.ts b/api/src/config/env.ts
index dd75c795..ac766180 100644
--- a/api/src/config/env.ts
+++ b/api/src/config/env.ts
@@ -35,8 +35,8 @@ const envSchema = z.object({
JWT_ACCESS_EXPIRY: z.string().default('15m'),
JWT_REFRESH_EXPIRY: z.string().default('7d'),
- // Encryption (for DB-stored secrets like SMTP password; falls back to JWT_ACCESS_SECRET)
- ENCRYPTION_KEY: z.string().min(32, 'ENCRYPTION_KEY must be at least 32 characters').optional(),
+ // Encryption (for DB-stored secrets like SMTP password — required for all environments)
+ ENCRYPTION_KEY: z.string().min(32, 'ENCRYPTION_KEY must be at least 32 characters'),
// Initial Super Admin (auto-created during database seeding)
INITIAL_ADMIN_EMAIL: z.string().email().default('admin@cmlite.org'),
diff --git a/api/src/modules/auth/auth.routes.ts b/api/src/modules/auth/auth.routes.ts
index 9db9ca31..80e37efa 100644
--- a/api/src/modules/auth/auth.routes.ts
+++ b/api/src/modules/auth/auth.routes.ts
@@ -186,32 +186,45 @@ router.post(
async (req: Request, res: Response, next: NextFunction) => {
try {
const { token, password } = req.body;
- const result = await passwordResetTokenService.validateToken(token);
-
- if (!result.valid || !result.userId) {
- res.status(400).json({
- error: { message: result.error || 'Invalid token', code: 'INVALID_TOKEN' },
- });
- return;
- }
+ // Hash password BEFORE token validation to minimize race window
const hashedPassword = await bcrypt.hash(password, 12);
- // Update password, mark token used, invalidate all refresh tokens — all in one transaction
+ // Atomic: validate + consume token + reset password in one transaction
await prisma.$transaction(async (tx) => {
- await tx.user.update({
- where: { id: result.userId },
- data: { password: hashedPassword },
- });
- await tx.refreshToken.deleteMany({ where: { userId: result.userId } });
- await tx.passwordResetToken.update({
- where: { token },
+ // Atomically mark token as used (only if valid and not already used)
+ const updated = await tx.passwordResetToken.updateMany({
+ where: {
+ token,
+ usedAt: null,
+ expiresAt: { gt: new Date() },
+ },
data: { usedAt: new Date() },
});
+
+ if (updated.count === 0) {
+ throw new Error('INVALID_TOKEN');
+ }
+
+ // Get the userId from the consumed token
+ const record = await tx.passwordResetToken.findUnique({ where: { token } });
+ if (!record) throw new Error('INVALID_TOKEN');
+
+ await tx.user.update({
+ where: { id: record.userId },
+ data: { password: hashedPassword },
+ });
+ await tx.refreshToken.deleteMany({ where: { userId: record.userId } });
});
res.json({ message: 'Password has been reset. You can now log in with your new password.' });
} catch (err) {
+ if (err instanceof Error && err.message === 'INVALID_TOKEN') {
+ res.status(400).json({
+ error: { message: 'Invalid or expired reset token', code: 'INVALID_TOKEN' },
+ });
+ return;
+ }
next(err);
}
}
diff --git a/api/src/modules/influence/effectiveness/effectiveness.service.ts b/api/src/modules/influence/effectiveness/effectiveness.service.ts
index c5f9282a..f13d8486 100644
--- a/api/src/modules/influence/effectiveness/effectiveness.service.ts
+++ b/api/src/modules/influence/effectiveness/effectiveness.service.ts
@@ -403,7 +403,12 @@ export const effectivenessService = {
const from = dateFilter?.gte || defaultFrom;
const to = dateFilter?.lte || new Date();
- const truncFnSql = Prisma.raw(`'${truncFn}'`);
+ // Use a lookup map instead of Prisma.raw() to prevent SQL injection if enum changes
+ const truncFnMap: Record> = {
+ day: Prisma.sql`'day'`,
+ week: Prisma.sql`'week'`,
+ };
+ const truncFnSql = truncFnMap[truncFn] || truncFnMap.day;
const campaignFilter = query.campaignId
? Prisma.sql`AND "campaignId" = ${query.campaignId}`
: Prisma.sql``;
diff --git a/api/src/modules/map/canvass/canvass.service.ts b/api/src/modules/map/canvass/canvass.service.ts
index b3c3c017..851f0d89 100644
--- a/api/src/modules/map/canvass/canvass.service.ts
+++ b/api/src/modules/map/canvass/canvass.service.ts
@@ -96,6 +96,15 @@ async function annotateAddressesWithVisits(
const ADMIN_ADDRESS_FIELDS = ['firstName', 'lastName', 'unitNumber', 'email', 'phone'] as const;
const VOLUNTEER_ADDRESS_FIELDS = ['supportLevel', 'sign', 'signSize', 'notes'] as const;
+/** Strip PII fields from address data for non-admin volunteer access */
+function stripAddressPii(addresses: any[]): any[] {
+ return addresses.map(({ firstName, lastName, email, phone, ...safe }) => ({
+ ...safe,
+ // Volunteers see support data but not personal contact info
+ hasContactInfo: !!(firstName || lastName || email || phone),
+ }));
+}
+
export const canvassService = {
// ─── Volunteer Methods ─────────────────────────────────────────────
@@ -432,7 +441,8 @@ export const canvassService = {
const durationSeconds = (Date.now() - startTime) / 1000;
recordLocationQuery('canvass_cut', !!bounds, result.length, durationSeconds);
- return result;
+ // Strip PII for volunteer access (firstName, lastName, email, phone)
+ return stripAddressPii(result);
},
async getAllLocationsForCanvass(
@@ -521,7 +531,8 @@ export const canvassService = {
const durationSeconds = (Date.now() - startTime) / 1000;
recordLocationQuery('canvass_all', !!bounds, result.length, durationSeconds);
- return result;
+ // Strip PII for volunteer access (firstName, lastName, email, phone)
+ return stripAddressPii(result);
},
async updateAddressAsVolunteer(
diff --git a/api/src/modules/map/shifts/shifts.service.ts b/api/src/modules/map/shifts/shifts.service.ts
index 2262308d..53fe207e 100644
--- a/api/src/modules/map/shifts/shifts.service.ts
+++ b/api/src/modules/map/shifts/shifts.service.ts
@@ -26,14 +26,9 @@ import type {
PublicSignupInput,
} from './shifts.schemas';
-const adjectives = ['Blue', 'Red', 'Green', 'Swift', 'Bright', 'Bold', 'Calm', 'Fair'];
-const nouns = ['Eagle', 'River', 'Mountain', 'Star', 'Forest', 'Lake', 'Wolf', 'Hawk'];
-
function generateReadablePassword(): string {
- const adj = adjectives[Math.floor(Math.random() * adjectives.length)];
- const noun = nouns[Math.floor(Math.random() * nouns.length)];
- const num = Math.floor(Math.random() * 90) + 10;
- return `${adj}${noun}${num}`;
+ // Generate a cryptographically strong random password (128 bits of entropy)
+ return crypto.randomBytes(16).toString('base64url');
}
const meetingSelect = {
@@ -427,6 +422,7 @@ export const shiftsService = {
throw new AppError(400, 'This shift has already passed', 'SHIFT_PAST');
}
+ // Pre-check capacity (definitive check is inside transaction below)
if (shift.currentVolunteers >= shift.maxVolunteers) {
throw new AppError(400, 'Shift is full', 'SHIFT_FULL');
}
@@ -468,50 +464,50 @@ export const shiftsService = {
isNewUser = true;
}
- // Create signup (or re-activate cancelled one)
- let signup;
- if (existingSignup && existingSignup.status === SignupStatus.CANCELLED) {
- [signup] = await prisma.$transaction([
- prisma.shiftSignup.update({
+ // Atomic signup + capacity check inside transaction to prevent TOCTOU race
+ const signup = await prisma.$transaction(async (tx) => {
+ // Re-check capacity atomically inside the transaction
+ const currentShift = await tx.shift.findUnique({ where: { id: shiftId } });
+ if (!currentShift || currentShift.currentVolunteers >= currentShift.maxVolunteers) {
+ throw new AppError(400, 'Shift is full', 'SHIFT_FULL');
+ }
+
+ let created;
+ if (existingSignup && existingSignup.status === SignupStatus.CANCELLED) {
+ created = await tx.shiftSignup.update({
where: { id: existingSignup.id },
data: {
status: SignupStatus.CONFIRMED,
signupSource: user ? SignupSource.AUTHENTICATED : SignupSource.PUBLIC,
userName: data.name,
userPhone: data.phone,
- userId: user.id,
+ userId: user!.id,
},
- }),
- prisma.shift.update({
- where: { id: shiftId },
- data: {
- currentVolunteers: { increment: 1 },
- status: shift.currentVolunteers + 1 >= shift.maxVolunteers ? ShiftStatus.FULL : undefined,
- },
- }),
- ]);
- } else {
- [signup] = await prisma.$transaction([
- prisma.shiftSignup.create({
+ });
+ } else {
+ created = await tx.shiftSignup.create({
data: {
shiftId,
- shiftTitle: shift.title,
- userId: user.id,
+ shiftTitle: currentShift.title,
+ userId: user!.id,
userEmail: data.email,
userName: data.name,
userPhone: data.phone,
signupSource: isNewUser ? SignupSource.PUBLIC : SignupSource.AUTHENTICATED,
},
- }),
- prisma.shift.update({
- where: { id: shiftId },
- data: {
- currentVolunteers: { increment: 1 },
- status: shift.currentVolunteers + 1 >= shift.maxVolunteers ? ShiftStatus.FULL : undefined,
- },
- }),
- ]);
- }
+ });
+ }
+
+ await tx.shift.update({
+ where: { id: shiftId },
+ data: {
+ currentVolunteers: { increment: 1 },
+ status: currentShift.currentVolunteers + 1 >= currentShift.maxVolunteers ? ShiftStatus.FULL : undefined,
+ },
+ });
+
+ return created;
+ });
// Send confirmation email
try {
diff --git a/api/src/modules/media/routes/public.routes.ts b/api/src/modules/media/routes/public.routes.ts
index 91031be5..e40706dc 100644
--- a/api/src/modules/media/routes/public.routes.ts
+++ b/api/src/modules/media/routes/public.routes.ts
@@ -1,7 +1,7 @@
import { FastifyInstance } from 'fastify';
import { createReadStream, stat } from 'fs';
import { access } from 'fs/promises';
-import { join } from 'path';
+import { join, resolve } from 'path';
import { lookup } from 'mime-types';
import { prisma } from '../../../config/database';
import { optionalAuth } from '../middleware/auth';
@@ -204,14 +204,13 @@ export async function publicRoutes(fastify: FastifyInstance) {
return reply.code(404).send({ message: 'Thumbnail not found' });
}
- // Validate path doesn't contain traversal attempts
- if (video.thumbnailPath.includes('..')) {
- logger.warn(`Path traversal attempt detected: ${video.thumbnailPath}`);
+ // Validate path is within allowed media directory
+ const thumbnailPath = resolve(video.thumbnailPath);
+ if (!thumbnailPath.startsWith(resolve('/media/local'))) {
+ logger.warn(`Path traversal attempt detected: ${thumbnailPath}`);
return reply.code(403).send({ message: 'Access denied' });
}
- const thumbnailPath = video.thumbnailPath;
-
// Check file exists
try {
await access(thumbnailPath);
@@ -304,16 +303,15 @@ export async function publicRoutes(fastify: FastifyInstance) {
}
}
- // Validate path doesn't contain traversal attempts
- if (video.path.includes('..') || video.filename.includes('..')) {
- logger.warn(`Path traversal attempt detected: ${video.path}/${video.filename}`);
- return reply.code(403).send({ message: 'Access denied' });
- }
-
- // Construct full file path
- const filePath = video.path.endsWith(video.filename)
+ // Validate path is within allowed media directory
+ const candidatePath = video.path.endsWith(video.filename)
? video.path
: join(video.path, video.filename);
+ const filePath = resolve(candidatePath);
+ if (!filePath.startsWith(resolve('/media/local'))) {
+ logger.warn(`Path traversal attempt detected: ${filePath}`);
+ return reply.code(403).send({ message: 'Access denied' });
+ }
// Check file exists
try {
diff --git a/api/src/modules/media/routes/video-actions.routes.ts b/api/src/modules/media/routes/video-actions.routes.ts
index 73e0cd81..1e75bd2f 100644
--- a/api/src/modules/media/routes/video-actions.routes.ts
+++ b/api/src/modules/media/routes/video-actions.routes.ts
@@ -6,7 +6,7 @@ import { logger } from '../../../utils/logger';
import { sign } from 'jsonwebtoken';
import { env } from '../../../config/env';
import { copyFile } from 'fs/promises';
-import { join, dirname, basename, extname, normalize } from 'path';
+import { join, dirname, basename, extname, normalize, resolve } from 'path';
import { z } from 'zod';
const UpdateVideoSchema = z.object({
@@ -176,13 +176,14 @@ export async function videoActionsRoutes(fastify: FastifyInstance) {
}
const { newPath, newFilename, durationSeconds, width, height, fileSize } = parseResult.data;
- // Path traversal protection
+ // Path traversal protection: resolve against allowed base and verify containment
+ const MEDIA_BASE = '/media/local';
if (newPath.includes('\0') || newFilename.includes('\0')) {
return reply.code(400).send({ message: 'Invalid file path' });
}
- const normalizedPath = normalize(newPath);
- if (normalizedPath.includes('..') || normalizedPath.startsWith('/') || normalizedPath.startsWith('\\')) {
- return reply.code(400).send({ message: 'Invalid file path: must be relative with no traversal' });
+ const resolvedPath = resolve(MEDIA_BASE, newPath);
+ if (!resolvedPath.startsWith(resolve(MEDIA_BASE))) {
+ return reply.code(400).send({ message: 'Invalid file path: must be within media directory' });
}
const sanitizedFilename = basename(newFilename);
@@ -199,7 +200,7 @@ export async function videoActionsRoutes(fastify: FastifyInstance) {
const updatedVideo = await prisma.video.update({
where: { id: videoId },
data: {
- path: normalizedPath,
+ path: resolvedPath,
filename: sanitizedFilename,
originalPath: existingVideo.path, // Save old path for reference
originalFilename: existingVideo.filename,
@@ -245,11 +246,18 @@ export async function videoActionsRoutes(fastify: FastifyInstance) {
const videoId = parseInt(request.params.id);
const { startDate, endDate } = request.query;
+ // Validate date parameters
+ const parsedStart = startDate ? new Date(startDate) : undefined;
+ const parsedEnd = endDate ? new Date(endDate) : undefined;
+ if ((parsedStart && isNaN(parsedStart.getTime())) || (parsedEnd && isNaN(parsedEnd.getTime()))) {
+ return reply.code(400).send({ message: 'Invalid date format for startDate or endDate' });
+ }
+
try {
const analytics = await videoAnalyticsService.getVideoAnalytics(
videoId,
- startDate ? new Date(startDate) : undefined,
- endDate ? new Date(endDate) : undefined
+ parsedStart,
+ parsedEnd,
);
return analytics;
diff --git a/api/src/modules/media/routes/video-streaming.routes.ts b/api/src/modules/media/routes/video-streaming.routes.ts
index 44b96d9a..cc2cfd3a 100644
--- a/api/src/modules/media/routes/video-streaming.routes.ts
+++ b/api/src/modules/media/routes/video-streaming.routes.ts
@@ -1,7 +1,7 @@
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
import { createReadStream, stat } from 'fs';
import { access, readFile } from 'fs/promises';
-import { join } from 'path';
+import { join, resolve } from 'path';
import { lookup } from 'mime-types';
import jwt from 'jsonwebtoken';
import { UserRole, UserStatus } from '@prisma/client';
@@ -110,17 +110,16 @@ export async function videoStreamingRoutes(fastify: FastifyInstance) {
return reply.code(404).send({ message: 'Video not found or not published' });
}
- // Security: Validate path doesn't contain traversal attempts
- if (video.path.includes('..') || video.filename.includes('..')) {
- logger.warn(`Path traversal attempt detected: ${video.path}/${video.filename}`);
- return reply.code(403).send({ message: 'Access denied' });
- }
-
- // Construct full file path
- // Handle both new format (full path) and legacy format (directory only)
- const filePath = video.path.endsWith(video.filename)
+ // Security: Validate path is within allowed media directory
+ const MEDIA_BASE = '/media/local';
+ const candidatePath = video.path.endsWith(video.filename)
? video.path
: join(video.path, video.filename);
+ const filePath = resolve(candidatePath);
+ if (!filePath.startsWith(resolve(MEDIA_BASE))) {
+ logger.warn(`Path traversal attempt detected: ${filePath}`);
+ return reply.code(403).send({ message: 'Access denied' });
+ }
// Check file exists
try {
@@ -216,22 +215,23 @@ export async function videoStreamingRoutes(fastify: FastifyInstance) {
return reply.code(404).send({ message: 'Thumbnail not found' });
}
- // Security: Validate path
- if (video.thumbnailPath.includes('..')) {
- logger.warn(`Path traversal attempt detected: ${video.thumbnailPath}`);
+ // Security: Validate path is within allowed media directory
+ const resolvedThumb = resolve(video.thumbnailPath);
+ if (!resolvedThumb.startsWith(resolve('/media/local'))) {
+ logger.warn(`Path traversal attempt detected: ${resolvedThumb}`);
return reply.code(403).send({ message: 'Access denied' });
}
// Check file exists
try {
- await access(video.thumbnailPath);
+ await access(resolvedThumb);
} catch {
- logger.error(`Thumbnail file not found on disk: ${video.thumbnailPath}`);
+ logger.error(`Thumbnail file not found on disk: ${resolvedThumb}`);
return reply.code(404).send({ message: 'Thumbnail file not found' });
}
// Determine MIME type
- const mimeType = lookup(video.thumbnailPath) || 'image/jpeg';
+ const mimeType = lookup(resolvedThumb) || 'image/jpeg';
// Read and send thumbnail
const thumbnailBuffer = await readFile(video.thumbnailPath);
diff --git a/api/src/modules/media/routes/videos.routes.ts b/api/src/modules/media/routes/videos.routes.ts
index 095598df..eeed5b0f 100644
--- a/api/src/modules/media/routes/videos.routes.ts
+++ b/api/src/modules/media/routes/videos.routes.ts
@@ -210,7 +210,7 @@ export async function videosRoutes(fastify: FastifyInstance) {
return { success: true, video };
} catch (error: any) {
logger.error(`Error publishing video ${videoId}:`, error);
- return reply.code(500).send({ message: 'Failed to publish video', error: error.message });
+ return reply.code(500).send({ message: 'Failed to publish video' });
}
}
);
@@ -236,7 +236,7 @@ export async function videosRoutes(fastify: FastifyInstance) {
return { success: true, video };
} catch (error: any) {
logger.error(`Error unpublishing video ${videoId}:`, error);
- return reply.code(500).send({ message: 'Failed to unpublish video', error: error.message });
+ return reply.code(500).send({ message: 'Failed to unpublish video' });
}
}
);
@@ -268,7 +268,7 @@ export async function videosRoutes(fastify: FastifyInstance) {
return { success: true, count: result.count };
} catch (error: any) {
logger.error(`Error bulk publishing videos:`, error);
- return reply.code(500).send({ message: 'Failed to publish videos', error: error.message });
+ return reply.code(500).send({ message: 'Failed to publish videos' });
}
}
);
@@ -299,7 +299,7 @@ export async function videosRoutes(fastify: FastifyInstance) {
return { success: true, count: result.count };
} catch (error: any) {
logger.error(`Error bulk unpublishing videos:`, error);
- return reply.code(500).send({ message: 'Failed to unpublish videos', error: error.message });
+ return reply.code(500).send({ message: 'Failed to unpublish videos' });
}
}
);
@@ -326,7 +326,7 @@ export async function videosRoutes(fastify: FastifyInstance) {
return { success: true, video };
} catch (error: any) {
logger.error(`Error locking video ${videoId}:`, error);
- return reply.code(500).send({ message: 'Failed to lock video', error: error.message });
+ return reply.code(500).send({ message: 'Failed to lock video' });
}
}
);
@@ -352,7 +352,7 @@ export async function videosRoutes(fastify: FastifyInstance) {
return { success: true, video };
} catch (error: any) {
logger.error(`Error unlocking video ${videoId}:`, error);
- return reply.code(500).send({ message: 'Failed to unlock video', error: error.message });
+ return reply.code(500).send({ message: 'Failed to unlock video' });
}
}
);
@@ -402,7 +402,7 @@ export async function videosRoutes(fastify: FastifyInstance) {
return { success: true, video: updatedVideo };
} catch (error: any) {
logger.error(`Error generating thumbnail for video ${videoId}:`, error);
- return reply.code(500).send({ message: 'Failed to generate thumbnail', error: error.message });
+ return reply.code(500).send({ message: 'Failed to generate thumbnail' });
}
}
);
@@ -473,7 +473,7 @@ export async function videosRoutes(fastify: FastifyInstance) {
};
} catch (error: any) {
logger.error('Error bulk generating thumbnails:', error);
- return reply.code(500).send({ message: 'Failed to bulk generate thumbnails', error: error.message });
+ return reply.code(500).send({ message: 'Failed to bulk generate thumbnails' });
}
}
);
diff --git a/api/src/modules/pages/pages.service.ts b/api/src/modules/pages/pages.service.ts
index ae8a569b..be9eb14f 100644
--- a/api/src/modules/pages/pages.service.ts
+++ b/api/src/modules/pages/pages.service.ts
@@ -103,10 +103,13 @@ function validateStubPath(stubPath: string): void {
function wrapInMaterialOverride(html: string, css: string | null): string {
const styleBlock = css ? `` : '';
+ // Wrap dynamic content in {% raw %} to prevent Jinja2 SSTI via admin-authored HTML/CSS
return `{% extends "main.html" %}
{% block content %}
+{% raw %}
${styleBlock}
${html}
+{% endraw %}
{% endblock %}
`;
}
diff --git a/api/src/modules/payments/donations.service.ts b/api/src/modules/payments/donations.service.ts
index fd1095f6..b269d417 100644
--- a/api/src/modules/payments/donations.service.ts
+++ b/api/src/modules/payments/donations.service.ts
@@ -5,6 +5,16 @@ import { paymentSettingsService } from './payment-settings.service';
import { stringify } from 'csv-stringify/sync';
import { logger } from '../../utils/logger';
+/** Sanitize a string to prevent CSV formula injection */
+function sanitizeCsvValue(value: string): string {
+ if (!value) return value;
+ const dangerous = ['=', '+', '-', '@', '\t', '\r'];
+ if (dangerous.some(c => value.startsWith(c))) {
+ return `'${value}`;
+ }
+ return value;
+}
+
export const donationsService = {
/** Create a Stripe Checkout session for a donation */
async createDonationCheckout(
@@ -170,12 +180,12 @@ export const donationsService = {
return stringify(orders.map((o) => ({
'Date': o.createdAt.toISOString(),
- 'Donor Name': o.isAnonymous ? 'Anonymous' : (o.buyerName || ''),
- 'Donor Email': o.isAnonymous ? '' : (o.buyerEmail || ''),
+ 'Donor Name': sanitizeCsvValue(o.isAnonymous ? 'Anonymous' : (o.buyerName || '')),
+ 'Donor Email': sanitizeCsvValue(o.isAnonymous ? '' : (o.buyerEmail || '')),
'Amount (CAD)': (o.amountCAD / 100).toFixed(2),
'Status': o.status,
- 'Donation Page': o.donationPage?.title || 'General',
- 'Message': o.donorMessage || '',
+ 'Donation Page': sanitizeCsvValue(o.donationPage?.title || 'General'),
+ 'Message': sanitizeCsvValue(o.donorMessage || ''),
'Anonymous': o.isAnonymous ? 'Yes' : 'No',
'Stripe Payment Intent': o.stripePaymentIntentId || '',
'Stripe Checkout Session': o.stripeCheckoutSessionId || '',
diff --git a/api/src/modules/payments/payments-admin.routes.ts b/api/src/modules/payments/payments-admin.routes.ts
index d263b033..0f32f24d 100644
--- a/api/src/modules/payments/payments-admin.routes.ts
+++ b/api/src/modules/payments/payments-admin.routes.ts
@@ -353,6 +353,17 @@ router.post(
// =================== CSV Export ===================
+/** Sanitize a CSV field to prevent formula injection (=, +, -, @, \t, \r) */
+function sanitizeCsvField(value: string): string {
+ if (!value) return '';
+ const dangerous = ['=', '+', '-', '@', '\t', '\r'];
+ let sanitized = value.replace(/"/g, '""');
+ if (dangerous.some(c => sanitized.startsWith(c))) {
+ sanitized = `'${sanitized}`;
+ }
+ return `"${sanitized}"`;
+}
+
// GET /api/payments/admin/export
router.get('/export', async (_req: Request, res: Response, next: NextFunction) => {
try {
@@ -369,9 +380,9 @@ router.get('/export', async (_req: Request, res: Response, next: NextFunction) =
o.createdAt.toISOString(),
o.type,
(o.amountCAD / 100).toFixed(2),
- `"${o.buyerEmail}"`,
- `"${o.buyerName || ''}"`,
- `"${o.product?.title || ''}"`,
+ sanitizeCsvField(o.buyerEmail || ''),
+ sanitizeCsvField(o.buyerName || ''),
+ sanitizeCsvField(o.product?.title || ''),
o.status,
].join(','));
}
diff --git a/api/src/modules/payments/subscriptions.service.ts b/api/src/modules/payments/subscriptions.service.ts
index 515dbd14..d83af969 100644
--- a/api/src/modules/payments/subscriptions.service.ts
+++ b/api/src/modules/payments/subscriptions.service.ts
@@ -5,6 +5,16 @@ import { logger } from '../../utils/logger';
import type { SubscriptionStatus, Prisma } from '@prisma/client';
import { stringify } from 'csv-stringify/sync';
+/** Sanitize a string to prevent CSV formula injection */
+function sanitizeCsvValue(value: string): string {
+ if (!value) return value;
+ const dangerous = ['=', '+', '-', '@', '\t', '\r'];
+ if (dangerous.some(c => value.startsWith(c))) {
+ return `'${value}`;
+ }
+ return value;
+}
+
export const subscriptionsService = {
/** Create a Stripe Checkout session for a subscription */
async createCheckoutSession(userId: string, planId: number, frequency: 'monthly' | 'yearly') {
@@ -240,9 +250,9 @@ export const subscriptionsService = {
});
return stringify(subscriptions.map((s) => ({
- 'User Name': s.user?.name || '',
- 'User Email': s.user?.email || '',
- 'Plan': s.plan?.name || '',
+ 'User Name': sanitizeCsvValue(s.user?.name || ''),
+ 'User Email': sanitizeCsvValue(s.user?.email || ''),
+ 'Plan': sanitizeCsvValue(s.plan?.name || ''),
'Price (CAD/mo)': s.plan ? (s.plan.priceCAD / 100).toFixed(2) : '',
'Status': s.status,
'Started': s.startDate.toISOString(),
diff --git a/api/src/modules/social/social.routes.ts b/api/src/modules/social/social.routes.ts
index b8808966..b5401e9b 100644
--- a/api/src/modules/social/social.routes.ts
+++ b/api/src/modules/social/social.routes.ts
@@ -24,9 +24,9 @@ import { challengeRouter } from './challenge.routes';
const router = Router();
// EventSource (SSE) doesn't support custom headers — accept token via query param
-// This runs before authenticate so the SSE connection can be established
+// Scoped to /sse path only to limit token-in-URL exposure to where it's truly needed
router.use((req, _res, next) => {
- if (req.query.token && !req.headers.authorization) {
+ if (req.query.token && !req.headers.authorization && req.path.startsWith('/sse')) {
req.headers.authorization = `Bearer ${req.query.token}`;
}
next();
diff --git a/api/src/modules/ticketed-events/checkin.routes.ts b/api/src/modules/ticketed-events/checkin.routes.ts
index 44b21acf..beb8cf54 100644
--- a/api/src/modules/ticketed-events/checkin.routes.ts
+++ b/api/src/modules/ticketed-events/checkin.routes.ts
@@ -1,14 +1,41 @@
import { Router, Request, Response, NextFunction } from 'express';
import { authenticate } from '../../middleware/auth.middleware';
+import { requireRole } from '../../middleware/rbac.middleware';
+import { requireNonTemp } from '../../middleware/rbac.middleware';
import { validate } from '../../middleware/validate';
import { ticketsService } from './tickets.service';
import { validateTokenSchema, confirmCheckinSchema, manualCheckinSchema } from './ticketed-events.schemas';
import { prisma } from '../../config/database';
+import { AppError } from '../../middleware/error-handler';
+import { EVENTS_ROLES } from '../../utils/roles';
+import { UserRole } from '@prisma/client';
const router = Router();
-// All check-in routes require authentication
-router.use(authenticate);
+/** Require EVENTS_ROLES or canCreateTicketedEvents permission for check-in */
+async function requireCheckinPermission(req: Request, _res: Response, next: NextFunction) {
+ if (!req.user) return next(new AppError(401, 'Authentication required', 'AUTH_REQUIRED'));
+
+ const userRoles = req.user.roles || [req.user.role];
+ if (userRoles.some(r => EVENTS_ROLES.includes(r as UserRole))) {
+ return next();
+ }
+
+ // Check user permissions
+ const user = await prisma.user.findUnique({
+ where: { id: req.user.id },
+ select: { permissions: true },
+ });
+ const perms = (user?.permissions as Record) || {};
+ if (perms.canCreateTicketedEvents) {
+ return next();
+ }
+
+ return next(new AppError(403, 'Insufficient permissions for check-in operations', 'FORBIDDEN'));
+}
+
+// All check-in routes require authentication + non-temp + events permission
+router.use(authenticate, requireNonTemp, requireCheckinPermission);
// POST /validate — validate QR token (preview without marking checked in)
router.post('/validate', validate(validateTokenSchema), async (req: Request, res: Response, next: NextFunction) => {
diff --git a/api/src/modules/ticketed-events/ticketed-events-admin.routes.ts b/api/src/modules/ticketed-events/ticketed-events-admin.routes.ts
index 1f878e0d..21f34e8f 100644
--- a/api/src/modules/ticketed-events/ticketed-events-admin.routes.ts
+++ b/api/src/modules/ticketed-events/ticketed-events-admin.routes.ts
@@ -169,7 +169,11 @@ router.post('/:id/tiers', validate(createTierSchema), async (req: Request, res:
// PUT /:id/tiers/:tierId
router.put('/:id/tiers/:tierId', validate(updateTierSchema), async (req: Request, res: Response, next: NextFunction) => {
try {
- const tier = await ticketedEventsService.updateTier(req.params.tierId as string, req.body);
+ const tier = await ticketedEventsService.updateTier(
+ req.params.tierId as string,
+ req.body,
+ req.params.id as string,
+ );
res.json(tier);
} catch (err) { next(err); }
});
@@ -177,7 +181,7 @@ router.put('/:id/tiers/:tierId', validate(updateTierSchema), async (req: Request
// DELETE /:id/tiers/:tierId
router.delete('/:id/tiers/:tierId', async (req: Request, res: Response, next: NextFunction) => {
try {
- await ticketedEventsService.deleteTier(req.params.tierId as string);
+ await ticketedEventsService.deleteTier(req.params.tierId as string, req.params.id as string);
res.json({ success: true });
} catch (err) { next(err); }
});
@@ -232,7 +236,7 @@ router.post('/:id/resend-ticket/:ticketId', async (req: Request, res: Response,
const crypto = await import('crypto');
const { env: envConfig } = await import('../../config/env');
const nonce = crypto.randomBytes(16);
- const hmac = crypto.createHmac('sha256', envConfig.ENCRYPTION_KEY || envConfig.JWT_ACCESS_SECRET);
+ const hmac = crypto.createHmac('sha256', envConfig.ENCRYPTION_KEY);
hmac.update(ticket.id);
hmac.update(nonce);
const token = Buffer.concat([
diff --git a/api/src/modules/ticketed-events/ticketed-events-public.routes.ts b/api/src/modules/ticketed-events/ticketed-events-public.routes.ts
index f015fcde..442babec 100644
--- a/api/src/modules/ticketed-events/ticketed-events-public.routes.ts
+++ b/api/src/modules/ticketed-events/ticketed-events-public.routes.ts
@@ -246,13 +246,41 @@ router.post('/:slug/register', optionalAuth, validate(registerFreeSchema), async
});
// GET /:slug/ticket/:ticketCode — ticket confirmation page data
-router.get('/:slug/ticket/:ticketCode', async (req: Request, res: Response, next: NextFunction) => {
+// Requires authentication or matching holder email to prevent PII enumeration
+router.get('/:slug/ticket/:ticketCode', optionalAuth, async (req: Request, res: Response, next: NextFunction) => {
try {
const ticket = await ticketsService.findByCode(req.params.ticketCode as string);
if (ticket.event.slug !== req.params.slug) {
throw new AppError(404, 'Ticket not found', 'NOT_FOUND');
}
- // Generate QR URL from ticket code (check-in scanner handles code-based lookup)
+
+ // Only return full details if user is authenticated and is the ticket holder or an admin
+ const isHolder = req.user && (
+ req.user.id === ticket.userId ||
+ req.user.email === ticket.holderEmail
+ );
+ const userRoles = req.user?.roles || (req.user?.role ? [req.user.role] : []);
+ const isAdmin = userRoles.some((r: string) => ['SUPER_ADMIN', 'EVENTS_ADMIN'].includes(r));
+
+ if (!isHolder && !isAdmin) {
+ // Return minimal non-PII data for unauthenticated/unrelated users
+ res.json({
+ ticketCode: ticket.ticketCode,
+ status: ticket.status,
+ event: {
+ slug: ticket.event.slug,
+ title: ticket.event.title,
+ date: ticket.event.date,
+ startTime: ticket.event.startTime,
+ endTime: ticket.event.endTime,
+ venueName: ticket.event.venueName,
+ },
+ tier: ticket.tier,
+ });
+ return;
+ }
+
+ // Full details for authenticated ticket holder or admin
const qrUrl = `${env.API_URL}/api/qr?text=${encodeURIComponent(ticket.ticketCode)}&size=300`;
res.json({ ...ticket, qrUrl });
} catch (err) { next(err); }
diff --git a/api/src/modules/ticketed-events/ticketed-events.service.ts b/api/src/modules/ticketed-events/ticketed-events.service.ts
index f706f0bb..fd0434ae 100644
--- a/api/src/modules/ticketed-events/ticketed-events.service.ts
+++ b/api/src/modules/ticketed-events/ticketed-events.service.ts
@@ -8,6 +8,7 @@ import { generateModeratorToken } from '../jitsi/jitsi.utils';
import { generateSlug as generateMeetingSlug } from '../../utils/slug';
import { env } from '../../config/env';
import crypto from 'crypto';
+import { EVENTS_ROLES } from '../../utils/roles';
function generateSlug(title: string): string {
return title
@@ -31,7 +32,10 @@ function generateInviteCode(): string {
return crypto.randomBytes(6).toString('hex').toUpperCase();
}
-const ADMIN_ROLES = ['SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMIN'];
+// Use EVENTS_ROLES for ownership bypass checks (imported from utils/roles)
+// Previously hardcoded as ['SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMIN'] which
+// missed EVENTS_ADMIN, causing a privilege inversion
+const ADMIN_ROLES = EVENTS_ROLES.map(r => r.toString());
/** Validate that enableMeet is on when format requires Jitsi */
async function validateMeetFormat(format: EventFormat | string) {
@@ -521,10 +525,15 @@ export const ticketedEventsService = {
});
},
- async updateTier(tierId: string, data: Record) {
+ async updateTier(tierId: string, data: Record, eventId?: string) {
const tier = await prisma.ticketTier.findUnique({ where: { id: tierId } });
if (!tier) throw new AppError(404, 'Tier not found', 'NOT_FOUND');
+ // Verify tier belongs to the specified event (prevents IDOR)
+ if (eventId && tier.eventId !== eventId) {
+ throw new AppError(403, 'Tier does not belong to this event', 'FORBIDDEN');
+ }
+
// Convert date strings
if (typeof data.salesStartAt === 'string') data.salesStartAt = new Date(data.salesStartAt as string);
if (typeof data.salesEndAt === 'string') data.salesEndAt = new Date(data.salesEndAt as string);
@@ -535,12 +544,18 @@ export const ticketedEventsService = {
});
},
- async deleteTier(tierId: string) {
+ async deleteTier(tierId: string, eventId?: string) {
const tier = await prisma.ticketTier.findUnique({
where: { id: tierId },
include: { _count: { select: { tickets: true } } },
});
if (!tier) throw new AppError(404, 'Tier not found', 'NOT_FOUND');
+
+ // Verify tier belongs to the specified event (prevents IDOR)
+ if (eventId && tier.eventId !== eventId) {
+ throw new AppError(403, 'Tier does not belong to this event', 'FORBIDDEN');
+ }
+
if (tier._count.tickets > 0) {
throw new AppError(400, 'Cannot delete a tier that has sold tickets', 'HAS_TICKETS');
}
diff --git a/api/src/modules/ticketed-events/tickets.service.ts b/api/src/modules/ticketed-events/tickets.service.ts
index 7517f1cd..41f14ad3 100644
--- a/api/src/modules/ticketed-events/tickets.service.ts
+++ b/api/src/modules/ticketed-events/tickets.service.ts
@@ -5,7 +5,7 @@ import { AppError } from '../../middleware/error-handler';
import { logger } from '../../utils/logger';
function getEncryptionKey(): string {
- return env.ENCRYPTION_KEY || env.JWT_ACCESS_SECRET;
+ return env.ENCRYPTION_KEY;
}
/** Generate a human-readable ticket code like "ABCD-1234" */
diff --git a/api/src/modules/users/users.routes.ts b/api/src/modules/users/users.routes.ts
index 358a5e5f..8aa9bee5 100644
--- a/api/src/modules/users/users.routes.ts
+++ b/api/src/modules/users/users.routes.ts
@@ -1,5 +1,6 @@
import { Router, Request, Response, NextFunction } from 'express';
import { z } from 'zod';
+import bcrypt from 'bcryptjs';
import { UserRole, UserStatus } from '@prisma/client';
import { usersService } from './users.service';
import { createUserSchema, updateUserSchema, listUsersSchema } from './users.schemas';
@@ -113,6 +114,28 @@ router.put(
delete req.body.permissions;
}
+ // Self-service password change requires current password verification
+ if (isSelf && !isAdminUser && req.body.password) {
+ if (!req.body.currentPassword) {
+ res.status(400).json({ error: { message: 'Current password is required to change your password', code: 'CURRENT_PASSWORD_REQUIRED' } });
+ return;
+ }
+ const currentUser = await prisma.user.findUnique({
+ where: { id },
+ select: { password: true },
+ });
+ if (!currentUser) {
+ res.status(404).json({ error: { message: 'User not found', code: 'NOT_FOUND' } });
+ return;
+ }
+ const valid = await bcrypt.compare(req.body.currentPassword, currentUser.password);
+ if (!valid) {
+ res.status(401).json({ error: { message: 'Current password is incorrect', code: 'INVALID_CREDENTIALS' } });
+ return;
+ }
+ delete req.body.currentPassword;
+ }
+
const parsed = updateUserSchema.parse(req.body);
const user = await usersService.update(id, parsed);
res.json(user);
diff --git a/api/src/server.ts b/api/src/server.ts
index 3da0d2dc..cb318265 100644
--- a/api/src/server.ts
+++ b/api/src/server.ts
@@ -250,6 +250,21 @@ app.get('/api/metrics', authenticate, requireRole('SUPER_ADMIN'), healthMetricsR
res.end(await register.metrics());
});
+// --- Internal Metrics Endpoint (for Prometheus scraping within Docker network) ---
+// Only accessible from Docker-internal network (nginx doesn't proxy this path externally)
+app.get('/api/metrics/internal', async (req, res) => {
+ // Basic network-level protection: only allow from Docker bridge / localhost
+ const remoteIp = req.ip || req.socket.remoteAddress || '';
+ const isInternal = remoteIp === '127.0.0.1' || remoteIp === '::1' ||
+ remoteIp.startsWith('172.') || remoteIp.startsWith('10.') || remoteIp.startsWith('192.168.');
+ if (!isInternal) {
+ res.status(403).json({ error: 'Internal endpoint only' });
+ return;
+ }
+ res.set('Content-Type', register.contentType);
+ res.end(await register.metrics());
+});
+
// --- API Routes ---
app.use('/api/auth', authRouter);
app.use('/api/users', usersRouter);
@@ -358,14 +373,7 @@ async function start() {
logger.info('Database connected');
// Initialize encryption for DB-stored secrets (SMTP password, etc.)
- // In production, require separate encryption key (not JWT secret reuse)
- if (env.NODE_ENV === 'production' && !env.ENCRYPTION_KEY) {
- throw new Error('ENCRYPTION_KEY must be set in production (do not reuse JWT_ACCESS_SECRET)');
- }
- if (!env.ENCRYPTION_KEY) {
- logger.warn('ENCRYPTION_KEY not set — falling back to JWT_ACCESS_SECRET for encryption. Set ENCRYPTION_KEY in production.');
- }
- initEncryption(env.ENCRYPTION_KEY || env.JWT_ACCESS_SECRET);
+ initEncryption(env.ENCRYPTION_KEY);
// Warn if Listmonk sync is enabled but webhook secret is not configured
if (env.LISTMONK_SYNC_ENABLED === 'true' && !env.LISTMONK_WEBHOOK_SECRET) {
diff --git a/api/src/services/listmonk.client.ts b/api/src/services/listmonk.client.ts
index 74b7a558..4c6ed38d 100644
--- a/api/src/services/listmonk.client.ts
+++ b/api/src/services/listmonk.client.ts
@@ -131,7 +131,9 @@ class ListmonkClient {
async findSubscriberByEmail(email: string): Promise {
this.assertEnabled();
try {
- const safeEmail = email.replace(/'/g, "''");
+ // Validate email format and sanitize for Listmonk query language
+ // Strip all characters except valid email chars to prevent query injection
+ const safeEmail = email.replace(/[^a-zA-Z0-9@._+\-]/g, '').replace(/'/g, "''");
const query = encodeURIComponent(`subscribers.email='${safeEmail}'`);
const res = await this.request<{ data: { results: ListmonkSubscriber[] } }>(
'GET',
diff --git a/api/src/services/password-reset-token.service.ts b/api/src/services/password-reset-token.service.ts
index 514b8ac7..3f767033 100644
--- a/api/src/services/password-reset-token.service.ts
+++ b/api/src/services/password-reset-token.service.ts
@@ -21,17 +21,20 @@ export const passwordResetTokenService = {
async validateToken(token: string): Promise<{ valid: boolean; userId?: string; error?: string }> {
const record = await prisma.passwordResetToken.findUnique({ where: { token } });
+ // Use a generic error message for all failure cases to prevent token state enumeration
+ const genericError = 'Invalid or expired reset token';
+
if (!record) {
- return { valid: false, error: 'Invalid or expired reset token' };
+ return { valid: false, error: genericError };
}
if (record.expiresAt < new Date()) {
await prisma.passwordResetToken.delete({ where: { id: record.id } });
- return { valid: false, error: 'Reset token has expired' };
+ return { valid: false, error: genericError };
}
if (record.usedAt) {
- return { valid: false, error: 'Reset token has already been used' };
+ return { valid: false, error: genericError };
}
return { valid: true, userId: record.userId };
diff --git a/configs/prometheus/prometheus.yml b/configs/prometheus/prometheus.yml
index b294c037..bb06e2ee 100644
--- a/configs/prometheus/prometheus.yml
+++ b/configs/prometheus/prometheus.yml
@@ -20,7 +20,7 @@ scrape_configs:
- job_name: 'changemaker-v2-api'
static_configs:
- targets: ['changemaker-v2-api:4000']
- metrics_path: '/api/metrics'
+ metrics_path: '/api/metrics/internal'
scrape_interval: 10s
scrape_timeout: 5s
diff --git a/docker-compose.yml b/docker-compose.yml
index 979221f0..9ec3a494 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -858,7 +858,7 @@ services:
condition: service_started
environment:
- ROOT_URL=http://chat.${DOMAIN:-cmlite.org}
- - MONGO_URL=mongodb://mongodb-rocketchat:27017/rocketchat?replicaSet=rs0
+ - MONGO_URL=mongodb://${MONGO_ROOT_USER:-rocketchat}:${MONGO_ROOT_PASSWORD}@mongodb-rocketchat:27017/rocketchat?replicaSet=rs0&authSource=admin
- MONGO_OPLOG_URL=mongodb://mongodb-rocketchat:27017/local?replicaSet=rs0
- TRANSPORTER=monolith+nats://nats-rocketchat:4222
- PORT=3000
@@ -908,14 +908,17 @@ services:
image: mongo:6.0
container_name: mongodb-rocketchat
restart: unless-stopped
- command: ["mongod", "--replSet", "rs0", "--bind_ip_all"]
+ command: ["mongod", "--replSet", "rs0", "--bind_ip_all", "--auth", "--keyFile", "/data/replica.key"]
+ environment:
+ MONGO_INITDB_ROOT_USERNAME: ${MONGO_ROOT_USER:-rocketchat}
+ MONGO_INITDB_ROOT_PASSWORD: ${MONGO_ROOT_PASSWORD:?MONGO_ROOT_PASSWORD must be set in .env}
volumes:
- mongodb-rocketchat-data:/data/db
logging: *default-logging
networks:
- changemaker-lite
healthcheck:
- test: ["CMD", "mongosh", "--quiet", "--eval", "try { rs.status().ok } catch(e) { rs.initiate({_id:'rs0',members:[{_id:0,host:'mongodb-rocketchat:27017'}]}).ok }"]
+ test: ["CMD", "mongosh", "-u", "${MONGO_ROOT_USER:-rocketchat}", "-p", "${MONGO_ROOT_PASSWORD}", "--authenticationDatabase", "admin", "--quiet", "--eval", "try { rs.status().ok } catch(e) { rs.initiate({_id:'rs0',members:[{_id:0,host:'mongodb-rocketchat:27017'}]}).ok }"]
interval: 10s
timeout: 10s
retries: 10
@@ -1205,8 +1208,7 @@ services:
- GF_USERS_ALLOW_SIGN_UP=false
- GF_SERVER_ROOT_URL=${GRAFANA_ROOT_URL:-http://localhost:3001}
- GF_SECURITY_ALLOW_EMBEDDING=true
- - GF_AUTH_ANONYMOUS_ENABLED=true
- - GF_AUTH_ANONYMOUS_ORG_ROLE=Viewer
+ - GF_AUTH_ANONYMOUS_ENABLED=false
volumes:
- grafana-data:/var/lib/grafana
- ./configs/grafana:/etc/grafana/provisioning
diff --git a/nginx/conf.d/api.conf b/nginx/conf.d/api.conf
new file mode 100644
index 00000000..1f3399e6
--- /dev/null
+++ b/nginx/conf.d/api.conf
@@ -0,0 +1,43 @@
+server {
+ listen 80;
+ server_name api.cmlite.org api.betteredmonton.org api.pridecorner.ca;
+ add_header X-Frame-Options "SAMEORIGIN" always;
+
+ # Media API endpoints (must come BEFORE / for longest prefix match)
+ location /media/ {
+ limit_req zone=api_global burst=60 nodelay;
+ set $upstream_media http://changemaker-media-api:4100/api/;
+ proxy_pass $upstream_media;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $remote_addr;
+ proxy_set_header X-Forwarded-Proto $scheme;
+
+ # Large upload support
+ client_max_body_size 10G;
+ proxy_read_timeout 3600s;
+ proxy_connect_timeout 75s;
+ proxy_request_buffering off;
+
+ # WebSocket support
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection "upgrade";
+ }
+
+ # Main API (Express) — includes WebSocket upgrade for docs collaboration
+ location / {
+ limit_req zone=api_global burst=60 nodelay;
+ set $upstream_api http://changemaker-v2-api:4000;
+ proxy_pass $upstream_api;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $remote_addr;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_read_timeout 300s;
+ proxy_connect_timeout 75s;
+
+ # WebSocket support (docs collaboration via Hocuspocus)
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection "upgrade";
+ }
+}
diff --git a/nginx/conf.d/services.conf b/nginx/conf.d/services.conf
new file mode 100644
index 00000000..61429c40
--- /dev/null
+++ b/nginx/conf.d/services.conf
@@ -0,0 +1,731 @@
+# Gitea — allows iframe embedding from admin (app.cmlite.org)
+server {
+ listen 80;
+ server_name git.cmlite.org;
+ add_header Content-Security-Policy "frame-ancestors 'self' app.cmlite.org" always;
+
+ # Increase max body size for large git pushes (2GB)
+ client_max_body_size 2048M;
+
+ location / {
+ set $upstream_gitea http://gitea-changemaker:3000;
+ proxy_pass $upstream_gitea;
+ proxy_hide_header X-Frame-Options;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ }
+}
+
+# n8n — allows iframe embedding from admin (app.cmlite.org)
+server {
+ listen 80;
+ server_name n8n.cmlite.org;
+ add_header Content-Security-Policy "frame-ancestors 'self' app.cmlite.org" always;
+
+ location / {
+ set $upstream_n8n http://n8n-changemaker:5678;
+ proxy_pass $upstream_n8n;
+ proxy_hide_header X-Frame-Options;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection "upgrade";
+ }
+}
+
+# Grafana — allows iframe embedding from admin (app.cmlite.org)
+server {
+ listen 80;
+ server_name grafana.cmlite.org grafana.betteredmonton.org grafana.pridecorner.ca;
+ add_header Content-Security-Policy "frame-ancestors 'self' app.cmlite.org app.betteredmonton.org" always;
+
+ location / {
+ set $upstream_grafana http://grafana-changemaker:3000;
+ proxy_pass $upstream_grafana;
+ proxy_hide_header X-Frame-Options;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection "upgrade";
+ }
+}
+
+# NocoDB (data browser) — allows iframe embedding from admin
+server {
+ listen 80;
+ server_name db.cmlite.org db.betteredmonton.org db.pridecorner.ca;
+ add_header Content-Security-Policy "frame-ancestors 'self' app.cmlite.org app.betteredmonton.org" always;
+
+ location / {
+ set $upstream_nocodb http://changemaker-v2-nocodb:8080;
+ proxy_pass $upstream_nocodb;
+ proxy_hide_header X-Frame-Options;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ }
+}
+
+# Listmonk — via auth proxy, allows iframe embedding from admin
+server {
+ listen 80;
+ server_name listmonk.cmlite.org listmonk.betteredmonton.org listmonk.pridecorner.ca;
+ add_header Content-Security-Policy "frame-ancestors 'self' app.cmlite.org app.betteredmonton.org app.pridecorner.ca" always;
+
+ location / {
+ set $upstream_listmonk http://changemaker-v2-api:9002;
+ proxy_pass $upstream_listmonk;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ }
+}
+
+# MkDocs — allows iframe embedding from admin
+server {
+ listen 80;
+ server_name docs.cmlite.org docs.betteredmonton.org docs.pridecorner.ca;
+ add_header Content-Security-Policy "frame-ancestors 'self' app.cmlite.org app.betteredmonton.org" always;
+
+ location / {
+ set $upstream_mkdocs http://mkdocs-changemaker:8000;
+ proxy_pass $upstream_mkdocs;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection "upgrade";
+ }
+}
+
+# Code Server — allows iframe embedding from admin (app.cmlite.org)
+server {
+ listen 80;
+ server_name code.cmlite.org;
+ add_header Content-Security-Policy "frame-ancestors 'self' app.cmlite.org" always;
+
+ location / {
+ set $upstream_code http://code-server-changemaker:8443;
+ proxy_pass $upstream_code;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection "upgrade";
+ }
+}
+
+# MailHog (email testing) — allows iframe embedding from admin (app.cmlite.org)
+server {
+ listen 80;
+ server_name mail.cmlite.org;
+ add_header Content-Security-Policy "frame-ancestors 'self' app.cmlite.org" always;
+
+ location / {
+ set $upstream_mailhog http://mailhog-changemaker:8025;
+ proxy_pass $upstream_mailhog;
+ proxy_hide_header X-Frame-Options;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ # WebSocket support for MailHog live updates
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection "upgrade";
+ }
+}
+
+# Mini QR — allows iframe embedding from admin (app.cmlite.org)
+server {
+ listen 80;
+ server_name qr.cmlite.org;
+ add_header Content-Security-Policy "frame-ancestors 'self' app.cmlite.org" always;
+
+ location / {
+ set $upstream_miniqr http://mini-qr:8080;
+ proxy_pass $upstream_miniqr;
+ proxy_hide_header X-Frame-Options;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ }
+}
+
+# Excalidraw — allows iframe embedding from admin (app.cmlite.org)
+server {
+ listen 80;
+ server_name draw.cmlite.org;
+ add_header Content-Security-Policy "frame-ancestors 'self' app.cmlite.org" always;
+
+ location / {
+ set $upstream_excalidraw http://excalidraw-changemaker:80;
+ proxy_pass $upstream_excalidraw;
+ proxy_hide_header X-Frame-Options;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+
+ # WebSocket support for collaboration
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection "upgrade";
+ proxy_http_version 1.1;
+ }
+}
+
+# Vaultwarden (password manager) — allows iframe embedding from admin
+server {
+ listen 80;
+ server_name vault.cmlite.org;
+ add_header Content-Security-Policy "frame-ancestors 'self' app.cmlite.org" always;
+
+ location / {
+ set $upstream_vaultwarden http://vaultwarden-changemaker:80;
+ proxy_pass $upstream_vaultwarden;
+ proxy_hide_header X-Frame-Options;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection "upgrade";
+ proxy_http_version 1.1;
+ }
+}
+
+# Rocket.Chat (team chat) — allows iframe embedding from admin
+server {
+ listen 80;
+ server_name chat.cmlite.org chat.betteredmonton.org chat.pridecorner.ca;
+ add_header Content-Security-Policy "frame-ancestors 'self' app.cmlite.org app.betteredmonton.org" always;
+
+ location / {
+ set $upstream_rocketchat http://rocketchat-changemaker:3000;
+ proxy_pass $upstream_rocketchat;
+ proxy_hide_header X-Frame-Options;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ # WebSocket support (critical for RC real-time messaging)
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection "upgrade";
+ proxy_http_version 1.1;
+ client_max_body_size 100m;
+ }
+}
+
+# Gancio (event management) — allows iframe embedding from admin (app.cmlite.org)
+server {
+ listen 80;
+ server_name events.cmlite.org;
+ add_header Content-Security-Policy "frame-ancestors 'self' app.cmlite.org" always;
+
+ location / {
+ set $upstream_gancio http://gancio-changemaker:13120;
+ proxy_pass $upstream_gancio;
+ proxy_hide_header X-Frame-Options;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ }
+}
+
+# Jitsi Meet (video conferencing) — allows iframe embedding from admin (app.cmlite.org)
+server {
+ listen 80;
+ server_name meet.cmlite.org meet.betteredmonton.org meet.pridecorner.ca;
+ add_header Content-Security-Policy "frame-ancestors 'self' app.cmlite.org app.betteredmonton.org" always;
+
+ location / {
+ set $upstream_jitsi http://jitsi-web-changemaker:80;
+ proxy_pass $upstream_jitsi;
+ proxy_hide_header X-Frame-Options;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection "upgrade";
+ proxy_http_version 1.1;
+ }
+}
+
+# --- Embed proxy ports (for iframe embedding without DNS/subdomain) ---
+# These listen on dedicated ports so the admin GUI can iframe services via
+# localhost:PORT, bypassing X-Frame-Options without needing *.localhost DNS.
+# NOTE: In Docker deployments, these ports come from env vars via the .template file.
+# These hardcoded values are defaults for reference only.
+
+server {
+ listen 8881;
+ location / {
+ set $upstream_nocodb http://changemaker-v2-nocodb:8080;
+ proxy_pass $upstream_nocodb;
+ proxy_hide_header X-Frame-Options;
+ proxy_hide_header Content-Security-Policy;
+ add_header Content-Security-Policy "frame-ancestors 'self' localhost 127.0.0.1" always;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ }
+}
+
+server {
+ listen 8882;
+ location / {
+ set $upstream_n8n http://n8n-changemaker:5678;
+ proxy_pass $upstream_n8n;
+ proxy_hide_header X-Frame-Options;
+ proxy_hide_header Content-Security-Policy;
+ add_header Content-Security-Policy "frame-ancestors 'self' localhost 127.0.0.1" always;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection "upgrade";
+ }
+}
+
+server {
+ listen 8883;
+ # Increase max body size for large git pushes (2GB)
+ client_max_body_size 2048M;
+ location / {
+ set $upstream_gitea http://gitea-changemaker:3000;
+ proxy_pass $upstream_gitea;
+ proxy_hide_header X-Frame-Options;
+ proxy_hide_header Content-Security-Policy;
+ add_header Content-Security-Policy "frame-ancestors 'self' localhost 127.0.0.1" always;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ }
+}
+
+server {
+ listen 8884;
+ location / {
+ set $upstream_mailhog http://mailhog-changemaker:8025;
+ proxy_pass $upstream_mailhog;
+ proxy_hide_header X-Frame-Options;
+ proxy_hide_header Content-Security-Policy;
+ add_header Content-Security-Policy "frame-ancestors 'self' localhost 127.0.0.1" always;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection "upgrade";
+ }
+}
+
+server {
+ listen 8885;
+ location / {
+ set $upstream_miniqr http://mini-qr:8080;
+ proxy_pass $upstream_miniqr;
+ proxy_hide_header X-Frame-Options;
+ proxy_hide_header Content-Security-Policy;
+ add_header Content-Security-Policy "frame-ancestors 'self' localhost 127.0.0.1" always;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ }
+}
+
+# Excalidraw embed proxy (port 8886)
+server {
+ listen 8886;
+ location / {
+ set $upstream_excalidraw http://excalidraw-changemaker:80;
+ proxy_pass $upstream_excalidraw;
+ proxy_hide_header X-Frame-Options;
+ proxy_hide_header Content-Security-Policy;
+ add_header Content-Security-Policy "frame-ancestors 'self' localhost 127.0.0.1" always;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+
+ # WebSocket support for collaboration
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection "upgrade";
+ proxy_http_version 1.1;
+ }
+}
+
+# Admin GUI — app subdomain
+server {
+ listen 80;
+ server_name app.cmlite.org app.betteredmonton.org app.pridecorner.ca;
+ add_header X-Frame-Options "SAMEORIGIN" always;
+
+ # Social media bot detection for OG meta tags
+ set $is_bot 0;
+ if ($http_user_agent ~* "(Twitterbot|facebookexternalhit|LinkedInBot|Slackbot|TelegramBot|WhatsApp|Discordbot|Googlebot|bingbot|Pinterest|Embedly|Quora Link Preview|Showyoubot|outbrain|vkShare|W3C_Validator)") {
+ set $is_bot 1;
+ }
+
+ # Bot-specific rewrites — serve OG meta from API for rich social previews
+ location ~ ^/campaign/([^/]+)$ {
+ if ($is_bot) {
+ rewrite ^/campaign/(.+)$ /api/og/campaign/$1 last;
+ }
+ set $upstream_admin http://changemaker-v2-admin:3000;
+ proxy_pass $upstream_admin;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection "upgrade";
+ }
+
+ location ~ ^/p/([^/]+)$ {
+ if ($is_bot) {
+ rewrite ^/p/(.+)$ /api/og/page/$1 last;
+ }
+ set $upstream_admin http://changemaker-v2-admin:3000;
+ proxy_pass $upstream_admin;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection "upgrade";
+ }
+
+ location ~ ^/gallery/watch/([^/]+)$ {
+ if ($is_bot) {
+ rewrite ^/gallery/watch/(.+)$ /api/og/gallery/$1 last;
+ }
+ set $upstream_admin http://changemaker-v2-admin:3000;
+ proxy_pass $upstream_admin;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection "upgrade";
+ }
+
+ location / {
+ set $upstream_admin http://changemaker-v2-admin:3000;
+ proxy_pass $upstream_admin;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ # WebSocket support for Vite HMR
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection "upgrade";
+ }
+
+ # Media API (direct path - used by admin GUI media-api.ts client)
+ # Rewrites /media/* to /api/* (matches Vite dev proxy behavior)
+ # Uses variable proxy_pass for runtime DNS resolution after container restarts
+ location /media/ {
+ rewrite ^/media/(.*) /api/$1 break;
+ set $upstream_media_app http://changemaker-media-api:4100;
+ proxy_pass $upstream_media_app;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+
+ # Large file upload support
+ client_max_body_size 10G;
+ proxy_read_timeout 3600s;
+ proxy_connect_timeout 75s;
+ proxy_request_buffering off;
+
+ # WebSocket support
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection "upgrade";
+ }
+
+ # Media API endpoints (must come BEFORE /api/ for longest prefix match)
+ location /api/media/ {
+ set $upstream_media http://changemaker-media-api:4100;
+ proxy_pass $upstream_media;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+
+ # Large upload support
+ client_max_body_size 10G;
+ proxy_read_timeout 3600s;
+ proxy_connect_timeout 75s;
+ proxy_request_buffering off;
+
+ # WebSocket support
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection "upgrade";
+ }
+
+ # MkDocs proxy (docs search index for volunteer map)
+ location /mkdocs-proxy/ {
+ set $upstream_mkdocs http://mkdocs-changemaker:8000;
+ rewrite ^/mkdocs-proxy/(.*) /$1 break;
+ proxy_pass $upstream_mkdocs;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ }
+
+ # API (Express)
+ location /api/ {
+ set $upstream_api http://changemaker-v2-api:4000;
+ proxy_pass $upstream_api;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ }
+}
+
+# MkDocs built static site — root domain
+server {
+ listen 80;
+ server_name cmlite.org;
+
+ location / {
+ set $upstream_site http://mkdocs-site-server-changemaker:80;
+ proxy_pass $upstream_site;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ }
+}
+
+# Root domain — routes to admin GUI (supports custom DOMAIN env var)
+server {
+ listen 80;
+ server_name betteredmonton.org pridecorner.ca;
+ add_header X-Frame-Options "SAMEORIGIN" always;
+
+ location / {
+ set $upstream_admin http://changemaker-v2-admin:3000;
+ proxy_pass $upstream_admin;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ # WebSocket support for Vite HMR
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection "upgrade";
+ }
+
+ # Media API (direct path - used by admin GUI media-api.ts client)
+ # Uses variable proxy_pass for runtime DNS resolution after container restarts
+ location /media/ {
+ rewrite ^/media/(.*) /api/$1 break;
+ set $upstream_media_root http://changemaker-media-api:4100;
+ proxy_pass $upstream_media_root;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+
+ # Large file upload support
+ client_max_body_size 10G;
+ proxy_read_timeout 3600s;
+ proxy_connect_timeout 75s;
+ proxy_request_buffering off;
+
+ # WebSocket support
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection "upgrade";
+ }
+
+ # Media API endpoints (must come BEFORE /api/ for longest prefix match)
+ location /api/media/ {
+ set $upstream_media http://changemaker-media-api:4100;
+ proxy_pass $upstream_media;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+
+ # Large upload support
+ client_max_body_size 10G;
+ proxy_read_timeout 3600s;
+ proxy_connect_timeout 75s;
+ proxy_request_buffering off;
+
+ # WebSocket support
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection "upgrade";
+ }
+
+ # API (Express)
+ location /api/ {
+ set $upstream_api http://changemaker-v2-api:4000;
+ proxy_pass $upstream_api;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ }
+}
+
+# Homepage dashboard — allows iframe embedding from admin
+server {
+ listen 80;
+ server_name home.cmlite.org;
+ add_header Content-Security-Policy "frame-ancestors 'self' app.cmlite.org app.betteredmonton.org" always;
+
+ location / {
+ set $upstream_homepage http://homepage-changemaker:3000;
+ proxy_pass $upstream_homepage;
+ proxy_hide_header X-Frame-Options;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ }
+}
+
+# Homepage embed proxy (port 8887)
+server {
+ listen 8887;
+ location / {
+ set $upstream_homepage http://homepage-changemaker:3000;
+ proxy_pass $upstream_homepage;
+ proxy_hide_header X-Frame-Options;
+ proxy_hide_header Content-Security-Policy;
+ add_header Content-Security-Policy "frame-ancestors 'self' localhost 127.0.0.1" always;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ }
+}
+
+# Vaultwarden embed proxy (port 8890)
+server {
+ listen 8890;
+ location / {
+ set $upstream_vaultwarden http://vaultwarden-changemaker:80;
+ proxy_pass $upstream_vaultwarden;
+ proxy_hide_header X-Frame-Options;
+ proxy_hide_header Content-Security-Policy;
+ add_header Content-Security-Policy "frame-ancestors 'self' localhost 127.0.0.1" always;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection "upgrade";
+ proxy_http_version 1.1;
+ }
+}
+
+# Rocket.Chat embed proxy (port 8891)
+server {
+ listen 8891;
+ location / {
+ set $upstream_rocketchat http://rocketchat-changemaker:3000;
+ proxy_pass $upstream_rocketchat;
+ proxy_hide_header X-Frame-Options;
+ proxy_hide_header Content-Security-Policy;
+ add_header Content-Security-Policy "frame-ancestors 'self' localhost 127.0.0.1" always;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection "upgrade";
+ proxy_http_version 1.1;
+ client_max_body_size 100m;
+ }
+}
+
+# Gancio embed proxy (port 8892)
+server {
+ listen 8892;
+ location / {
+ set $upstream_gancio http://gancio-changemaker:13120;
+ proxy_pass $upstream_gancio;
+ proxy_hide_header X-Frame-Options;
+ proxy_hide_header Content-Security-Policy;
+ add_header Content-Security-Policy "frame-ancestors 'self' localhost 127.0.0.1" always;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ }
+}
+
+# Jitsi Meet embed proxy (port 8893)
+server {
+ listen 8893;
+ location / {
+ set $upstream_jitsi http://jitsi-web-changemaker:80;
+ proxy_pass $upstream_jitsi;
+ proxy_hide_header X-Frame-Options;
+ proxy_hide_header Content-Security-Policy;
+ add_header Content-Security-Policy "frame-ancestors 'self' localhost 127.0.0.1" always;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection "upgrade";
+ proxy_http_version 1.1;
+ }
+}
+
+# Grafana embed proxy (port 8894)
+server {
+ listen 8894;
+ location / {
+ set $upstream_grafana http://grafana-changemaker:3000;
+ proxy_pass $upstream_grafana;
+ proxy_hide_header X-Frame-Options;
+ proxy_hide_header Content-Security-Policy;
+ add_header Content-Security-Policy "frame-ancestors 'self' localhost 127.0.0.1" always;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection "upgrade";
+ }
+}
+
+# Alertmanager embed proxy (port 8895)
+server {
+ listen 8895;
+ location / {
+ set $upstream_alertmanager http://alertmanager-changemaker:9093;
+ proxy_pass $upstream_alertmanager;
+ proxy_hide_header X-Frame-Options;
+ proxy_hide_header Content-Security-Policy;
+ add_header Content-Security-Policy "frame-ancestors 'self' localhost 127.0.0.1" always;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ }
+}
diff --git a/nginx/nginx.conf b/nginx/nginx.conf
index 4be3a9b7..176a77a3 100644
--- a/nginx/nginx.conf
+++ b/nginx/nginx.conf
@@ -32,6 +32,12 @@ http {
types_hash_max_size 2048;
client_max_body_size 50m;
+ # Rate limiting zones (defense-in-depth alongside app-level Redis rate limits)
+ limit_req_zone $binary_remote_addr zone=api_global:10m rate=30r/s;
+ limit_req_zone $binary_remote_addr zone=api_auth:10m rate=5r/s;
+ limit_req_zone $binary_remote_addr zone=upload:10m rate=2r/s;
+ limit_req_status 429;
+
# Gzip compression
gzip on;
gzip_vary on;