diff --git a/.gitignore b/.gitignore index 72bc7dd..d68fca0 100644 --- a/.gitignore +++ b/.gitignore @@ -31,5 +31,6 @@ react-snap-routes.json *.njsproj *.sln *.sw? +*.tmp %SystemDrive%/ diff --git a/backend/.env.example b/backend/.env.example index 0537b7b..c8eedaa 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -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= +# GOOGLE_OAUTH_CLIENT_SECRET= +# GOOGLE_OAUTH_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= +# GOOGLE_ARCHIVE_OAUTH_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 diff --git a/backend/package.json b/backend/package.json index 47baa67..af1fcfc 100644 --- a/backend/package.json +++ b/backend/package.json @@ -12,7 +12,7 @@ "license": "MIT", "type": "module", "engines": { - "node": ">=20" + "node": ">=22" }, "dependencies": { "@getbrevo/brevo": "^6.0.3", diff --git a/backend/src/app.js b/backend/src/app.js index 3e24d80..f8d240e 100644 --- a/backend/src/app.js +++ b/backend/src/app.js @@ -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' })) diff --git a/backend/src/config/env.js b/backend/src/config/env.js index 18679ad..ee3880d 100644 --- a/backend/src/config/env.js +++ b/backend/src/config/env.js @@ -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), @@ -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) diff --git a/backend/src/controllers/videoController.js b/backend/src/controllers/videoController.js index f294963..897aa0c 100644 --- a/backend/src/controllers/videoController.js +++ b/backend/src/controllers/videoController.js @@ -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))), diff --git a/backend/src/services/storage/googleDriveDriver.js b/backend/src/services/storage/googleDriveDriver.js index 052a4b1..8bcccd4 100644 --- a/backend/src/services/storage/googleDriveDriver.js +++ b/backend/src/services/storage/googleDriveDriver.js @@ -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' @@ -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) @@ -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) { @@ -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 } } diff --git a/backend/src/services/storage/index.js b/backend/src/services/storage/index.js index e62ac68..c7b04f8 100644 --- a/backend/src/services/storage/index.js +++ b/backend/src/services/storage/index.js @@ -2,6 +2,7 @@ import fs from 'node:fs/promises' import os from 'node:os' import path from 'node:path' import { env } from '../../config/env.js' +import { AppError } from '../../utils/AppError.js' import { logger } from '../../utils/logger.js' import { createGoogleDriveDriver } from './googleDriveDriver.js' import { createLocalDriver } from './localDriver.js' @@ -14,9 +15,24 @@ const localDriver = createLocalDriver( * 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 }) => +const baseOauth = { + clientId: env.GOOGLE_OAUTH_CLIENT_ID, + clientSecret: env.GOOGLE_OAUTH_CLIENT_SECRET, + refreshToken: env.GOOGLE_OAUTH_REFRESH_TOKEN, +} + +const archiveOauth = { + clientId: env.GOOGLE_ARCHIVE_OAUTH_CLIENT_ID, + clientSecret: env.GOOGLE_ARCHIVE_OAUTH_CLIENT_SECRET, + refreshToken: env.GOOGLE_ARCHIVE_OAUTH_REFRESH_TOKEN, +} + +const hasArchiveOauth = Object.values(archiveOauth).every(Boolean) + +const buildGdrive = (driverName, { serviceAccountJson, oauth, rootFolderId }) => createGoogleDriveDriver({ serviceAccountJson, + oauth, rootFolderId, driverName, }) @@ -26,6 +42,7 @@ const baseGdrive = env.STORAGE_DRIVER === 'gdrive' ? buildGdrive('gdrive', { serviceAccountJson: env.GOOGLE_SERVICE_ACCOUNT_JSON, + oauth: baseOauth, rootFolderId: env.GOOGLE_DRIVE_FOLDER_ID, }) : null @@ -37,6 +54,11 @@ const archiveGdrive = ? buildGdrive('gdrive-archive', { serviceAccountJson: env.GOOGLE_ARCHIVE_SERVICE_ACCOUNT_JSON || env.GOOGLE_SERVICE_ACCOUNT_JSON, + oauth: hasArchiveOauth + ? archiveOauth + : env.GOOGLE_ARCHIVE_SERVICE_ACCOUNT_JSON + ? null + : baseOauth, rootFolderId: env.GOOGLE_ARCHIVE_DRIVE_FOLDER_ID || env.GOOGLE_DRIVE_FOLDER_ID, }) @@ -61,48 +83,73 @@ const defaultDriver = () => driverFor(env.STORAGE_DRIVER) const archiveDriverName = () => env.ARCHIVE_STORAGE_DRIVER === 'gdrive' ? 'gdrive-archive' : env.ARCHIVE_STORAGE_DRIVER +const throwStorageError = (err, action) => { + logger.error( + { + err, + action, + googleStatus: err?.code ?? err?.response?.status, + googleBody: err?.response?.data ?? err?.errors, + }, + 'Storage operation failed' + ) + throw new AppError(502, 'STORAGE_ERROR', 'The video could not be stored. Try again later.') +} + export const storageService = { tempDir: localDriver.tempDir, init: () => Promise.all(Object.values(drivers).map((driver) => driver.init?.())), /** Saves standard upload files to the primary storage driver. */ - async save({ tempPath, originalName = '', mimeType, ext }, { folder }) { + async save({ tempPath, originalName = '', mimeType, ext }, { folder, fileName }) { 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), + try { + const { key } = await driver.save({ tempPath, ext, folder, fileName, mimeType }) + const { size } = await driver.stat(key) + return { + driver: driver.name, + key, + mimeType, + size, + originalName: originalName.slice(0, 200), + } + } catch (err) { + throwStorageError(err, 'save') } }, /** Saves archive recordings to the dedicated archive storage driver. */ async archive({ tempPath, originalName = '', mimeType, ext, folder, fileName }) { 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}`, + try { + 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}`, + } + } catch (err) { + throwStorageError(err, 'save') } }, /** 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), + try { + const { size } = await driver.stat(ref.key) + return { + size, + mimeType: ref.mimeType, + createReadStream: (range) => driver.createReadStream(ref.key, range), + } + } catch (err) { + throwStorageError(err, 'stat') } }, @@ -111,8 +158,8 @@ export const storageService = { 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') + } catch (err) { + throwStorageError(err, 'remove') } }, } diff --git a/backend/src/services/storage/localDriver.js b/backend/src/services/storage/localDriver.js index 8a4f29b..869431e 100644 --- a/backend/src/services/storage/localDriver.js +++ b/backend/src/services/storage/localDriver.js @@ -7,7 +7,7 @@ import { randomUUID } from 'node:crypto' * STORAGE DRIVER CONTRACT * Any driver (local disk now, Google Drive later) implements these four methods and nothing else: * - * save({ tempPath, ext, folder }) -> Promise<{ key }> move/upload a finished temp file, return an opaque key + * save({ tempPath, ext, folder, fileName }) -> Promise<{ key }> move/upload a finished temp file, return an opaque key * stat(key) -> Promise<{ size }> throws if the file is gone * createReadStream(key, { start, end }) -> Readable inclusive byte range, for HTTP Range requests * remove(key) -> Promise idempotent @@ -35,7 +35,15 @@ export function createLocalDriver(rootDir) { }, async save({ tempPath, ext = '', folder, fileName }) { - const key = `${folder}/${fileName || `${randomUUID()}${ext}`}` + let key = `${folder}/${fileName || `${randomUUID()}${ext}`}` + if (fileName) { + const dot = fileName.lastIndexOf('.') + const stem = dot > 0 ? fileName.slice(0, dot) : fileName + const suffix = dot > 0 ? fileName.slice(dot) : '' + for (let n = 2; await fs.stat(resolveKey(key)).then(() => true, () => false); n += 1) { + key = `${folder}/${stem} (${n})${suffix}` + } + } const destination = resolveKey(key) await fs.mkdir(path.dirname(destination), { recursive: true }) try { diff --git a/backend/src/services/videoService.js b/backend/src/services/videoService.js index ec0ce49..59aca85 100644 --- a/backend/src/services/videoService.js +++ b/backend/src/services/videoService.js @@ -2,7 +2,7 @@ import { POSTER_MAX_BYTES, ROLES, VIDEO_STATUS } from '../config/constants.js' import { categoryRepo } from '../repositories/categoryRepo.js' import { videoRepo } from '../repositories/videoRepo.js' import { notFound, validationError } from '../utils/AppError.js' -import { sniffImage, sniffVideo } from '../utils/files.js' +import { safeName, sniffImage, sniffVideo } from '../utils/files.js' import { logger } from '../utils/logger.js' import { storageService } from './storage/index.js' @@ -47,15 +47,24 @@ export const videoService = { posterKind = await sniffImage(posterUpload.path) if (!posterKind) throw validationError({ poster: 'The poster must be a JPEG, PNG or WebP image.' }) } - await assertCategoryExists(input.categoryId) + const category = input.categoryId ? await categoryRepo.findById(input.categoryId) : null + if (input.categoryId && !category) throw validationError({ categoryId: 'That category does not exist.' }) + const folder = `videos/${safeName(category?.label, 'Unassigned')}` + const base = safeName(input.title) const stored = [] try { - const videoFile = await storageService.save({ tempPath: upload.path, originalName: upload.originalname, mimeType: videoKind.mimeType, ext: videoKind.ext }, { folder: 'videos' }) + const videoFile = await storageService.save( + { tempPath: upload.path, originalName: upload.originalname, mimeType: videoKind.mimeType, ext: videoKind.ext }, + { folder, fileName: `${base}${videoKind.ext}` }, + ) stored.push(videoFile) let posterFile = null if (posterUpload) { - posterFile = await storageService.save({ tempPath: posterUpload.path, originalName: posterUpload.originalname, mimeType: posterKind.mimeType, ext: posterKind.ext }, { folder: 'posters' }) + posterFile = await storageService.save( + { tempPath: posterUpload.path, originalName: posterUpload.originalname, mimeType: posterKind.mimeType, ext: posterKind.ext }, + { folder, fileName: `${base} (poster)${posterKind.ext}` }, + ) stored.push(posterFile) } const order = input.categoryId ? (await videoRepo.maxOrder(input.categoryId)) + 1 : 1 diff --git a/backend/src/utils/files.js b/backend/src/utils/files.js index ae5e6d1..6ca12e6 100644 --- a/backend/src/utils/files.js +++ b/backend/src/utils/files.js @@ -3,6 +3,9 @@ import fs from 'node:fs/promises' /** Best-effort delete; a missing file is fine. */ export const removeQuietly = async (path) => { if (path) await fs.unlink(path).catch(() => {}) } +export const safeName = (value, fallback = 'Untitled') => + String(value ?? '').replace(/[\\\/:*?"<>|]+/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 100) || fallback + async function readHead(path, length = 16) { const handle = await fs.open(path, 'r') try { diff --git a/backend/src/utils/sendFile.js b/backend/src/utils/sendFile.js index 43f625e..b1f4038 100644 --- a/backend/src/utils/sendFile.js +++ b/backend/src/utils/sendFile.js @@ -2,34 +2,38 @@ * Streams a stored file with HTTP Range support, which browsers need for seeking inside