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: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -31,5 +31,6 @@ react-snap-routes.json
*.njsproj
*.sln
*.sw?
*.tmp

%SystemDrive%/
12 changes: 8 additions & 4 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -49,17 +49,21 @@ STORAGE_DRIVER=local
ARCHIVE_STORAGE_DRIVER=local
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_OAUTH_CLIENT_ID=<your client id>
# GOOGLE_OAUTH_CLIENT_SECRET=<your client secret>
# GOOGLE_OAUTH_REFRESH_TOKEN=<the refresh token>
# 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_OAUTH_CLIENT_ID=<your client id>
# GOOGLE_ARCHIVE_OAUTH_CLIENT_SECRET=<your client secret>
# GOOGLE_ARCHIVE_OAUTH_REFRESH_TOKEN=
# GOOGLE_ARCHIVE_DRIVE_FOLDER_ID=the-second-account-folder-id

MAX_VIDEO_UPLOAD_MB=300
MAX_RECORDING_UPLOAD_MB=100
MAX_VIDEO_UPLOAD_MB=90
MAX_RECORDING_UPLOAD_MB=80

RATE_LIMIT_ENABLED=true

Expand Down
2 changes: 1 addition & 1 deletion backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
"license": "MIT",
"type": "module",
"engines": {
"node": ">=20"
"node": ">=22"
},
"dependencies": {
"@getbrevo/brevo": "^6.0.3",
Expand Down
3 changes: 1 addition & 2 deletions backend/src/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,7 @@ app.use(pinoHttp({
customLogLevel: (req, res, error) => (error || res.statusCode >= 500 ? 'error' : res.statusCode >= 400 ? 'warn' : 'info'),
}))

// same-site lets the app on localhost:5173 load videos and posters from the API on localhost:5000.
app.use(helmet({ crossOriginResourcePolicy: { policy: 'same-site' } }))
app.use(helmet({ crossOriginResourcePolicy: { policy: 'cross-origin' } }))
app.use(cors({ origin: env.clientOrigins, credentials: true }))
app.use(compression())
app.use(express.json({ limit: '100kb' }))
Expand Down
66 changes: 60 additions & 6 deletions backend/src/config/env.js
Original file line number Diff line number Diff line change
Expand Up @@ -66,11 +66,17 @@ const schema = z.object({
ARCHIVE_STORAGE_DRIVER: z.enum(['local', 'gdrive']).default('local'),
UPLOAD_DIR: z.string().default('uploads'),
GOOGLE_SERVICE_ACCOUNT_JSON: z.string().optional(),
GOOGLE_OAUTH_CLIENT_ID: z.string().optional(),
GOOGLE_OAUTH_CLIENT_SECRET: z.string().optional(),
GOOGLE_OAUTH_REFRESH_TOKEN: z.string().optional(),
GOOGLE_DRIVE_FOLDER_ID: z.string().optional(),
GOOGLE_ARCHIVE_OAUTH_CLIENT_ID: z.string().optional(),
GOOGLE_ARCHIVE_OAUTH_CLIENT_SECRET: z.string().optional(),
GOOGLE_ARCHIVE_OAUTH_REFRESH_TOKEN: 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),
MAX_VIDEO_UPLOAD_MB: z.coerce.number().positive().default(90),
MAX_RECORDING_UPLOAD_MB: z.coerce.number().positive().default(80),

RATE_LIMIT_ENABLED: bool.default(true),
SUBMISSION_COOLDOWN_MS: z.coerce.number().int().positive().default(30_000),
Expand All @@ -86,15 +92,63 @@ const schema = z.object({
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) {
if (!data.GOOGLE_SERVICE_ACCOUNT_JSON) {
context.addIssue({ code: 'custom', path: ['GOOGLE_SERVICE_ACCOUNT_JSON'], message: 'Required when STORAGE_DRIVER=gdrive.' })
const hasOauth = (clientId, clientSecret, refreshToken) =>
Boolean(clientId && clientSecret && refreshToken)
const baseOauth = hasOauth(
data.GOOGLE_OAUTH_CLIENT_ID,
data.GOOGLE_OAUTH_CLIENT_SECRET,
data.GOOGLE_OAUTH_REFRESH_TOKEN
)
const archiveOauth = hasOauth(
data.GOOGLE_ARCHIVE_OAUTH_CLIENT_ID,
data.GOOGLE_ARCHIVE_OAUTH_CLIENT_SECRET,
data.GOOGLE_ARCHIVE_OAUTH_REFRESH_TOKEN
)
const hasAny = (...values) => values.some(Boolean)

for (const [prefix, values] of [
['GOOGLE_OAUTH', [
data.GOOGLE_OAUTH_CLIENT_ID,
data.GOOGLE_OAUTH_CLIENT_SECRET,
data.GOOGLE_OAUTH_REFRESH_TOKEN,
]],
['GOOGLE_ARCHIVE_OAUTH', [
data.GOOGLE_ARCHIVE_OAUTH_CLIENT_ID,
data.GOOGLE_ARCHIVE_OAUTH_CLIENT_SECRET,
data.GOOGLE_ARCHIVE_OAUTH_REFRESH_TOKEN,
]],
]) {
if (hasAny(...values) && !values.every(Boolean)) {
context.addIssue({
code: 'custom',
path: [`${prefix}_CLIENT_ID`],
message: 'Set all three OAuth client ID, client secret, and refresh token variables together.',
})
}
}

if (data.STORAGE_DRIVER === 'gdrive') {
if (!baseOauth && !data.GOOGLE_SERVICE_ACCOUNT_JSON) {
context.addIssue({ code: 'custom', path: ['GOOGLE_OAUTH_REFRESH_TOKEN'], message: 'Set the Google OAuth variables or a service-account JSON.' })
}
if (!data.GOOGLE_DRIVE_FOLDER_ID) {
context.addIssue({ code: 'custom', path: ['GOOGLE_DRIVE_FOLDER_ID'], message: 'Required when STORAGE_DRIVER=gdrive.' })
}
}

if (data.ARCHIVE_STORAGE_DRIVER === 'gdrive') {
const archiveHasCredentials =
archiveOauth ||
data.GOOGLE_ARCHIVE_SERVICE_ACCOUNT_JSON ||
baseOauth ||
data.GOOGLE_SERVICE_ACCOUNT_JSON
if (!archiveHasCredentials) {
context.addIssue({ code: 'custom', path: ['GOOGLE_ARCHIVE_OAUTH_REFRESH_TOKEN'], message: 'Set the archive Google OAuth variables, shared Google credentials, or an archive service-account JSON.' })
}
if (!data.GOOGLE_ARCHIVE_DRIVE_FOLDER_ID && !data.GOOGLE_DRIVE_FOLDER_ID) {
context.addIssue({ code: 'custom', path: ['GOOGLE_ARCHIVE_DRIVE_FOLDER_ID'], message: 'Required when ARCHIVE_STORAGE_DRIVER=gdrive and no shared Drive folder is configured.' })
}
}
})

const parsed = schema.safeParse(testSafeEnv)
Expand Down
12 changes: 11 additions & 1 deletion backend/src/controllers/videoController.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,17 @@ import { sendFile } from '../utils/sendFile.js'
export const videoController = {
list: asyncHandler(async (req, res) => ok(res, await videoService.list(req.user))),
get: asyncHandler(async (req, res) => ok(res, await videoService.get(req.params.id, req.user))),
create: asyncHandler(async (req, res) => created(res, await videoService.create(req.user, req.body, req.files))),

create: asyncHandler(async (req, res) => {
try {
const result = await videoService.create(req.user, req.body, req.files)
return created(res, result)
} catch (err) {
console.error('UPLOAD ERROR:', err.message, err.status, err.stack)
throw err // Re-throws to express global error handler
}
}),

update: asyncHandler(async (req, res) => ok(res, await videoService.update(req.params.id, req.body))),
remove: asyncHandler(async (req, res) => { await videoService.remove(req.params.id); noContent(res) }),
file: asyncHandler(async (req, res) => sendFile(req, res, await videoService.getFile(req.params.id, req.user))),
Expand Down
105 changes: 51 additions & 54 deletions backend/src/services/storage/googleDriveDriver.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import fs from 'node:fs'
import { createReadStream } from 'node:fs'
import { google } from 'googleapis'
import { randomUUID } from 'node:crypto'
import { logger } from '../../utils/logger.js'

const DRIVE_SCOPE = 'https://www.googleapis.com/auth/drive'
const FOLDER_MIME = 'application/vnd.google-apps.folder'
Expand Down Expand Up @@ -45,30 +45,29 @@ function parseCredentials(raw) {
return credentials
}

export function createGoogleDriveDriver({ serviceAccountJson, rootFolderId, driverName = 'gdrive' }) {
const credentials = parseCredentials(serviceAccountJson)

if (!credentials) {
throw new Error(`[${driverName}] Missing valid service account credentials.`)
export function createGoogleDriveDriver({ serviceAccountJson, oauth, rootFolderId, driverName = 'gdrive' }) {
if (!rootFolderId) throw new Error(`[${driverName}] Missing rootFolderId.`)

let auth
if (oauth?.clientId && oauth?.clientSecret && oauth?.refreshToken) {
auth = new google.auth.OAuth2(oauth.clientId, oauth.clientSecret)
auth.setCredentials({ refresh_token: oauth.refreshToken })
} else {
const credentials = parseCredentials(serviceAccountJson)
if (!credentials) throw new Error(`[${driverName}] Missing Google credentials.`)
auth = new google.auth.GoogleAuth({ credentials, scopes: [DRIVE_SCOPE] })
}
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) {
if (!folderPath) return rootFolderId

let parentId = rootFolderId
let cacheKey = ''
for (const name of folderPath.split('/').filter(Boolean)) {
const segments = String(folderPath).split('/').filter(Boolean)

for (const name of segments) {
cacheKey = `${cacheKey}/${name}`
if (folderIds.has(cacheKey)) {
parentId = folderIds.get(cacheKey)
Expand All @@ -80,6 +79,7 @@ export function createGoogleDriveDriver({ serviceAccountJson, rootFolderId, driv
pageSize: 1,
spaces: 'drive',
supportsAllDrives: true,
includeItemsFromAllDrives: true,
})
let id = listed.data.files?.[0]?.id
if (!id) {
Expand All @@ -97,46 +97,43 @@ export function createGoogleDriveDriver({ serviceAccountJson, rootFolderId, driv
}

async function uploadWithResumableSession({ tempPath, parentId, fileName, mimeType, ext }) {
const fileSize = (await fs.promises.stat(tempPath)).size
const safeMime = mimeType || 'video/mp4'
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 uploadName = fileName || `${randomUUID()}${suffix}`

let res
try {
res = await drive.files.create({
requestBody: { name: uploadName, parents: [parentId], mimeType: safeMime },
media: { mimeType: safeMime, body: createReadStream(tempPath) },
fields: 'id',
supportsAllDrives: true,
})
} catch (err) {
const responseText =
typeof err?.response?.data === 'string'
? err.response.data
: JSON.stringify(err?.response?.data ?? err?.errors ?? err?.message ?? {})
logger.error(
{
err,
googleStatus: err?.code ?? err?.response?.status,
googleBody: err?.response?.data ?? err?.errors,
responseText,
},
'Google Drive upload session creation failed'
)
throw new Error(
`Google Drive upload session creation failed (${err?.code ?? err?.response?.status ?? 'unknown'}): ${responseText}`
)
}

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 key = res.data.id
if (!key) {
const responseText = 'Google Drive upload succeeded but no file id was returned.'
logger.error({ responseText }, 'Google Drive single-request upload failed')
throw new Error(`Google Drive single-request upload failed: ${responseText}`)
}

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 }
}

Expand Down
Loading
Loading