Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .vercelignore
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
node_modules
dist
coverage
backend
uploads
*.log
.env
Expand Down
11 changes: 9 additions & 2 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ JWT_EXPIRES_DAYS=7
# Defaults to true in production, false otherwise. Must be true when COOKIE_SAMESITE=none.
# COOKIE_SECURE=false
# lax works when the app and API share a site (localhost, or a Vite proxy in dev).
# Use none (with COOKIE_SECURE=true) only if they live on different sites.
# Use none (with COOKIE_SECURE=true) only if they live on different sites (e.g. two
# separate Vercel/Railway domains without a shared parent domain).
COOKIE_SAMESITE=lax
BCRYPT_ROUNDS=12

Expand Down Expand Up @@ -50,9 +51,15 @@ UPLOAD_DIR=uploads
# Required when STORAGE_DRIVER=gdrive. Store the whole service-account JSON as one environment value.
# GOOGLE_SERVICE_ACCOUNT_JSON={"type":"service_account",...}
# GOOGLE_DRIVE_FOLDER_ID=your-shared-drive-folder-id

# Optional: use a SECOND Google account/folder for archived recordings, kept separate from
# the base-video account above (different service account and/or different Drive account).
# When unset, ARCHIVE_STORAGE_DRIVER=gdrive falls back to the shared credentials above.
# GOOGLE_ARCHIVE_SERVICE_ACCOUNT_JSON={"type":"service_account",...}
# GOOGLE_ARCHIVE_DRIVE_FOLDER_ID=the-second-account-folder-id

MAX_VIDEO_UPLOAD_MB=300
MAX_RECORDING_UPLOAD_MB=100
SUBMISSION_COOLDOWN_MS=30000

RATE_LIMIT_ENABLED=true

Expand Down
9 changes: 0 additions & 9 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,29 +16,20 @@
},
"dependencies": {
"@getbrevo/brevo": "^6.0.3",
"axios": "^1.20.0",
"bcrypt": "^6.0.0",
"bcryptjs": "^2.4.3",
"compression": "^1.8.2",
"cookie-parser": "^1.4.7",
"cors": "^2.8.6",
"dotenv": "^16.6.1",
"express": "^4.22.3",
"express-rate-limit": "^8.7.0",
"express-validator": "^7.2.1",
"fs-extra": "^11.4.1",
"googleapis": "^182.0.0",
"helmet": "^8.3.0",
"jsonwebtoken": "^9.0.3",
"mongoose": "^7.8.12",
"morgan": "^1.10.0",
"multer": "^2.4.0",
"openapi-client-axios": "^7.9.0",
"pg": "^8.13.1",
"pg-hstore": "^2.3.4",
"pino": "^10.3.1",
"pino-http": "^11.0.0",
"sequelize": "^6.37.5",
"swagger-ui-express": "^5.0.1",
"zod": "^4.6.5"
},
Expand Down
42 changes: 18 additions & 24 deletions backend/server.js
Original file line number Diff line number Diff line change
@@ -1,32 +1,26 @@
import app from './src/app.js'
import { connectDb, disconnectDb } from './src/config/db.js'
import { env } from './src/config/env.js'
import { authService } from './src/services/authService.js'
import { storageService } from './src/services/storage/index.js'
import { initServices } from './src/services/initialization.js'
import { logger } from './src/utils/logger.js'

async function start() {
await connectDb()
await storageService.init()
await authService.ensureAdminFromEnv()
// Vercel serverless handler export
async function handler(req, res) {
return app(req, res)
}

const server = app.listen(env.PORT, () => logger.info(`API listening on http://localhost:${env.PORT}`))
// Standalone execution (Railway / Docker / Local Node)
// Ignore process.env.VERCEL if running via direct `node server.js` command
const PORT = env.PORT || process.env.PORT || 5000

let closing = false
const shutdown = async (signal) => {
if (closing) return
closing = true
logger.info({ signal }, 'Shutting down')
server.close(async () => {
await disconnectDb()
process.exit(0)
})
setTimeout(() => process.exit(1), 10_000).unref() // do not hang forever on open connections
}
process.on('SIGINT', () => shutdown('SIGINT'))
process.on('SIGTERM', () => shutdown('SIGTERM'))
}
const server = app.listen(PORT, '0.0.0.0', () => {
console.log(`==================================================`)
console.log(`API SERVER LISTENING ON http://0.0.0.0:${PORT}`)
console.log(`==================================================`)
})

process.on('unhandledRejection', (reason) => { logger.fatal({ err: reason }, 'Unhandled rejection'); process.exit(1) })
// Trigger background initialization immediately after port binding
initServices().catch((error) => {
console.error('[BOOT ERROR] Service initialization failed on boot:', error.message)
})

start().catch((error) => { logger.fatal({ err: error }, 'Failed to start'); process.exit(1) })
export default handler
15 changes: 11 additions & 4 deletions backend/src/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { env } from './config/env.js'
import { openapi } from './docs/openapi.js'
import { errorHandler, notFoundHandler } from './middlewares/errorHandler.js'
import { enforceAccessRestrictions } from './middlewares/enforceAccessRestrictions.js'
import { ensureServicesInitialized } from './services/initialization.js'
import { apiLimiter } from './middlewares/rateLimiter.js'
import { verifyOrigin } from './middlewares/verifyOrigin.js'
import routes from './routes/index.js'
Expand All @@ -19,7 +20,9 @@ import swaggerUi from 'swagger-ui-express'
const app = express()

app.disable('x-powered-by')
if (env.TRUST_PROXY) app.set('trust proxy', 1) // needed behind nginx/Render/Heroku so rate limits see real IPs
if (env.TRUST_PROXY) app.set('trust proxy', 1) // needed behind nginx/Render/Railway so rate limits see real IPs

app.use(ensureServicesInitialized)

// Request logging with an id that is also sent back, so a support report can be matched to a log line.
app.use(pinoHttp({
Expand All @@ -40,13 +43,17 @@ app.get('/', (req, res) => res.json({ data: {
name: 'SignPak Commons API',
api: API_PREFIX,
health: `${API_PREFIX}/health`,
docs: '/docs',
docs: env.isProduction ? `${API_PREFIX}/docs.json` : '/docs',
} }))
app.get('/favicon.ico', (req, res) => res.status(204).end())
// The raw OpenAPI JSON has no filesystem dependency, so it stays available in every
// environment; only the interactive Swagger UI (below) is gated out of production.
app.get('/docs.json', (req, res) => res.json(openapi))
app.get('/api-docs.json', (req, res) => res.json(openapi))
app.use('/docs', swaggerUi.serve, swaggerUi.setup(openapi, { explorer: true }))
app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(openapi, { explorer: true }))
if (!env.isProduction) {
app.use('/docs', swaggerUi.serve, swaggerUi.setup(openapi, { explorer: true }))
app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(openapi, { explorer: true }))
}

app.use(API_PREFIX, apiLimiter, verifyOrigin, enforceAccessRestrictions, routes)

Expand Down
87 changes: 60 additions & 27 deletions backend/src/config/env.js
Original file line number Diff line number Diff line change
@@ -1,37 +1,64 @@
import 'dotenv/config'
import fs from 'node:fs'
import path from 'node:path'
import { config } from 'dotenv'
import { z } from 'zod'

const bool = z.union([z.boolean(), z.enum(['true', 'false']).transform((value) => value === 'true')])
const bool = z.union([
z.boolean(),
z.enum(['true', 'false']).transform((value) => value === 'true'),
])

const explicitEnv = { ...process.env }
const dotEnvPath = path.resolve(process.cwd(), '.env')
const dotEnv = fs.existsSync(dotEnvPath)
? config({ path: dotEnvPath, override: false }).parsed ?? {}
: {}

const mergedEnv = { ...dotEnv, ...explicitEnv }
const isTest = (explicitEnv.NODE_ENV ?? dotEnv.NODE_ENV ?? 'development') === 'test'

const testSafeEnv = isTest
? {
...mergedEnv,
COOKIE_SECURE: explicitEnv.COOKIE_SECURE ?? 'false',
COOKIE_SAMESITE: explicitEnv.COOKIE_SAMESITE ?? 'lax',
STORAGE_DRIVER: explicitEnv.STORAGE_DRIVER ?? 'local',
ARCHIVE_STORAGE_DRIVER: explicitEnv.ARCHIVE_STORAGE_DRIVER ?? 'local',
RATE_LIMIT_ENABLED: explicitEnv.RATE_LIMIT_ENABLED ?? 'false',
}
: mergedEnv

const schema = z.object({
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
PORT: z.coerce.number().int().positive().default(5000),
LOG_LEVEL: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace', 'silent']).default('info'),
TRUST_PROXY: bool.default(false),

MONGODB_URI: z.string({ error: 'MONGODB_URI is required (for example mongodb://127.0.0.1:27017/signpakcommons)' }).min(1, 'MONGODB_URI is required'),
MONGODB_URI: z.string().min(1, 'MONGODB_URI is required'),

// Comma-separated list of browser origins allowed to call the API with cookies.
CLIENT_ORIGIN: z.string().default('http://localhost:5173'),

JWT_SECRET: z.string({ error: 'JWT_SECRET is required. Generate one with: node -e "console.log(require(\'crypto\').randomBytes(48).toString(\'hex\'))"' }).min(32, 'JWT_SECRET must be at least 32 characters. Generate one with: node -e "console.log(require(\'crypto\').randomBytes(48).toString(\'hex\'))"'),
JWT_SECRET: z.string().min(32, 'JWT_SECRET must be at least 32 characters.'),
JWT_EXPIRES_DAYS: z.coerce.number().positive().default(7),
COOKIE_SECURE: bool.optional(),
COOKIE_SAMESITE: z.enum(['lax', 'strict', 'none']).default('lax'),
BCRYPT_ROUNDS: z.coerce.number().int().min(4).max(15).default(12),

BREVO_API_KEY: z.string().optional(),
BREVO_SENDER_EMAIL: z.email().optional(),
BREVO_SENDER_EMAIL: z.string().email('BREVO_SENDER_EMAIL must be a valid email').optional().or(z.literal('')),
BREVO_SENDER_NAME: z.string().min(1).default('SignPak Commons'),
AUTH_OTP_TTL_MINUTES: z.coerce.number().int().min(1).max(30).default(5),
AUTH_OTP_RESEND_SECONDS: z.coerce.number().int().min(0).max(3600).default(60),
AUTH_OTP_MAX_ATTEMPTS: z.coerce.number().int().min(1).max(10).default(5),

WEB3FORMS_ACCESS_KEY: z.string().optional(),
CONTACT_DAILY_LIMIT: z.coerce.number().int().min(1).default(25),
CONTACT_USER_DAILY_LIMIT: z.coerce.number().int().min(1).default(1),
CONTACT_ABUSE_BLOCK_MINUTES: z.coerce.number().int().min(1).max(1440).default(10),

ADMIN_EMAIL: z.email({ error: 'ADMIN_EMAIL is required and must be a valid email' }),
ADMIN_PASSWORD: z.string({ error: 'ADMIN_PASSWORD is required (at least 8 characters)' }).min(8, 'ADMIN_PASSWORD must be at least 8 characters').max(72),
ADMIN_EMAIL: z.string().email('ADMIN_EMAIL must be a valid email'),
ADMIN_PASSWORD: z.string().min(8, 'ADMIN_PASSWORD must be at least 8 characters').max(72),
ADMIN_FIRST_NAME: z.string().min(1).default('Admin'),
ADMIN_SURNAME: z.string().min(1).default('Signpak'),

Expand All @@ -40,47 +67,53 @@ const schema = z.object({
UPLOAD_DIR: z.string().default('uploads'),
GOOGLE_SERVICE_ACCOUNT_JSON: z.string().optional(),
GOOGLE_DRIVE_FOLDER_ID: z.string().optional(),
GOOGLE_ARCHIVE_SERVICE_ACCOUNT_JSON: z.string().optional(),
GOOGLE_ARCHIVE_DRIVE_FOLDER_ID: z.string().optional(),
MAX_VIDEO_UPLOAD_MB: z.coerce.number().positive().default(300),
MAX_RECORDING_UPLOAD_MB: z.coerce.number().positive().default(100),

RATE_LIMIT_ENABLED: bool.default(true),

// A contributor can submit a video more than once, but not back to back: this is the
// minimum gap enforced between two submissions for the same (user, video). Overridable
// (like BCRYPT_ROUNDS) so tests do not have to wait 30 real seconds.
SUBMISSION_COOLDOWN_MS: z.coerce.number().int().positive().default(30_000),
}).superRefine((data, context) => {
if (data.NODE_ENV === 'production' && !data.BREVO_API_KEY) {
context.addIssue({ code: 'custom', path: ['BREVO_API_KEY'], message: 'Required in production for email verification and password reset.' })
}
if (data.NODE_ENV === 'production' && !data.BREVO_SENDER_EMAIL) {
context.addIssue({ code: 'custom', path: ['BREVO_SENDER_EMAIL'], message: 'Required in production; use a verified Brevo sender address.' })
}
if (data.NODE_ENV === 'production' && !data.WEB3FORMS_ACCESS_KEY) {
context.addIssue({ code: 'custom', path: ['WEB3FORMS_ACCESS_KEY'], message: 'Required in production to deliver contact messages.' })
if (data.NODE_ENV === 'production') {
if (!data.BREVO_API_KEY) {
context.addIssue({ code: 'custom', path: ['BREVO_API_KEY'], message: 'Required in production for email verification and password reset.' })
}
if (!data.BREVO_SENDER_EMAIL) {
context.addIssue({ code: 'custom', path: ['BREVO_SENDER_EMAIL'], message: 'Required in production; use a verified Brevo sender address.' })
}
if (!data.WEB3FORMS_ACCESS_KEY) {
context.addIssue({ code: 'custom', path: ['WEB3FORMS_ACCESS_KEY'], message: 'Required in production to deliver contact messages.' })
}
}
const needsGoogle = data.STORAGE_DRIVER === 'gdrive' || data.ARCHIVE_STORAGE_DRIVER === 'gdrive'
if (needsGoogle && !data.GOOGLE_SERVICE_ACCOUNT_JSON) {
context.addIssue({ code: 'custom', path: ['GOOGLE_SERVICE_ACCOUNT_JSON'], message: 'Required when STORAGE_DRIVER=gdrive.' })
}
if (needsGoogle && !data.GOOGLE_DRIVE_FOLDER_ID) {
context.addIssue({ code: 'custom', path: ['GOOGLE_DRIVE_FOLDER_ID'], message: 'Required when STORAGE_DRIVER=gdrive.' })
if (needsGoogle) {
if (!data.GOOGLE_SERVICE_ACCOUNT_JSON) {
context.addIssue({ code: 'custom', path: ['GOOGLE_SERVICE_ACCOUNT_JSON'], message: 'Required when STORAGE_DRIVER=gdrive.' })
}
if (!data.GOOGLE_DRIVE_FOLDER_ID) {
context.addIssue({ code: 'custom', path: ['GOOGLE_DRIVE_FOLDER_ID'], message: 'Required when STORAGE_DRIVER=gdrive.' })
}
}
})

const parsed = schema.safeParse(process.env)
const parsed = schema.safeParse(testSafeEnv)

if (!parsed.success) {
const lines = parsed.error.issues.map((issue) => ` - ${issue.path.join('.') || 'env'}: ${issue.message}`)
console.error(`\nInvalid environment configuration:\n${lines.join('\n')}\n\nCopy .env.example to .env and fill in the values.\n`)
console.error('\n==================================================')
console.error('FATAL ENVIRONMENT CONFIGURATION ERROR:')
console.error(lines.join('\n'))
console.error('==================================================\n')
process.exit(1)
}

const data = parsed.data

export const env = Object.freeze({
...data,
isProduction: data.NODE_ENV === 'production',
isTest: data.NODE_ENV === 'test',
// Secure cookies by default in production; override for local HTTPS-less setups.
COOKIE_SECURE: data.COOKIE_SECURE ?? data.NODE_ENV === 'production',
clientOrigins: data.CLIENT_ORIGIN.split(',').map((item) => item.trim().replace(/\/$/, '')).filter(Boolean),
})
8 changes: 7 additions & 1 deletion backend/src/routes/index.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Router } from 'express'
import swaggerUi from 'swagger-ui-express'
import { isDbReady } from '../config/db.js'
import { env } from '../config/env.js'
import { openapi } from '../docs/openapi.js'
import adminRoutes from './adminRoutes.js'
import authRoutes from './authRoutes.js'
Expand All @@ -19,7 +20,12 @@ router.get('/health', (req, res) => {
})

router.get('/docs.json', (req, res) => res.json(openapi))
router.use('/docs', swaggerUi.serve, swaggerUi.setup(openapi, { explorer: true }))
// Kept out of production: swagger-ui-express serves bundled static assets from disk, and
// exposing the full interactive API surface publicly isn't worth it once this is live.
// /docs.json above still works everywhere for tooling that needs the raw spec.
if (!env.isProduction) {
router.use('/docs', swaggerUi.serve, swaggerUi.setup(openapi, { explorer: true }))
}

router.use('/auth', authRoutes)
router.use('/users', userRoutes)
Expand Down
5 changes: 3 additions & 2 deletions backend/src/routes/videoRoutes.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,10 @@ router.get('/', optionalAuthenticate, videoController.list)
router.get('/:id', optionalAuthenticate, validate({ params: idParams }), videoController.get)
router.get('/:id/poster', optionalAuthenticate, validate({ params: idParams }), videoController.poster)

// Published video files are public reference media; unpublished/admin media still requires visibility checks in the service.
// Published video files are public reference media; unpublished/admin media still requires
// visibility checks in the service, which optionalAuthenticate + findVisible() already cover
// for both anonymous and logged-in requesters, so a single route handles every case.
router.get('/:id/file', optionalAuthenticate, validate({ params: idParams }), videoController.file)
router.get('/:id/file', authenticate, validate({ params: idParams }), videoController.file)

// Order matters: log in, check role, then accept the upload, then validate the text fields that came with it.
router.post('/', ...adminOnly, uploadLimiter, videoUpload, validate({ body: createVideoSchema }), videoController.create)
Expand Down
9 changes: 7 additions & 2 deletions backend/src/services/accessRestrictionService.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,12 @@ import { createHash } from 'node:crypto'
import { AccessRestriction } from '../models/AccessRestriction.js'
import { conflict, notFound } from '../utils/AppError.js'

const hash = (value) => createHash('sha256').update(value.trim().toLowerCase()).digest('hex')
// Guarded against undefined/empty input: req.ip can be undefined if TRUST_PROXY is ever
// misconfigured behind a host's proxy, and this used to throw (value.trim() on undefined),
// turning every /api/v1/* request into a 500. An empty value still hashes safely; it just
// means "no IP could be determined" shares one bucket, which is a fine degraded fallback
// as long as TRUST_PROXY is set correctly in normal operation.
const hash = (value) => createHash('sha256').update(String(value || '').trim().toLowerCase()).digest('hex')

function hint(type, value) {
if (type === 'ip') {
Expand Down Expand Up @@ -69,4 +74,4 @@ export const accessRestrictionService = {
}
}
},
}
}
14 changes: 12 additions & 2 deletions backend/src/services/authService.js
Original file line number Diff line number Diff line change
Expand Up @@ -85,20 +85,30 @@ export const authService = {
return { user, token: signToken(user.id, user.authVersion || 0) }
},

/**
* Resend and password-reset both used to `await issueOtp(...)`, which calls out to Brevo.
* An unknown email returned instantly (no user, nothing to await); a known email waited on
* the network call. That timing gap let someone enumerate registered emails just by timing
* the response, even though the response body is identical either way (see authController,
* which always returns the same generic message). Firing the OTP issuance in the background
* closes that gap: both cases now return at roughly the same speed.
*/
async resendVerification(email) {
const user = await userRepo.findByEmail(email, { withOtp: true })
if (!user || user.emailVerified) return false
const sentAt = user.emailVerificationSentAt?.getTime() || 0
if (Date.now() - sentAt < env.AUTH_OTP_RESEND_SECONDS * 1000) return false
return this.issueOtp(user, 'email-verification')
void this.issueOtp(user, 'email-verification').catch((error) => logger.error({ err: error }, 'Could not issue verification OTP'))
return true
},

async requestPasswordReset(email) {
const user = await userRepo.findByEmail(email, { withOtp: true })
if (!user) return false
const sentAt = user.passwordResetSentAt?.getTime() || 0
if (Date.now() - sentAt < env.AUTH_OTP_RESEND_SECONDS * 1000) return false
return this.issueOtp(user, 'password-reset')
void this.issueOtp(user, 'password-reset').catch((error) => logger.error({ err: error }, 'Could not issue password reset OTP'))
return true
},

async resetPassword({ email, code, password }) {
Expand Down
Loading
Loading