Add Gitea SSO, fix security audit findings, harden production defaults

Gitea SSO: cookie-based single sign-on via nginx auth_request — sets
cml_session cookie on login/refresh, validates via /api/auth/gitea-sso-validate,
injects X-WEBAUTH-USER header for reverse proxy auth. Dedicated GITEA_SSO_SECRET
and SERVICE_PASSWORD_SALT env vars isolate secret rotation.

Security fixes from March 30 audit: IDOR on ticketed events (requireEventOwnership
middleware), IDOR on action items (admin/assignee/creator check), path traversal
on photos (resolve-based validation), CSV upload size limit (5MB), shared calendar
email exposure removed.

Gitea provisioner: auto-sync docs repo collaborator access based on role
(CONTENT_ROLES get write, SUPER_ADMIN gets admin). Gitea client extended
with collaborator management API methods.

Production hardening: NODE_ENV defaults to production in docker-compose.prod.yml,
Grafana anonymous auth disabled, install.sh branch ref updated to main.

Admin UI: moved docs reset from toolbar to MkDocs Settings danger zone,
improved collab Ctrl+S to explicitly save + cache-bust preview.

MkDocs site rebuild with updated repo data, upgrade screenshots, and content.

Bunker Admin
This commit is contained in:
2026-03-31 11:20:01 -06:00
parent 9321aeb263
commit 91db29402c
175 changed files with 9683 additions and 1184 deletions

135
api/package-lock.json generated
View File

@@ -1656,14 +1656,6 @@
"resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.5.0.tgz",
"integrity": "sha512-eUgLqrMf8nJkZxT24JvVRrQya1vZkQh8BBeYNwGDqa5I0VUi8ACx7uFvAaLxintokpTenkK6DASvo/bvNbBGow=="
},
"node_modules/@isaacs/cliui": {
"version": "9.0.0",
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-9.0.0.tgz",
"integrity": "sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==",
"engines": {
"node": ">=18"
}
},
"node_modules/@js-temporal/polyfill": {
"version": "0.5.1",
"resolved": "https://registry.npmjs.org/@js-temporal/polyfill/-/polyfill-0.5.1.tgz",
@@ -1901,6 +1893,7 @@
"resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.10.tgz",
"integrity": "sha512-B4xqkqfZ8Wek+rCOeRxsjMS9OgvzebEzzLYw7NHYuvzb7IdxOkI0ZHGgeEBX4PUM7QGVvNSK60T3OvWj3YfBRg==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"@types/express": "*"
}
@@ -2102,9 +2095,9 @@
}
},
"node_modules/ajv": {
"version": "8.17.1",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
"version": "8.18.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
"integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
@@ -2216,14 +2209,11 @@
}
},
"node_modules/balanced-match": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.2.tgz",
"integrity": "sha512-x0K50QvKQ97fdEz2kPehIerj+YTeptKF9hyYkKf6egnwmMWAkADiO0QCzSp0R5xN8FTZgYaBfSaue46Ej62nMg==",
"dependencies": {
"jackspeak": "^4.2.3"
},
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
"engines": {
"node": "20 || >=22"
"node": "18 || 20 || >=22"
}
},
"node_modules/bcryptjs": {
@@ -2260,14 +2250,14 @@
}
},
"node_modules/brace-expansion": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.2.tgz",
"integrity": "sha512-Pdk8c9poy+YhOgVWw1JNN22/HcivgKWwpxKq04M/jTmHyCZn12WPJebZxdjSa5TmBqISrUSgNYU3eRORljfCCw==",
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "20 || >=22"
"node": "18 || 20 || >=22"
}
},
"node_modules/buffer-equal-constant-time": {
@@ -2548,6 +2538,7 @@
"version": "1.4.7",
"resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz",
"integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==",
"license": "MIT",
"dependencies": {
"cookie": "0.7.2",
"cookie-signature": "1.0.6"
@@ -2700,15 +2691,15 @@
}
},
"node_modules/drizzle-kit": {
"version": "0.31.9",
"resolved": "https://registry.npmjs.org/drizzle-kit/-/drizzle-kit-0.31.9.tgz",
"integrity": "sha512-GViD3IgsXn7trFyBUUHyTFBpH/FsHTxYJ66qdbVggxef4UBPHRYxQaRzYLTuekYnk9i5FIEL9pbBIwMqX/Uwrg==",
"version": "0.31.10",
"resolved": "https://registry.npmjs.org/drizzle-kit/-/drizzle-kit-0.31.10.tgz",
"integrity": "sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw==",
"dev": true,
"dependencies": {
"@drizzle-team/brocli": "^0.10.2",
"@esbuild-kit/esm-loader": "^2.5.5",
"esbuild": "^0.25.4",
"esbuild-register": "^3.5.0"
"tsx": "^4.21.0"
},
"bin": {
"drizzle-kit": "bin.cjs"
@@ -3426,41 +3417,6 @@
"@esbuild/win32-x64": "0.27.3"
}
},
"node_modules/esbuild-register": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/esbuild-register/-/esbuild-register-3.6.0.tgz",
"integrity": "sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg==",
"dev": true,
"dependencies": {
"debug": "^4.3.4"
},
"peerDependencies": {
"esbuild": ">=0.12 <1"
}
},
"node_modules/esbuild-register/node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dev": true,
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/esbuild-register/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true
},
"node_modules/escape-html": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
@@ -3623,9 +3579,9 @@
]
},
"node_modules/fastify": {
"version": "5.7.4",
"resolved": "https://registry.npmjs.org/fastify/-/fastify-5.7.4.tgz",
"integrity": "sha512-e6l5NsRdaEP8rdD8VR0ErJASeyaRbzXYpmkrpr2SuvuMq6Si3lvsaVy5C+7gLanEkvjpMDzBXWE5HPeb/hgTxA==",
"version": "5.8.4",
"resolved": "https://registry.npmjs.org/fastify/-/fastify-5.8.4.tgz",
"integrity": "sha512-sa42J1xylbBAYUWALSBoyXKPDUvM3OoNOibIefA+Oha57FryXKKCZarA1iDntOCWp3O35voZLuDg2mdODXtPzQ==",
"funding": [
{
"type": "github",
@@ -3646,7 +3602,7 @@
"fast-json-stringify": "^6.0.0",
"find-my-way": "^9.0.0",
"light-my-request": "^6.0.0",
"pino": "^10.1.0",
"pino": "^9.14.0 || ^10.1.0",
"process-warning": "^5.0.0",
"rfdc": "^1.3.1",
"secure-json-parse": "^4.0.0",
@@ -4066,20 +4022,6 @@
"url": "https://github.com/sponsors/dmonad"
}
},
"node_modules/jackspeak": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.2.3.tgz",
"integrity": "sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==",
"dependencies": {
"@isaacs/cliui": "^9.0.0"
},
"engines": {
"node": "20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/jiti": {
"version": "2.6.1",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
@@ -4407,14 +4349,14 @@
}
},
"node_modules/minimatch": {
"version": "10.2.0",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.0.tgz",
"integrity": "sha512-ugkC31VaVg9cF0DFVoADH12k6061zNZkZON+aX8AWsR9GhPcErkcMBceb6znR8wLERM2AkkOxy2nWRLpT9Jq5w==",
"version": "10.2.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
"integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
"dependencies": {
"brace-expansion": "^5.0.2"
"brace-expansion": "^5.0.5"
},
"engines": {
"node": "20 || >=22"
"node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
@@ -4542,9 +4484,9 @@
}
},
"node_modules/nodemailer": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.1.tgz",
"integrity": "sha512-5kcldIXmaEjZcHR6F28IKGSgpmZHaF1IXLWFTG+Xh3S+Cce4MiakLtWY+PlBU69fLbRa8HlaGIrC/QolUpHkhg==",
"version": "8.0.4",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.4.tgz",
"integrity": "sha512-k+jf6N8PfQJ0Fe8ZhJlgqU5qJU44Lpvp2yvidH3vp1lPnVQMgi4yEEMPXg5eJS1gFIJTVq1NHBk7Ia9ARdSBdQ==",
"engines": {
"node": ">=6.0.0"
}
@@ -4705,9 +4647,9 @@
}
},
"node_modules/path-to-regexp": {
"version": "0.1.12",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ=="
"version": "0.1.13",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA=="
},
"node_modules/pathe": {
"version": "2.0.3",
@@ -5000,9 +4942,9 @@
}
},
"node_modules/qs": {
"version": "6.14.1",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz",
"integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==",
"version": "6.14.2",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz",
"integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==",
"dependencies": {
"side-channel": "^1.1.0"
},
@@ -5848,10 +5790,9 @@
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ=="
},
"node_modules/yaml": {
"version": "2.8.2",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
"integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
"license": "ISC",
"version": "2.8.3",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz",
"integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==",
"bin": {
"yaml": "bin.mjs"
},

View File

@@ -38,6 +38,11 @@ const envSchema = z.object({
// 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'),
// Gitea SSO cookie signing secret (falls back to JWT_ACCESS_SECRET if empty)
GITEA_SSO_SECRET: z.string().default(''),
// Salt for deriving deterministic service passwords (Gitea, Rocket.Chat — falls back to JWT_ACCESS_SECRET if empty)
SERVICE_PASSWORD_SALT: z.string().default(''),
// 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')

View File

@@ -1,6 +1,7 @@
import { Router, Request, Response, NextFunction } from 'express';
import { z } from 'zod';
import bcrypt from 'bcryptjs';
import jwt from 'jsonwebtoken';
import { UserRole, UserStatus } from '@prisma/client';
import { authService } from './auth.service';
import { loginSchema, registerSchema, refreshSchema } from './auth.schemas';
@@ -22,6 +23,9 @@ const router = Router();
const REFRESH_COOKIE_NAME = 'cml_refresh';
const REFRESH_COOKIE_MAX_AGE = 7 * 24 * 60 * 60 * 1000; // 7 days in ms
const SESSION_COOKIE_NAME = 'cml_session';
const SESSION_COOKIE_MAX_AGE = 30 * 60 * 1000; // 30 min buffer (JWT inside enforces 15min expiry)
/** Set the refresh token as an httpOnly cookie.
* Uses req.secure (respects trust proxy + X-Forwarded-Proto) to determine
* the Secure flag, so it works correctly over both HTTP (dev) and HTTPS (tunnel). */
@@ -45,6 +49,53 @@ function clearRefreshCookie(req: Request, res: Response) {
});
}
/** Cookie options for the SSO session cookie (domain-wide for Gitea reverse proxy auth) */
function sessionCookieOptions(req: Request) {
const isSecure = req.secure;
const domain = env.DOMAIN;
// Use domain-wide cookie for production (subdomains); omit for localhost dev
const hasDomain = domain && !domain.includes('localhost') && !domain.match(/^\d/);
return {
httpOnly: true,
secure: isSecure,
sameSite: 'lax' as const,
path: '/',
maxAge: SESSION_COOKIE_MAX_AGE,
...(hasDomain ? { domain: `.${domain}` } : {}),
};
}
/** Set the SSO session cookie for Gitea reverse proxy auth (fire-and-forget).
* Only sets the cookie if the user has a provisioned Gitea account. */
async function setSessionCookie(req: Request, res: Response, userId: string) {
try {
const user = await prisma.user.findUnique({
where: { id: userId },
select: { permissions: true },
});
const permissions = (user?.permissions as Record<string, unknown>) || {};
const giteaUser = permissions._giteaUsername as string | undefined;
if (!giteaUser) return; // Not provisioned — skip
const ssoSecret = env.GITEA_SSO_SECRET || env.JWT_ACCESS_SECRET;
const token = jwt.sign(
{ sub: userId, giteaUser },
ssoSecret,
{ algorithm: 'HS256', expiresIn: '15m' },
);
res.cookie(SESSION_COOKIE_NAME, token, sessionCookieOptions(req));
} catch (err) {
logger.debug('Failed to set SSO session cookie:', err);
}
}
/** Clear the SSO session cookie */
function clearSessionCookie(req: Request, res: Response) {
const opts = sessionCookieOptions(req);
delete (opts as Record<string, unknown>).maxAge;
res.clearCookie(SESSION_COOKIE_NAME, opts);
}
// POST /api/auth/login
router.post(
'/login',
@@ -55,6 +106,8 @@ router.post(
const result = await authService.login(req.body.email, req.body.password);
// Set refresh token as httpOnly cookie (not in response body)
setRefreshCookie(req, res, result.refreshToken);
// Set SSO session cookie for Gitea reverse proxy auth
await setSessionCookie(req, res, result.user.id);
const { refreshToken: _, ...responseWithoutRefresh } = result;
res.json(responseWithoutRefresh);
} catch (err) {
@@ -281,6 +334,10 @@ router.post(
const result = await authService.refreshTokens(refreshToken);
// Set new refresh token as httpOnly cookie
setRefreshCookie(req, res, result.refreshToken);
// Renew SSO session cookie for Gitea reverse proxy auth
if (result.user?.id) {
await setSessionCookie(req, res, result.user.id);
}
const { refreshToken: _, ...responseWithoutRefresh } = result;
res.json(responseWithoutRefresh);
} catch (err) {
@@ -302,9 +359,11 @@ router.post(
await authService.logout(refreshToken);
}
clearRefreshCookie(req, res);
clearSessionCookie(req, res);
res.json({ message: 'Logged out' });
} catch (err) {
clearRefreshCookie(req, res);
clearSessionCookie(req, res);
next(err);
}
}

View File

@@ -0,0 +1,50 @@
import { Router, Request, Response } from 'express';
import jwt from 'jsonwebtoken';
import { env } from '../../config/env';
import { logger } from '../../utils/logger';
const router = Router();
interface SsoPayload {
sub: string;
giteaUser: string;
}
/**
* GET /api/auth/gitea-sso-validate
*
* Called by nginx auth_request to validate the cml_session cookie.
* Always returns 200 — never blocks requests.
* Sets X-Gitea-User header when the session is valid and the user
* has a provisioned Gitea account. Empty header = no SSO (Gitea
* shows its own login page).
*/
router.get('/gitea-sso-validate', (req: Request, res: Response) => {
const token = req.cookies?.cml_session;
if (!token) {
res.setHeader('X-Gitea-User', '');
res.status(200).end();
return;
}
try {
const ssoSecret = env.GITEA_SSO_SECRET || env.JWT_ACCESS_SECRET;
const payload = jwt.verify(token, ssoSecret, {
algorithms: ['HS256'],
}) as SsoPayload;
if (payload.giteaUser) {
res.setHeader('X-Gitea-User', payload.giteaUser);
} else {
res.setHeader('X-Gitea-User', '');
}
} catch {
// Expired or invalid JWT — no SSO, graceful fallback
res.setHeader('X-Gitea-User', '');
}
res.status(200).end();
});
export { router as giteaSsoRouter };

View File

@@ -925,7 +925,7 @@ export const sharedCalendarService = {
include: {
members: {
where: { status: SharedViewMemberStatus.ACCEPTED },
include: { user: { select: { id: true, name: true, email: true } } },
include: { user: { select: { id: true, name: true } } },
},
},
});
@@ -972,7 +972,7 @@ export const sharedCalendarService = {
orderBy: [{ date: 'asc' }, { startTime: 'asc' }],
});
const memberName = member.user.name || member.user.email;
const memberName = member.user.name || 'Member';
for (const item of items) {
if (item.visibility === CalendarVisibility.PRIVATE || item.visibility === CalendarVisibility.FRIENDS) continue;
allItems.push({
@@ -999,7 +999,7 @@ export const sharedCalendarService = {
const sysLayers = filteredLayers.filter(l => l.layerType === CalendarLayerType.SYSTEM);
for (const sysLayer of sysLayers) {
const sysItems = await this.getSystemLayerItems(member.userId, sysLayer, start, end);
const memberName = member.user.name || member.user.email;
const memberName = member.user.name || 'Member';
for (const si of sysItems) {
allItems.push({
...si,

View File

@@ -1,6 +1,7 @@
import { FastifyInstance } from 'fastify';
import { createReadStream } from 'fs';
import { access } from 'fs/promises';
import { resolve } from 'path';
import { prisma } from '../../../config/database';
import { optionalAuth } from '../middleware/auth';
import { logger } from '../../../utils/logger';
@@ -181,19 +182,25 @@ export async function photosPublicRoutes(fastify: FastifyInstance) {
}
}
if (!filePath || filePath.includes('..')) {
if (!filePath) {
return reply.code(404).send({ message: 'Image variant not found' });
}
const PHOTOS_BASE = '/media/local/photos';
const resolvedPath = resolve(filePath);
if (!resolvedPath.startsWith(resolve(PHOTOS_BASE) + '/')) {
logger.warn(`Photo path traversal attempt blocked: ${filePath}`);
return reply.code(403).send({ message: 'Access denied' });
}
try {
await access(filePath);
await access(resolvedPath);
} catch {
return reply.code(404).send({ message: 'Image file not found' });
}
reply.header('Content-Type', contentType);
reply.header('Cache-Control', 'public, max-age=604800, immutable');
return reply.send(createReadStream(filePath));
return reply.send(createReadStream(resolvedPath));
}
);
@@ -208,19 +215,25 @@ export async function photosPublicRoutes(fastify: FastifyInstance) {
select: { thumbnailPath: true },
});
if (!photo?.thumbnailPath || photo.thumbnailPath.includes('..')) {
if (!photo?.thumbnailPath) {
return reply.code(404).send({ message: 'Thumbnail not found' });
}
const PHOTOS_BASE = '/media/local/photos';
const resolvedThumb = resolve(photo.thumbnailPath);
if (!resolvedThumb.startsWith(resolve(PHOTOS_BASE) + '/')) {
logger.warn(`Thumbnail path traversal attempt blocked: ${photo.thumbnailPath}`);
return reply.code(403).send({ message: 'Access denied' });
}
try {
await access(photo.thumbnailPath);
await access(resolvedThumb);
} catch {
return reply.code(404).send({ message: 'Thumbnail file not found' });
}
reply.header('Content-Type', 'image/jpeg');
reply.header('Cache-Control', 'public, max-age=604800, immutable');
return reply.send(createReadStream(photo.thumbnailPath));
return reply.send(createReadStream(resolvedThumb));
}
);

View File

@@ -8,7 +8,8 @@ import {
import { validate } from '../../middleware/validate';
import { authenticate } from '../../middleware/auth.middleware';
import { requireRole } from '../../middleware/rbac.middleware';
import { EVENTS_ROLES } from '../../utils/roles';
import { EVENTS_ROLES, hasAnyRole } from '../../utils/roles';
import { AppError } from '../../middleware/error-handler';
const router = Router();
@@ -44,6 +45,14 @@ router.get('/:id', authenticate, async (req: Request, res: Response, next: NextF
try {
const id = req.params.id as string;
const item = await actionItemsService.findById(id);
const isAdmin = hasAnyRole(req.user!, EVENTS_ROLES);
const isAssignee = item.assigneeUserId === req.user!.id;
const isCreator = item.createdByUserId === req.user!.id;
if (!isAdmin && !isAssignee && !isCreator) {
throw new AppError(403, 'Insufficient permissions', 'FORBIDDEN');
}
res.json(item);
} catch (err) { next(err); }
});
@@ -56,10 +65,19 @@ router.post('/', authenticate, requireRole(...EVENTS_ROLES), validate(createActi
} catch (err) { next(err); }
});
// Update action item (authenticate only - assignees can update their own)
// Update action item — admins, assignees, or creators can update
router.put('/:id', authenticate, validate(updateActionItemSchema), async (req: Request, res: Response, next: NextFunction) => {
try {
const id = req.params.id as string;
const existing = await actionItemsService.findById(id);
const isAdmin = hasAnyRole(req.user!, EVENTS_ROLES);
const isAssignee = existing.assigneeUserId === req.user!.id;
const isCreator = existing.createdByUserId === req.user!.id;
if (!isAdmin && !isAssignee && !isCreator) {
throw new AppError(403, 'Insufficient permissions', 'FORBIDDEN');
}
const item = await actionItemsService.update(id, req.body);
res.json(item);
} catch (err) { next(err); }

View File

@@ -171,6 +171,11 @@ router.post('/:id/import-csv', async (req, res, next) => {
res.status(400).json({ error: 'CSV text is required in the "csv" field' });
return;
}
const MAX_CSV_SIZE = 5 * 1024 * 1024; // 5MB
if (csv.length > MAX_CSV_SIZE) {
res.status(400).json({ error: { message: 'CSV too large (max 5MB)', code: 'CSV_TOO_LARGE' } });
return;
}
const result = await smsContactsService.importCsv(req.params.id as string, csv, filename);
res.json(result);
} catch (err) { next(err); }

View File

@@ -39,6 +39,30 @@ async function requireEventPermission(req: Request, _res: Response, next: NextFu
return next({ status: 403, message: 'Insufficient permissions' });
}
/** Middleware: for :id routes, verify non-admin users own the event */
async function requireEventOwnership(req: Request, res: Response, next: NextFunction) {
const eventId = req.params.id as string;
if (!eventId) return next();
const userRoles = req.user!.roles || [req.user!.role];
const isAdmin = userRoles.some(r => EVENTS_ROLES.includes(r as UserRole));
if (isAdmin) return next();
const event = await prisma.ticketedEvent.findUnique({
where: { id: eventId },
select: { createdByUserId: true },
});
if (!event) {
res.status(404).json({ error: { message: 'Event not found', code: 'NOT_FOUND' } });
return;
}
if (event.createdByUserId !== req.user!.id) {
res.status(403).json({ error: { message: 'Forbidden', code: 'FORBIDDEN' } });
return;
}
next();
}
// All routes require auth + event permission
router.use(authenticate, requireEventPermission);
@@ -73,7 +97,7 @@ router.post('/', validate(createEventSchema), async (req: Request, res: Response
});
// GET /:id — event detail
router.get('/:id', async (req: Request, res: Response, next: NextFunction) => {
router.get('/:id', requireEventOwnership, async (req: Request, res: Response, next: NextFunction) => {
try {
const event = await ticketedEventsService.findById(req.params.id as string);
res.json(event);
@@ -144,7 +168,7 @@ router.post('/:id/complete', requireRole(...EVENTS_ROLES), async (req: Request,
// --- Meeting ---
// POST /:id/meeting-token — generate moderator JWT for Jitsi
router.post('/:id/meeting-token', async (req: Request, res: Response, next: NextFunction) => {
router.post('/:id/meeting-token', requireEventOwnership, async (req: Request, res: Response, next: NextFunction) => {
try {
const user = await prisma.user.findUnique({
where: { id: req.user!.id },
@@ -159,7 +183,7 @@ router.post('/:id/meeting-token', async (req: Request, res: Response, next: Next
// --- Tiers ---
// POST /:id/tiers
router.post('/:id/tiers', validate(createTierSchema), async (req: Request, res: Response, next: NextFunction) => {
router.post('/:id/tiers', requireEventOwnership, validate(createTierSchema), async (req: Request, res: Response, next: NextFunction) => {
try {
const tier = await ticketedEventsService.addTier(req.params.id as string, req.body);
res.status(201).json(tier);
@@ -167,7 +191,7 @@ 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) => {
router.put('/:id/tiers/:tierId', requireEventOwnership, validate(updateTierSchema), async (req: Request, res: Response, next: NextFunction) => {
try {
const tier = await ticketedEventsService.updateTier(
req.params.tierId as string,
@@ -179,7 +203,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) => {
router.delete('/:id/tiers/:tierId', requireEventOwnership, async (req: Request, res: Response, next: NextFunction) => {
try {
await ticketedEventsService.deleteTier(req.params.tierId as string, req.params.id as string);
res.json({ success: true });
@@ -189,7 +213,7 @@ router.delete('/:id/tiers/:tierId', async (req: Request, res: Response, next: Ne
// --- Tickets ---
// GET /:id/tickets
router.get('/:id/tickets', async (req: Request, res: Response, next: NextFunction) => {
router.get('/:id/tickets', requireEventOwnership, async (req: Request, res: Response, next: NextFunction) => {
try {
const page = parseInt(req.query.page as string) || 1;
const limit = Math.min(parseInt(req.query.limit as string) || 20, 100);
@@ -201,7 +225,7 @@ router.get('/:id/tickets', async (req: Request, res: Response, next: NextFunctio
});
// GET /:id/checkins
router.get('/:id/checkins', async (req: Request, res: Response, next: NextFunction) => {
router.get('/:id/checkins', requireEventOwnership, async (req: Request, res: Response, next: NextFunction) => {
try {
const page = parseInt(req.query.page as string) || 1;
const limit = Math.min(parseInt(req.query.limit as string) || 20, 100);
@@ -211,7 +235,7 @@ router.get('/:id/checkins', async (req: Request, res: Response, next: NextFuncti
});
// GET /:id/stats
router.get('/:id/stats', async (req: Request, res: Response, next: NextFunction) => {
router.get('/:id/stats', requireEventOwnership, async (req: Request, res: Response, next: NextFunction) => {
try {
const stats = await ticketedEventsService.getEventStats(req.params.id as string);
res.json(stats);
@@ -219,7 +243,7 @@ router.get('/:id/stats', async (req: Request, res: Response, next: NextFunction)
});
// POST /:id/resend-ticket/:ticketId
router.post('/:id/resend-ticket/:ticketId', async (req: Request, res: Response, next: NextFunction) => {
router.post('/:id/resend-ticket/:ticketId', requireEventOwnership, async (req: Request, res: Response, next: NextFunction) => {
try {
const ticket = await prisma.ticket.findUnique({
where: { id: req.params.ticketId as string },
@@ -273,7 +297,7 @@ router.post('/:id/resend-ticket/:ticketId', async (req: Request, res: Response,
});
// POST /:id/tickets/:ticketId/cancel
router.post('/:id/tickets/:ticketId/cancel', async (req: Request, res: Response, next: NextFunction) => {
router.post('/:id/tickets/:ticketId/cancel', requireEventOwnership, async (req: Request, res: Response, next: NextFunction) => {
try {
await ticketsService.cancelTicket(req.params.ticketId as string);
res.json({ success: true });

View File

@@ -599,6 +599,51 @@ class GiteaClient {
{ name: tokenName, scopes: ['read', 'write'] as unknown as Record<string, unknown> } as unknown as Record<string, unknown>,
);
}
// --- Repository Collaborator Management ---
/**
* Add a collaborator to a repository with the specified permission level.
* @param permission - "read", "write", or "admin"
*/
async addCollaborator(
owner: string,
repo: string,
username: string,
permission: 'read' | 'write' | 'admin' = 'write',
): Promise<void> {
await this.request(
'PUT',
`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/collaborators/${encodeURIComponent(username)}`,
{ permission },
);
}
/**
* Remove a collaborator from a repository.
*/
async removeCollaborator(owner: string, repo: string, username: string): Promise<void> {
await this.request(
'DELETE',
`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/collaborators/${encodeURIComponent(username)}`,
);
}
/**
* Check if a user is a collaborator on a repository.
* Returns true if they are, false otherwise.
*/
async isCollaborator(owner: string, repo: string, username: string): Promise<boolean> {
try {
await this.request(
'GET',
`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/collaborators/${encodeURIComponent(username)}`,
);
return true;
} catch {
return false;
}
}
}
export const giteaClient = new GiteaClient();

View File

@@ -1,9 +1,10 @@
import { createHmac } from 'crypto';
import { Prisma } from '@prisma/client';
import { Prisma, UserRole } from '@prisma/client';
import { prisma } from '../../config/database';
import { env } from '../../config/env';
import { logger } from '../../utils/logger';
import { giteaClient } from '../gitea.client';
import { CONTENT_ROLES } from '../../utils/roles';
import type { ServiceProvisioner, ProvisionerConfig, ProvisionResult, CMUser } from './provisioner.interface';
const ROLE_MAP: Record<string, string[]> = {
@@ -14,9 +15,13 @@ const ROLE_MAP: Record<string, string[]> = {
TEMP: [],
};
/** The private docs repo name created by gitea-setup */
const DOCS_REPO_NAME = 'changemaker.lite';
/** Deterministic password — never exposed to users */
function generateGiteaPassword(userId: string): string {
return createHmac('sha256', env.JWT_ACCESS_SECRET)
const salt = env.SERVICE_PASSWORD_SALT || env.JWT_ACCESS_SECRET;
return createHmac('sha256', salt)
.update(`gitea:${userId}`)
.digest('hex');
}
@@ -94,6 +99,9 @@ class GiteaProvisioner implements ServiceProvisioner {
},
});
// Grant docs repo access based on role
await this.syncDocsRepoAccess(giteaUser.login, user.role);
return {
success: true,
serviceUserId: String(giteaUser.id),
@@ -121,6 +129,9 @@ class GiteaProvisioner implements ServiceProvisioner {
admin: isAdmin,
active: user.status === 'ACTIVE',
});
// Re-evaluate docs repo access based on current role
await this.syncDocsRepoAccess(username, user.role);
}
async deactivate(serviceUserId: string): Promise<void> {
@@ -140,13 +151,57 @@ class GiteaProvisioner implements ServiceProvisioner {
}
await giteaClient.adminUpdateUser(username, { active: false });
// Remove docs repo collaborator access
try {
const config = await giteaClient.getConfig();
if (config.repoOwner) {
await giteaClient.removeCollaborator(config.repoOwner, DOCS_REPO_NAME, username);
}
} catch {
// Ignore — user may not have been a collaborator
}
logger.info(`Gitea provisioner: deactivated user ${username}`);
}
async getAuthToken(_user: CMUser, _serviceUserId: string): Promise<string | null> {
// Gitea SSO via API tokens could be implemented here if needed for iframe embedding
return null;
}
/**
* Ensure the user's collaborator status on the private docs repo matches
* their role. CONTENT_ROLES (SUPER_ADMIN, CONTENT_ADMIN) get write access;
* SUPER_ADMIN users already have admin access via the Gitea admin flag,
* but we also add them as explicit collaborators for consistency.
* Users without CONTENT_ROLES are removed as collaborators.
*/
private async syncDocsRepoAccess(username: string, role: string): Promise<void> {
try {
const config = await giteaClient.getConfig();
const repoOwner = config.repoOwner;
if (!repoOwner) return; // Setup not complete — no repo owner yet
const hasDocsAccess = CONTENT_ROLES.includes(role as UserRole);
if (hasDocsAccess) {
// SUPER_ADMIN → admin, CONTENT_ADMIN → write
const permission = role === 'SUPER_ADMIN' ? 'admin' : 'write';
await giteaClient.addCollaborator(repoOwner, DOCS_REPO_NAME, username, permission);
logger.debug(`Gitea provisioner: granted ${permission} access to ${repoOwner}/${DOCS_REPO_NAME} for ${username}`);
} else {
// Remove access if user no longer has CONTENT_ROLES
const isCollab = await giteaClient.isCollaborator(repoOwner, DOCS_REPO_NAME, username);
if (isCollab) {
await giteaClient.removeCollaborator(repoOwner, DOCS_REPO_NAME, username);
logger.debug(`Gitea provisioner: removed ${username} from ${repoOwner}/${DOCS_REPO_NAME}`);
}
}
} catch (err) {
// Non-fatal — don't block provisioning if repo access sync fails
logger.warn(`Gitea provisioner: docs repo access sync failed for ${username}:`, err instanceof Error ? err.message : err);
}
}
}
export const giteaProvisioner = new GiteaProvisioner();

View File

@@ -16,7 +16,8 @@ const ROLE_MAP: Record<string, string[]> = {
/** Deterministic password — never exposed to users, only used for RC internal auth */
function generateRCPassword(userId: string): string {
return createHmac('sha256', env.JWT_ACCESS_SECRET)
const salt = env.SERVICE_PASSWORD_SALT || env.JWT_ACCESS_SECRET;
return createHmac('sha256', salt)
.update(`rc:${userId}`)
.digest('hex');
}