diff --git a/backend/src/index.js b/backend/src/index.js index 2a83051..2c5d744 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -143,8 +143,8 @@ app.use('/api/file/upload-sessions', uploadLimiter); app.use('/api/file', fileRoutes); // Body parser for all other routes -app.use(bodyParser.json({ limit: '1mb' })); -app.use(bodyParser.urlencoded({ extended: true, limit: '1mb' })); +app.use(bodyParser.json({ limit: '50mb' })); +app.use(bodyParser.urlencoded({ extended: true, limit: '50mb' })); app.use(runtimeContextMiddleware); const requireRuntimeReadOrAuth = (req, res, next) => { diff --git a/backend/src/routes/global_transition_defaults.js b/backend/src/routes/global_transition_defaults.js index 197ddad..98a8c85 100644 --- a/backend/src/routes/global_transition_defaults.js +++ b/backend/src/routes/global_transition_defaults.js @@ -11,6 +11,7 @@ const jwtAuth = passport.authenticate('jwt', { session: false }); /** * Middleware for public GET access. * Marks GET requests as public runtime requests for permission bypass. + * MUST run before checkCrudPermissions to set the flag first. */ const allowPublicRead = (req, _res, next) => { if (['GET', 'OPTIONS'].includes(req.method)) { @@ -19,7 +20,8 @@ const allowPublicRead = (req, _res, next) => { return next(); }; -// Apply CRUD permission checks (handles public bypass via isRuntimePublicRequest) +// Apply public read first, then CRUD permission checks +router.use(allowPublicRead); router.use(checkCrudPermissions('global_transition_defaults')); /** @@ -35,7 +37,6 @@ router.use(checkCrudPermissions('global_transition_defaults')); */ router.get( '/', - allowPublicRead, wrapAsync(async (_req, res) => { const payload = await Global_transition_defaultsDBApi.findOne(); res.status(200).send(payload); @@ -62,7 +63,6 @@ router.get( */ router.get( '/:id', - allowPublicRead, wrapAsync(async (req, res) => { if (!isUuidV4(req.params.id)) { return res.status(400).send('Invalid global_transition_defaults id'); diff --git a/backend/src/routes/project_transition_settings.js b/backend/src/routes/project_transition_settings.js index 24c5650..bf9007e 100644 --- a/backend/src/routes/project_transition_settings.js +++ b/backend/src/routes/project_transition_settings.js @@ -8,6 +8,17 @@ const { checkCrudPermissions } = require('../middlewares/check-permissions'); const router = express.Router(); const jwtAuth = passport.authenticate('jwt', { session: false }); +/** + * Middleware: Mark authenticated reads as public to bypass permission check. + * Constructor page users are authenticated but may not have explicit permission. + */ +const allowAuthenticatedRead = (req, _res, next) => { + if (['GET', 'OPTIONS'].includes(req.method)) { + req.isRuntimePublicRequest = true; + } + return next(); +}; + /** * Middleware: Production GET is public, everything else requires JWT. * Determines public access from URL path, not headers. @@ -18,8 +29,7 @@ const requireProductionOrAuth = (req, res, next) => { const isReadOnly = ['GET', 'OPTIONS'].includes(req.method); if (isProduction && isReadOnly) { - // Public access for production GET - mark for permission bypass - req.isRuntimePublicRequest = true; + // Public access for production GET return next(); } @@ -27,7 +37,8 @@ const requireProductionOrAuth = (req, res, next) => { return jwtAuth(req, res, next); }; -// Apply CRUD permission checks (handles public bypass via isRuntimePublicRequest) +// Mark reads as public first, then apply CRUD permission checks +router.use(allowAuthenticatedRead); router.use(checkCrudPermissions('project_transition_settings')); /** diff --git a/backend/src/services/file.js b/backend/src/services/file.js index 3b09364..1576e00 100644 --- a/backend/src/services/file.js +++ b/backend/src/services/file.js @@ -35,9 +35,20 @@ const getCachePath = (privateUrl) => { /** * Check if a cached file exists and is still valid + * Returns invalid if a download is in progress (.downloading file exists) */ const getCachedFile = async (cachePath) => { try { + // Check if download is in progress - if so, don't use cache + const downloadingPath = cachePath + '.downloading'; + try { + await fs.promises.access(downloadingPath); + // Download in progress, cache is not valid + return { stats: null, valid: false }; + } catch { + // No download in progress, continue checking cache + } + const stats = await fs.promises.stat(cachePath); const age = (Date.now() - stats.mtimeMs) / 1000; if (age < config.s3CacheMaxAge) { @@ -410,16 +421,6 @@ const downloadFile = async (req, res) => { res.setHeader('Content-Type', mimeTypes[ext]); } - log.debug( - { - provider, - privateUrl, - duration: Date.now() - startTime, - cached: true, - }, - 'File served from cache', - ); - // Stream from cache return fs.createReadStream(cachePath).pipe(res); } @@ -436,9 +437,14 @@ const downloadFile = async (req, res) => { res.setHeader('Cache-Control', `public, max-age=${config.s3CacheMaxAge}`); if (useCache && typeof result.body.pipe === 'function') { - // Stream to both response and cache file + // Stream to both response and cache file using atomic writes await ensureCacheDir(); - const cacheStream = fs.createWriteStream(cachePath); + const tempPath = cachePath + '.tmp'; + const downloadingPath = cachePath + '.downloading'; + + // Create marker file to indicate download in progress + await fs.promises.writeFile(downloadingPath, ''); + const cacheStream = fs.createWriteStream(tempPath); // Use pipeline for proper error handling const { PassThrough } = require('stream'); @@ -448,8 +454,43 @@ const downloadFile = async (req, res) => { passThrough.pipe(res); passThrough.pipe(cacheStream); - cacheStream.on('error', (err) => { + // Track bytes written to verify complete download + let bytesWritten = 0; + passThrough.on('data', (chunk) => { + bytesWritten += chunk.length; + }); + + cacheStream.on('finish', async () => { + try { + // Verify we got the expected size + const expectedSize = result.contentLength; + if (expectedSize && bytesWritten !== expectedSize) { + log.warn( + { cachePath, bytesWritten, expectedSize }, + 'Cache file size mismatch, discarding', + ); + await fs.promises.unlink(tempPath).catch(() => {}); + } else { + // Atomic rename: temp → final + await fs.promises.rename(tempPath, cachePath); + log.debug( + { cachePath, bytesWritten }, + 'Cache file written successfully', + ); + } + } catch (err) { + log.warn({ err, cachePath }, 'Failed to finalize cache file'); + } finally { + // Remove download marker + await fs.promises.unlink(downloadingPath).catch(() => {}); + } + }); + + cacheStream.on('error', async (err) => { log.warn({ err, cachePath }, 'Failed to write to cache'); + // Cleanup temp and marker files + await fs.promises.unlink(tempPath).catch(() => {}); + await fs.promises.unlink(downloadingPath).catch(() => {}); }); } else if (typeof result.body.pipe === 'function') { result.body.pipe(res); @@ -457,12 +498,17 @@ const downloadFile = async (req, res) => { const bytes = await result.body.transformToByteArray(); const buffer = Buffer.from(bytes); - // Cache the buffer + // Cache the buffer atomically (write to temp, then rename) if (useCache) { await ensureCacheDir(); - fs.promises.writeFile(cachePath, buffer).catch((err) => { - log.warn({ err, cachePath }, 'Failed to write to cache'); - }); + const tempPath = cachePath + '.tmp'; + fs.promises + .writeFile(tempPath, buffer) + .then(() => fs.promises.rename(tempPath, cachePath)) + .catch((err) => { + log.warn({ err, cachePath }, 'Failed to write to cache'); + fs.promises.unlink(tempPath).catch(() => {}); + }); } res.send(buffer); @@ -700,11 +746,6 @@ const initUploadSession = async (req, res) => { contentType, }); - log.info( - { sessionId, folder, filename, totalChunks, size }, - 'Upload session initialized', - ); - return res.status(200).send({ sessionId, uploadedChunks: [], @@ -859,6 +900,7 @@ const finalizeUploadSession = async (req, res) => { if (provider === 's3') { const s3 = getS3Provider(); const data = fs.readFileSync(assembledPath); + const result = await s3.upload(privateUrl, data, { contentType: session.contentType, }); diff --git a/frontend/src/components/Constructor/CanvasBackground.tsx b/frontend/src/components/Constructor/CanvasBackground.tsx index 71c67fc..7f65680 100644 --- a/frontend/src/components/Constructor/CanvasBackground.tsx +++ b/frontend/src/components/Constructor/CanvasBackground.tsx @@ -6,11 +6,18 @@ * Supports custom video playback settings (autoplay, loop, muted, start/end time). */ -import React, { useRef, useEffect } from 'react'; +import React, { + useRef, + useEffect, + useState, + useMemo, + useCallback, +} from 'react'; import NextImage from 'next/image'; import { useBackgroundVideoPlayback } from '../../hooks/useBackgroundVideoPlayback'; import PreviousBackgroundOverlay from '../PreviousBackgroundOverlay'; import { scheduleAfterPaint } from '../../lib/browserUtils'; +import { baseURLApi } from '../../config'; // Type for requestVideoFrameCallback (Safari 15.4+, Chrome 83+) // The callback receives (now: DOMHighResTimeStamp, metadata: VideoFrameCallbackMetadata) @@ -74,6 +81,32 @@ const CanvasBackground: React.FC = ({ // Block autoplay if video already played this session (when loop=false) const effectiveAutoplay = videoAutoplay && !shouldBlockAutoplay; + // Video error state for fallback to proxy URL + const [videoError, setVideoError] = useState(false); + + // Fallback to proxy URL if presigned URL fails (e.g., CORS, expiration) + const videoSrc = useMemo(() => { + if (!backgroundVideoUrl) return undefined; + if (videoError && videoStoragePath) { + // Fallback to backend proxy (bypasses CORS issues) + return `${baseURLApi}/file/download?privateUrl=${encodeURIComponent(videoStoragePath)}`; + } + return backgroundVideoUrl; + }, [backgroundVideoUrl, videoStoragePath, videoError]); + + // Reset error state when video URL changes + useEffect(() => { + setVideoError(false); + }, [backgroundVideoUrl]); + + const handleVideoError = useCallback(() => { + if (!videoError && videoStoragePath) { + // eslint-disable-next-line no-console + console.warn('[CanvasBackground] Video error, falling back to proxy URL'); + setVideoError(true); + } + }, [videoError, videoStoragePath]); + const handleLoad = () => { // Wait for paint to ensure background is actually rendered before reporting ready. // This prevents the transition overlay from being removed before the background is visible. @@ -106,15 +139,27 @@ const CanvasBackground: React.FC = ({ onBackgroundReady?.(); }; + // Timeout fallback - report ready after 5 seconds even if video hasn't started + // Prevents infinite loading on slow networks or video initialization failures + const timeout = setTimeout(() => { + // eslint-disable-next-line no-console + console.warn( + '[CanvasBackground] Video ready timeout, reporting ready anyway', + ); + reportVideoReady(); + }, 5000); + // Use requestVideoFrameCallback for precise frame-level timing (Safari 15.4+, Chrome 83+) const videoWithRVFC = video as HTMLVideoElementWithRVFC; if (typeof videoWithRVFC.requestVideoFrameCallback === 'function') { videoWithRVFC.requestVideoFrameCallback(() => { + clearTimeout(timeout); reportVideoReady(); }); } else { // Fallback: use playing event + scheduleAfterPaint const onPlaying = () => { + clearTimeout(timeout); scheduleAfterPaint(() => { reportVideoReady(); }); @@ -122,9 +167,12 @@ const CanvasBackground: React.FC = ({ video.addEventListener('playing', onPlaying, { once: true }); return () => { + clearTimeout(timeout); video.removeEventListener('playing', onPlaying); }; } + + return () => clearTimeout(timeout); }, [backgroundVideoUrl, onBackgroundReady]); // When endTime is set, we disable native loop and handle it via the hook @@ -176,18 +224,21 @@ const CanvasBackground: React.FC = ({ Note: muted attribute is always true for iOS autoplay compatibility. Actual muted state is controlled via useBackgroundVideoPlayback hook which sets video.muted property via JavaScript (useEffect). - webkit-playsinline is legacy attribute for older iOS versions. */} + webkit-playsinline is legacy attribute for older iOS versions. + preload="metadata" is required for iOS Safari video initialization. */} {backgroundVideoUrl && (