diff --git a/.vercelignore b/.vercelignore index 078d4c0..125db0a 100644 --- a/.vercelignore +++ b/.vercelignore @@ -1,7 +1,6 @@ node_modules dist coverage -backend uploads *.log .env diff --git a/backend/.env.example b/backend/.env.example index 13f25c2..0537b7b 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -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 @@ -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 diff --git a/backend/package.json b/backend/package.json index 1bf2161..47baa67 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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" }, diff --git a/backend/server.js b/backend/server.js index 63c1f48..0f38660 100644 --- a/backend/server.js +++ b/backend/server.js @@ -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 diff --git a/backend/src/app.js b/backend/src/app.js index 74facf6..3e24d80 100644 --- a/backend/src/app.js +++ b/backend/src/app.js @@ -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' @@ -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({ @@ -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) diff --git a/backend/src/config/env.js b/backend/src/config/env.js index c257d61..18679ad 100644 --- a/backend/src/config/env.js +++ b/backend/src/config/env.js @@ -1,7 +1,32 @@ -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'), @@ -9,29 +34,31 @@ const schema = z.object({ 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'), @@ -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), }) diff --git a/backend/src/routes/index.js b/backend/src/routes/index.js index ee3f7f2..400b63d 100644 --- a/backend/src/routes/index.js +++ b/backend/src/routes/index.js @@ -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' @@ -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) diff --git a/backend/src/routes/videoRoutes.js b/backend/src/routes/videoRoutes.js index 9d0f6a9..50b0e80 100644 --- a/backend/src/routes/videoRoutes.js +++ b/backend/src/routes/videoRoutes.js @@ -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) diff --git a/backend/src/services/accessRestrictionService.js b/backend/src/services/accessRestrictionService.js index 736c65a..d8c590b 100644 --- a/backend/src/services/accessRestrictionService.js +++ b/backend/src/services/accessRestrictionService.js @@ -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') { @@ -69,4 +74,4 @@ export const accessRestrictionService = { } } }, -} \ No newline at end of file +} diff --git a/backend/src/services/authService.js b/backend/src/services/authService.js index 5119dfc..f820c7d 100644 --- a/backend/src/services/authService.js +++ b/backend/src/services/authService.js @@ -85,12 +85,21 @@ 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) { @@ -98,7 +107,8 @@ export const authService = { 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 }) { diff --git a/backend/src/services/initialization.js b/backend/src/services/initialization.js new file mode 100644 index 0000000..cc1e837 --- /dev/null +++ b/backend/src/services/initialization.js @@ -0,0 +1,52 @@ +import { connectDb, isDbReady } from '../config/db.js' +import { env } from '../config/env.js' +import { authService } from './authService.js' +import { storageService } from './storage/index.js' +import { logger } from '../utils/logger.js' + +let initPromise = null + +export function initServices() { + if (!initPromise) { + initPromise = (async () => { + console.log('[INIT] Connecting to MongoDB...') + if (!isDbReady()) await connectDb() + + console.log('[INIT] Initializing Storage Service...') + await storageService.init() + + console.log('[INIT] Bootstrapping Admin Account...') + await authService.ensureAdminFromEnv() + + console.log('[INIT] All services initialized successfully.') + })().catch((error) => { + initPromise = null + console.error('[INIT FATAL ERROR]', error) + logger.fatal({ err: error }, 'Failed to initialize background services') + throw error + }) + } + return initPromise +} + +export async function ensureServicesInitialized(req, res, next) { + if (req.method === 'OPTIONS') return next() + try { + await initServices() + return next() + } catch (error) { + const origin = req.headers.origin + if (origin && env.clientOrigins?.includes(origin)) { + res.setHeader('Access-Control-Allow-Origin', origin) + res.setHeader('Access-Control-Allow-Credentials', 'true') + res.setHeader('Vary', 'Origin') + } + return res.status(503).json({ + error: { + code: 'SERVICE_UNAVAILABLE', + message: 'Service initializing or misconfigured.', + detail: error?.message, + }, + }) + } +} \ No newline at end of file diff --git a/backend/src/services/storage/googleDriveDriver.js b/backend/src/services/storage/googleDriveDriver.js index 87193a5..052a4b1 100644 --- a/backend/src/services/storage/googleDriveDriver.js +++ b/backend/src/services/storage/googleDriveDriver.js @@ -1,3 +1,4 @@ +import fs from 'node:fs' import { createReadStream } from 'node:fs' import { google } from 'googleapis' import { randomUUID } from 'node:crypto' @@ -7,12 +8,63 @@ const FOLDER_MIME = 'application/vnd.google-apps.folder' const escapeQueryValue = (value) => String(value).replace(/\\/g, '\\\\').replace(/'/g, "\\'") -export function createGoogleDriveDriver({ serviceAccountJson, rootFolderId }) { - const credentials = JSON.parse(serviceAccountJson) +/** + * Safely parses service account credentials from raw strings or env vars. + * Handles double-escaped strings, trailing quotes, and formatted newlines. + */ +function parseCredentials(raw) { + if (!raw) return null + if (typeof raw === 'object') return raw + + let cleaned = String(raw).trim() + + // Strip wrapping outer quotes if passed from stringified env configs + if ( + (cleaned.startsWith('"') && cleaned.endsWith('"')) || + (cleaned.startsWith("'") && cleaned.endsWith("'")) + ) { + cleaned = cleaned.slice(1, -1) + } + + let credentials + try { + credentials = JSON.parse(cleaned) + // Handle double-encoded JSON strings + if (typeof credentials === 'string') { + credentials = JSON.parse(credentials) + } + } catch (err) { + throw new Error(`Failed to parse Service Account JSON: ${err.message}`) + } + + // Fix escaped line breaks in private_key for OpenSSL compatibility + if (credentials?.private_key) { + credentials.private_key = credentials.private_key.replace(/\\n/g, '\n') + } + + return credentials +} + +export function createGoogleDriveDriver({ serviceAccountJson, rootFolderId, driverName = 'gdrive' }) { + const credentials = parseCredentials(serviceAccountJson) + + if (!credentials) { + throw new Error(`[${driverName}] Missing valid service account credentials.`) + } + if (!rootFolderId) { + throw new Error(`[${driverName}] Missing rootFolderId.`) + } + const auth = new google.auth.GoogleAuth({ credentials, scopes: [DRIVE_SCOPE] }) const drive = google.drive({ version: 'v3', auth }) const folderIds = new Map() + async function accessToken() { + const client = await auth.getClient() + const token = await client.getAccessToken() + return token.token + } + async function folderIdFor(folderPath) { let parentId = rootFolderId let cacheKey = '' @@ -27,12 +79,14 @@ export function createGoogleDriveDriver({ serviceAccountJson, rootFolderId }) { fields: 'files(id)', pageSize: 1, spaces: 'drive', + supportsAllDrives: true, }) let id = listed.data.files?.[0]?.id if (!id) { const created = await drive.files.create({ requestBody: { name, mimeType: FOLDER_MIME, parents: [parentId] }, fields: 'id', + supportsAllDrives: true, }) id = created.data.id } @@ -42,39 +96,77 @@ export function createGoogleDriveDriver({ serviceAccountJson, rootFolderId }) { return parentId } + async function uploadWithResumableSession({ tempPath, parentId, fileName, mimeType, ext }) { + const fileSize = (await fs.promises.stat(tempPath)).size + const suffix = ext ? (ext.startsWith('.') ? ext : `.${ext}`) : '' + const metadata = { name: fileName || `${randomUUID()}${suffix}`, parents: [parentId], mimeType } + const token = await accessToken() + const session = await fetch('https://www.googleapis.com/upload/drive/v3/files?uploadType=resumable&supportsAllDrives=true', { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json; charset=UTF-8', + 'X-Upload-Content-Type': mimeType, + 'X-Upload-Content-Length': String(fileSize), + }, + body: JSON.stringify(metadata), + }) + + if (!session.ok) { + const text = await session.text() + throw new Error(`Google Drive upload session creation failed (${session.status}): ${text}`) + } + + const uploadUrl = session.headers.get('Location') + if (!uploadUrl) throw new Error('Google Drive upload session is missing the upload URL.') + + const finalResponse = await fetch(uploadUrl, { + method: 'PUT', + headers: { + 'Content-Type': mimeType, + 'Content-Length': String(fileSize), + }, + body: createReadStream(tempPath), + }) + + if (!finalResponse.ok) { + const text = await finalResponse.text() + throw new Error(`Google Drive single-request upload failed (${finalResponse.status}): ${text}`) + } + + const json = await finalResponse.json().catch(() => ({})) + const key = json.id || json.fileId + if (!key) throw new Error('Google Drive upload succeeded but no file id was returned.') + return { key } + } + return { - name: 'gdrive', + name: driverName, tempDir: null, async init() { - await drive.files.get({ fileId: rootFolderId, fields: 'id, trashed' }) + await drive.files.get({ fileId: rootFolderId, fields: 'id, trashed', supportsAllDrives: true }) }, async save({ tempPath, ext = '', folder, fileName, mimeType }) { const parentId = await folderIdFor(folder) - const suffix = ext ? (ext.startsWith('.') ? ext : `.${ext}`) : '' - const uploaded = await drive.files.create({ - requestBody: { name: fileName || `${randomUUID()}${suffix}`, parents: [parentId], mimeType }, - media: { mimeType, body: createReadStream(tempPath) }, - fields: 'id', - }) - return { key: uploaded.data.id } + return uploadWithResumableSession({ tempPath, parentId, fileName, mimeType, ext }) }, async stat(key) { - const response = await drive.files.get({ fileId: key, fields: 'size' }) + const response = await drive.files.get({ fileId: key, fields: 'size', supportsAllDrives: true }) return { size: Number(response.data.size || 0) } }, async createReadStream(key, range) { const headers = range ? { Range: `bytes=${range.start}-${range.end}` } : undefined - const response = await drive.files.get({ fileId: key, alt: 'media' }, { responseType: 'stream', headers }) + const response = await drive.files.get({ fileId: key, alt: 'media', supportsAllDrives: true }, { responseType: 'stream', headers }) return response.data }, async remove(key) { try { - await drive.files.delete({ fileId: key }) + await drive.files.delete({ fileId: key, supportsAllDrives: true }) } catch (error) { if (error.code !== 404) throw error } diff --git a/backend/src/services/storage/index.js b/backend/src/services/storage/index.js index 45fa4f7..e62ac68 100644 --- a/backend/src/services/storage/index.js +++ b/backend/src/services/storage/index.js @@ -1,51 +1,118 @@ import fs from 'node:fs/promises' +import os from 'node:os' import path from 'node:path' import { env } from '../../config/env.js' import { logger } from '../../utils/logger.js' import { createGoogleDriveDriver } from './googleDriveDriver.js' import { createLocalDriver } from './localDriver.js' +const localDriver = createLocalDriver( + path.resolve(process.env.VERCEL ? os.tmpdir() : process.cwd(), env.UPLOAD_DIR) +) + +/** + * Builds a Google Drive driver instance with a explicit instance name + * so that primary ('gdrive') and archive ('gdrive-archive') drivers can coexist seamlessly. + */ +const buildGdrive = (driverName, { serviceAccountJson, rootFolderId }) => + createGoogleDriveDriver({ + serviceAccountJson, + rootFolderId, + driverName, + }) + +// Base videos/posters/demo: primary account + folder +const baseGdrive = + env.STORAGE_DRIVER === 'gdrive' + ? buildGdrive('gdrive', { + serviceAccountJson: env.GOOGLE_SERVICE_ACCOUNT_JSON, + rootFolderId: env.GOOGLE_DRIVE_FOLDER_ID, + }) + : null + +// Archived recordings: secondary account/folder when GOOGLE_ARCHIVE_* env vars are set; +// falls back to shared primary credentials when archive vars are omitted. +const archiveGdrive = + env.ARCHIVE_STORAGE_DRIVER === 'gdrive' + ? buildGdrive('gdrive-archive', { + serviceAccountJson: + env.GOOGLE_ARCHIVE_SERVICE_ACCOUNT_JSON || env.GOOGLE_SERVICE_ACCOUNT_JSON, + rootFolderId: + env.GOOGLE_ARCHIVE_DRIVE_FOLDER_ID || env.GOOGLE_DRIVE_FOLDER_ID, + }) + : null + const drivers = { - local: createLocalDriver(path.resolve(env.UPLOAD_DIR)), - ...(env.STORAGE_DRIVER === 'gdrive' || env.ARCHIVE_STORAGE_DRIVER === 'gdrive' ? { gdrive: createGoogleDriveDriver({ serviceAccountJson: env.GOOGLE_SERVICE_ACCOUNT_JSON, rootFolderId: env.GOOGLE_DRIVE_FOLDER_ID }) } : {}), + local: localDriver, + ...(baseGdrive ? { gdrive: baseGdrive } : {}), + ...(archiveGdrive ? { 'gdrive-archive': archiveGdrive } : {}), } + const driverFor = (name) => { - if (!drivers[name]) throw new Error(`Unknown storage driver "${name}"`) - return drivers[name] + const driver = drivers[name] + if (!driver) throw new Error(`Unknown storage driver "${name}"`) + return driver } -// New files go to the configured driver. Existing files keep using the driver recorded on them, -// so switching to Google Drive later does not orphan anything already uploaded. + +// New files use the driver configured for their intent. +// Stored records keep their recorded driver name ('gdrive' vs 'gdrive-archive') +// so switching configuration never breaks or orphans existing uploads. const defaultDriver = () => driverFor(env.STORAGE_DRIVER) +const archiveDriverName = () => + env.ARCHIVE_STORAGE_DRIVER === 'gdrive' ? 'gdrive-archive' : env.ARCHIVE_STORAGE_DRIVER export const storageService = { - tempDir: drivers.local.tempDir, + tempDir: localDriver.tempDir, init: () => Promise.all(Object.values(drivers).map((driver) => driver.init?.())), - /** Takes a finished temp upload and returns the reference to store on a document. */ + /** Saves standard upload files to the primary storage driver. */ async save({ tempPath, originalName = '', mimeType, ext }, { folder }) { const driver = defaultDriver() const { key } = await driver.save({ tempPath, ext, folder, mimeType }) const { size } = await driver.stat(key) - return { driver: driver.name, key, mimeType, size, originalName: originalName.slice(0, 200) } + return { + driver: driver.name, + key, + mimeType, + size, + originalName: originalName.slice(0, 200), + } }, + /** Saves archive recordings to the dedicated archive storage driver. */ async archive({ tempPath, originalName = '', mimeType, ext, folder, fileName }) { - const driver = driverFor(env.ARCHIVE_STORAGE_DRIVER) + const driver = driverFor(archiveDriverName()) const { size } = await fs.stat(tempPath) const { key } = await driver.save({ tempPath, ext, folder, fileName, mimeType }) - return { driver: driver.name, key, mimeType, size, originalName: originalName.slice(0, 200), archivePath: `${folder}/${fileName || key}` } + return { + driver: driver.name, + key, + mimeType, + size, + originalName: originalName.slice(0, 200), + archivePath: `${folder}/${fileName || key}`, + } }, - /** Descriptor consumed by utils/sendFile. */ + /** Returns readable file stream descriptor by looking up stored driver name. */ async describe(ref) { const driver = driverFor(ref.driver) const { size } = await driver.stat(ref.key) - return { size, mimeType: ref.mimeType, createReadStream: (range) => driver.createReadStream(ref.key, range) } + return { + size, + mimeType: ref.mimeType, + createReadStream: (range) => driver.createReadStream(ref.key, range), + } }, + /** Removes stored file using its associated driver. */ async remove(ref) { if (!ref) return - try { await driverFor(ref.driver).remove(ref.key) } catch (error) { logger.error({ err: error, key: ref.key }, 'Could not delete stored file') } + try { + await driverFor(ref.driver).remove(ref.key) + } catch (error) { + logger.error({ err: error, key: ref.key }, 'Could not delete stored file') + } }, } diff --git a/backend/src/services/videoService.js b/backend/src/services/videoService.js index 4bffede..ec0ce49 100644 --- a/backend/src/services/videoService.js +++ b/backend/src/services/videoService.js @@ -60,7 +60,7 @@ export const videoService = { } const order = input.categoryId ? (await videoRepo.maxOrder(input.categoryId)) + 1 : 1 return await videoRepo.create({ - title: input.title, level: input.level, status: input.status, durationSec: input.durationSec, + title: input.title, status: input.status, durationSec: input.durationSec, category: input.categoryId, order, videoFile, posterFile, createdBy: admin._id, }) } catch (error) { @@ -80,7 +80,7 @@ export const videoService = { // Moving into a category puts the video at the end of that path unless an order was given. if (moved && patch.categoryId && patch.order === undefined) video.order = (await videoRepo.maxOrder(patch.categoryId)) + 1 } - for (const key of ['title', 'level', 'status', 'order']) { + for (const key of ['title', 'status', 'order']) { if (patch[key] !== undefined) video[key] = patch[key] } return videoRepo.save(video) diff --git a/backend/src/utils/parseCredentials.js b/backend/src/utils/parseCredentials.js new file mode 100644 index 0000000..cacfd80 --- /dev/null +++ b/backend/src/utils/parseCredentials.js @@ -0,0 +1,35 @@ +// src/utils/parseCredentials.js + +export function parseServiceAccountCredentials(rawEnvVar) { + if (!rawEnvVar) { + throw new Error('Service account credentials environment variable is missing.'); + } + + let creds = rawEnvVar.trim(); + + // 1. Strip surrounding quotes if the env variable was saved with extra wrapping quotes + if ( + (creds.startsWith('"') && creds.endsWith('"')) || + (creds.startsWith("'") && creds.endsWith("'")) + ) { + creds = creds.slice(1, -1); + } + + // 2. Parse JSON (with a fallback double-parse if it was double-encoded) + let parsed; + try { + parsed = typeof creds === 'string' ? JSON.parse(creds) : creds; + if (typeof parsed === 'string') { + parsed = JSON.parse(parsed); // Handles double-stringified JSON + } + } catch (err) { + throw new Error(`Failed to parse Service Account JSON: ${err.message}`); + } + + // 3. Normalize private key newlines (essential for OpenSSL / Google Auth) + if (parsed.private_key) { + parsed.private_key = parsed.private_key.replace(/\\n/g, '\n'); + } + + return parsed; +} diff --git a/frontend/src/utils/media.js b/frontend/src/utils/media.js index 7e0c06b..a0fd7c2 100644 --- a/frontend/src/utils/media.js +++ b/frontend/src/utils/media.js @@ -10,7 +10,7 @@ export function inspectVideoFile(file) { video.preload = 'metadata' video.muted = true video.playsInline = true - video.onerror = () => finish({ duration: 0, poster: '' }) + video.onerror = () => finish({ durationSec: 0, poster: '' }) video.onloadedmetadata = () => { video.currentTime = Math.min(1, (video.duration || 0) / 2) } @@ -21,9 +21,9 @@ export function inspectVideoFile(file) { canvas.width = width canvas.height = Math.round((video.videoHeight / (video.videoWidth || 1)) * width) || 360 canvas.getContext('2d').drawImage(video, 0, 0, canvas.width, canvas.height) - finish({ duration: Math.round(video.duration || 0), poster: canvas.toDataURL('image/jpeg', 0.7) }) + finish({ durationSec: Math.round(video.duration || 0), poster: canvas.toDataURL('image/jpeg', 0.7) }) } catch { - finish({ duration: Math.round(video.duration || 0), poster: '' }) + finish({ durationSec: Math.round(video.duration || 0), poster: '' }) } } video.src = url