diff --git a/assets/pasted-20260317-041612-b96693ca.jpg b/assets/pasted-20260317-041612-b96693ca.jpg deleted file mode 100644 index f0e1cc4..0000000 Binary files a/assets/pasted-20260317-041612-b96693ca.jpg and /dev/null differ diff --git a/assets/pasted-20260317-042652-552394ff.jpg b/assets/pasted-20260317-042652-552394ff.jpg deleted file mode 100644 index 2ed3966..0000000 Binary files a/assets/pasted-20260317-042652-552394ff.jpg and /dev/null differ diff --git a/assets/pasted-20260317-043756-efefbfce.jpg b/assets/pasted-20260317-043756-efefbfce.jpg deleted file mode 100644 index b827464..0000000 Binary files a/assets/pasted-20260317-043756-efefbfce.jpg and /dev/null differ diff --git a/assets/pasted-20260317-044540-b2490251.jpg b/assets/pasted-20260317-044540-b2490251.jpg deleted file mode 100644 index 794c586..0000000 Binary files a/assets/pasted-20260317-044540-b2490251.jpg and /dev/null differ diff --git a/assets/pasted-20260317-045246-e4459bd6.jpg b/assets/pasted-20260317-045246-e4459bd6.jpg deleted file mode 100644 index 13fddd9..0000000 Binary files a/assets/pasted-20260317-045246-e4459bd6.jpg and /dev/null differ diff --git a/assets/pasted-20260319-061300-6728d60c.jpg b/assets/pasted-20260319-061300-6728d60c.jpg deleted file mode 100644 index 706c29c..0000000 Binary files a/assets/pasted-20260319-061300-6728d60c.jpg and /dev/null differ diff --git a/assets/pasted-20260319-061442-0a583e0c.png b/assets/pasted-20260319-061442-0a583e0c.png deleted file mode 100644 index b5d8e93..0000000 Binary files a/assets/pasted-20260319-061442-0a583e0c.png and /dev/null differ diff --git a/backend/src/config.js b/backend/src/config.js index 060e7ee..9a6c057 100644 --- a/backend/src/config.js +++ b/backend/src/config.js @@ -18,7 +18,8 @@ const config = { secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || '', prefix: process.env.AWS_S3_PREFIX || 'afeefb9d49f5b7977577876b99532ac7', // Timeout configuration (in milliseconds) - connectionTimeout: parseInt(process.env.AWS_S3_CONNECTION_TIMEOUT, 10) || 5000, + connectionTimeout: + parseInt(process.env.AWS_S3_CONNECTION_TIMEOUT, 10) || 5000, requestTimeout: parseInt(process.env.AWS_S3_REQUEST_TIMEOUT, 10) || 30000, // Retry configuration maxAttempts: parseInt(process.env.AWS_S3_MAX_ATTEMPTS, 10) || 3, @@ -26,7 +27,8 @@ const config = { maxSockets: parseInt(process.env.AWS_S3_MAX_SOCKETS, 10) || 50, keepAlive: process.env.AWS_S3_KEEP_ALIVE !== 'false', // Presigned URL expiry (in seconds) - presignExpirySeconds: parseInt(process.env.AWS_S3_PRESIGN_EXPIRY, 10) || 3600, + presignExpirySeconds: + parseInt(process.env.AWS_S3_PRESIGN_EXPIRY, 10) || 3600, }, bcrypt: { saltRounds: 12, diff --git a/backend/src/db/api/base.api.js b/backend/src/db/api/base.api.js index 9602f28..42cc536 100644 --- a/backend/src/db/api/base.api.js +++ b/backend/src/db/api/base.api.js @@ -131,7 +131,9 @@ class GenericDBApi { } // Apply custom transformers - for (const [field, transformer] of Object.entries(this.FIELD_TRANSFORMERS)) { + for (const [field, transformer] of Object.entries( + this.FIELD_TRANSFORMERS, + )) { if (mapped[field] !== undefined) { mapped[field] = transformer(mapped[field]); } diff --git a/backend/src/db/api/projects.js b/backend/src/db/api/projects.js index 74dec15..0d79fc0 100644 --- a/backend/src/db/api/projects.js +++ b/backend/src/db/api/projects.js @@ -35,14 +35,7 @@ class ProjectsDBApi extends GenericDBApi { } static get CSV_FIELDS() { - return [ - 'id', - 'name', - 'slug', - 'description', - 'logo_url', - 'createdAt', - ]; + return ['id', 'name', 'slug', 'description', 'logo_url', 'createdAt']; } static get AUTOCOMPLETE_FIELD() { @@ -63,7 +56,8 @@ class ProjectsDBApi extends GenericDBApi { favicon_url: data.favicon_url || null, og_image_url: data.og_image_url || null, design_width: data.design_width !== undefined ? data.design_width : null, - design_height: data.design_height !== undefined ? data.design_height : null, + design_height: + data.design_height !== undefined ? data.design_height : null, }; } diff --git a/backend/src/db/api/tour_pages.js b/backend/src/db/api/tour_pages.js index 48ec4e8..5b89bc9 100644 --- a/backend/src/db/api/tour_pages.js +++ b/backend/src/db/api/tour_pages.js @@ -94,8 +94,7 @@ class Tour_pagesDBApi extends GenericDBApi { data.background_video_end_time !== undefined ? data.background_video_end_time : null, - design_width: - data.design_width !== undefined ? data.design_width : null, + design_width: data.design_width !== undefined ? data.design_width : null, design_height: data.design_height !== undefined ? data.design_height : null, requires_auth: data.requires_auth || false, diff --git a/backend/src/db/migrations/20260331054340-remove-duplicate-element-type-defaults.js b/backend/src/db/migrations/20260331054340-remove-duplicate-element-type-defaults.js index 94bfb1b..6044a19 100644 --- a/backend/src/db/migrations/20260331054340-remove-duplicate-element-type-defaults.js +++ b/backend/src/db/migrations/20260331054340-remove-duplicate-element-type-defaults.js @@ -60,6 +60,8 @@ module.exports = { async down(_queryInterface, _Sequelize) { // Cannot restore deleted duplicates - console.log('Down migration not applicable - duplicates cannot be restored.'); + console.log( + 'Down migration not applicable - duplicates cannot be restored.', + ); }, }; diff --git a/backend/src/db/migrations/20260331063424-cleanup-invalid-element-type-defaults.js b/backend/src/db/migrations/20260331063424-cleanup-invalid-element-type-defaults.js index a23cff9..5cc5182 100644 --- a/backend/src/db/migrations/20260331063424-cleanup-invalid-element-type-defaults.js +++ b/backend/src/db/migrations/20260331063424-cleanup-invalid-element-type-defaults.js @@ -63,9 +63,7 @@ module.exports = { }, ); - console.log( - `Deleted ${idsToDelete.length} invalid element_type_defaults.`, - ); + console.log(`Deleted ${idsToDelete.length} invalid element_type_defaults.`); console.log( `Deleted ${deletedProjectDefaults.length} invalid project_element_defaults.`, ); diff --git a/backend/src/db/migrations/20260403000001-add-background-video-settings.js b/backend/src/db/migrations/20260403000001-add-background-video-settings.js index bd73284..0e7cb56 100644 --- a/backend/src/db/migrations/20260403000001-add-background-video-settings.js +++ b/backend/src/db/migrations/20260403000001-add-background-video-settings.js @@ -18,11 +18,15 @@ module.exports = { allowNull: false, defaultValue: true, }); - await queryInterface.addColumn('tour_pages', 'background_video_start_time', { - type: Sequelize.DECIMAL(10, 1), - allowNull: true, - defaultValue: null, - }); + await queryInterface.addColumn( + 'tour_pages', + 'background_video_start_time', + { + type: Sequelize.DECIMAL(10, 1), + allowNull: true, + defaultValue: null, + }, + ); await queryInterface.addColumn('tour_pages', 'background_video_end_time', { type: Sequelize.DECIMAL(10, 1), allowNull: true, @@ -31,10 +35,19 @@ module.exports = { }, async down(queryInterface, _Sequelize) { - await queryInterface.removeColumn('tour_pages', 'background_video_autoplay'); + await queryInterface.removeColumn( + 'tour_pages', + 'background_video_autoplay', + ); await queryInterface.removeColumn('tour_pages', 'background_video_loop'); await queryInterface.removeColumn('tour_pages', 'background_video_muted'); - await queryInterface.removeColumn('tour_pages', 'background_video_start_time'); - await queryInterface.removeColumn('tour_pages', 'background_video_end_time'); + await queryInterface.removeColumn( + 'tour_pages', + 'background_video_start_time', + ); + await queryInterface.removeColumn( + 'tour_pages', + 'background_video_end_time', + ); }, }; diff --git a/backend/src/routes/file.js b/backend/src/routes/file.js index 329f0c1..656fc9f 100644 --- a/backend/src/routes/file.js +++ b/backend/src/routes/file.js @@ -27,11 +27,17 @@ router.post('/presign', jsonParser, async (req, res) => { const { urls } = req.body || {}; if (!Array.isArray(urls) || urls.length === 0) { - return res.status(400).json(createErrorResponse('urls array required', 'MISSING_URLS')); + return res + .status(400) + .json(createErrorResponse('urls array required', 'MISSING_URLS')); } if (urls.length > 50) { - return res.status(400).json(createErrorResponse('Maximum 50 URLs per request', 'TOO_MANY_URLS')); + return res + .status(400) + .json( + createErrorResponse('Maximum 50 URLs per request', 'TOO_MANY_URLS'), + ); } // Validate that all URLs are non-empty strings @@ -39,24 +45,43 @@ router.post('/presign', jsonParser, async (req, res) => { (url) => typeof url !== 'string' || !url.trim(), ); if (invalidUrls.length > 0) { - return res.status(400).json(createErrorResponse('All URLs must be non-empty strings', 'INVALID_URL_FORMAT')); + return res + .status(400) + .json( + createErrorResponse( + 'All URLs must be non-empty strings', + 'INVALID_URL_FORMAT', + ), + ); } // Validate paths for security (no traversal, no protocols) const unsafeUrls = urls.filter((url) => !isValidPath(url)); if (unsafeUrls.length > 0) { log.warn({ unsafeUrls }, 'Presign request with invalid paths rejected'); - return res.status(400).json(createErrorResponse('Invalid file paths detected', 'INVALID_PATH', { - invalidPaths: unsafeUrls, - })); + return res.status(400).json( + createErrorResponse('Invalid file paths detected', 'INVALID_PATH', { + invalidPaths: unsafeUrls, + }), + ); } try { const presignedUrls = await services.generatePresignedUrls(urls); res.json({ presignedUrls }); } catch (error) { - log.error({ err: error, urlCount: urls.length }, 'Failed to generate presigned URLs'); - res.status(500).json(createErrorResponse('Failed to generate presigned URLs', 'PRESIGN_ERROR')); + log.error( + { err: error, urlCount: urls.length }, + 'Failed to generate presigned URLs', + ); + res + .status(500) + .json( + createErrorResponse( + 'Failed to generate presigned URLs', + 'PRESIGN_ERROR', + ), + ); } }); diff --git a/backend/src/routes/users.js b/backend/src/routes/users.js index 07a8b4d..0c105fe 100644 --- a/backend/src/routes/users.js +++ b/backend/src/routes/users.js @@ -13,7 +13,7 @@ const router = createEntityRouter('users', UsersService, UsersDBApi, { // Note: This needs to be added BEFORE the router is exported // The factory already registered this route, so we add middleware to sanitize const originalGetById = router.stack.find( - (layer) => layer.route?.path === '/:id' && layer.route?.methods?.get + (layer) => layer.route?.path === '/:id' && layer.route?.methods?.get, ); if (originalGetById) { diff --git a/backend/src/services/assets.js b/backend/src/services/assets.js index 205bf51..44e9ebe 100644 --- a/backend/src/services/assets.js +++ b/backend/src/services/assets.js @@ -93,12 +93,14 @@ class AssetsService extends BaseService { // Pre-generate reversed video for video assets (async, doesn't block response) if (assetType === 'video' && data.storage_key) { - AssetsService.preGenerateReversedVideo(asset, currentUser).catch((err) => { - logger.error( - { err, assetId: asset.id }, - 'Failed to pre-generate reversed video (non-blocking)', - ); - }); + AssetsService.preGenerateReversedVideo(asset, currentUser).catch( + (err) => { + logger.error( + { err, assetId: asset.id }, + 'Failed to pre-generate reversed video (non-blocking)', + ); + }, + ); } return asset; @@ -131,7 +133,9 @@ class AssetsService extends BaseService { // Check if reversed variant already exists (shouldn't happen on create, but safety check) const existingAsset = await AssetsDBApi.findBy({ id: asset.id }); const variants = existingAsset?.asset_variants_asset || []; - const existingReversed = variants.find((v) => v.variant_type === 'reversed'); + const existingReversed = variants.find( + (v) => v.variant_type === 'reversed', + ); if (existingReversed) { log.debug('Reversed variant already exists'); @@ -172,7 +176,10 @@ class AssetsService extends BaseService { ); log.info( - { reversedKey, sizeMb: (reversedBuffer.length / (1024 * 1024)).toFixed(2) }, + { + reversedKey, + sizeMb: (reversedBuffer.length / (1024 * 1024)).toFixed(2), + }, 'Pre-generated reversed video successfully', ); diff --git a/backend/src/services/file.js b/backend/src/services/file.js index c50d19b..220df0d 100644 --- a/backend/src/services/file.js +++ b/backend/src/services/file.js @@ -33,18 +33,25 @@ let gcloudHash = null; let uploadSessionManager = null; const getFileStorageProvider = () => { - const provider = (process.env.FILE_STORAGE_PROVIDER || '').trim().toLowerCase(); + const provider = (process.env.FILE_STORAGE_PROVIDER || '') + .trim() + .toLowerCase(); if (provider) return provider; const hasS3 = Boolean( - config.s3.bucket && config.s3.region && - config.s3.accessKeyId && config.s3.secretAccessKey + config.s3.bucket && + config.s3.region && + config.s3.accessKeyId && + config.s3.secretAccessKey, ); if (hasS3) return 's3'; const hasGCloud = Boolean( - process.env.GC_PROJECT_ID && process.env.GC_CLIENT_EMAIL && - process.env.GC_PRIVATE_KEY && config.gcloud.bucket && config.gcloud.hash + process.env.GC_PROJECT_ID && + process.env.GC_CLIENT_EMAIL && + process.env.GC_PRIVATE_KEY && + config.gcloud.bucket && + config.gcloud.hash, ); if (hasGCloud) return 'gcloud'; @@ -67,14 +74,17 @@ const getS3Provider = () => { keepAlive: config.s3.keepAlive, }); - logger.info({ - provider: 's3', - bucket: config.s3.bucket, - region: config.s3.region, - connectionTimeout: config.s3.connectionTimeout, - requestTimeout: config.s3.requestTimeout, - maxAttempts: config.s3.maxAttempts, - }, 'S3 storage provider initialized'); + logger.info( + { + provider: 's3', + bucket: config.s3.bucket, + region: config.s3.region, + connectionTimeout: config.s3.connectionTimeout, + requestTimeout: config.s3.requestTimeout, + maxAttempts: config.s3.maxAttempts, + }, + 'S3 storage provider initialized', + ); } return s3Provider; }; @@ -144,7 +154,11 @@ const getErrorMessage = (error, operation = 'process') => { const errorName = error?.name || ''; const errorCode = error?.code || ''; - if (errorName === 'NoSuchKey' || errorName === 'NotFound' || errorName === 'NoSuchBucket') { + if ( + errorName === 'NoSuchKey' || + errorName === 'NotFound' || + errorName === 'NoSuchBucket' + ) { return 'File not found'; } if (errorName === 'AccessDenied' || errorName === 'InvalidAccessKeyId') { @@ -203,17 +217,25 @@ const uploadFile = async (folder, req, res) => { const processFile = require('../middlewares/upload'); await processFile(req, res); - if (!req.file) return res.status(400).send(createErrorResponse('Please upload a file!', 'MISSING_FILE')); + if (!req.file) + return res + .status(400) + .send(createErrorResponse('Please upload a file!', 'MISSING_FILE')); const filename = req.body.filename; - if (!filename) return res.status(400).send(createErrorResponse('Missing filename', 'MISSING_FILENAME')); + if (!filename) + return res + .status(400) + .send(createErrorResponse('Missing filename', 'MISSING_FILENAME')); const privateUrl = `${folder}/${filename}`; let publicUrl = ''; if (provider === 's3') { const s3 = getS3Provider(); - const result = await s3.upload(privateUrl, req.file.buffer, { contentType: req.file.mimetype }); + const result = await s3.upload(privateUrl, req.file.buffer, { + contentType: req.file.mimetype, + }); publicUrl = result.url; } else if (provider === 'gcloud') { const { bucket, hash } = getGCloudBucket(); @@ -225,7 +247,9 @@ const uploadFile = async (folder, req, res) => { blobStream.on('finish', resolve); blobStream.end(req.file.buffer); }); - publicUrl = format(`https://storage.googleapis.com/${bucket.name}/${blob.name}`); + publicUrl = format( + `https://storage.googleapis.com/${bucket.name}/${blob.name}`, + ); } else { const local = getLocalProvider(); await local.upload(privateUrl, req.file.buffer); @@ -240,7 +264,14 @@ const uploadFile = async (folder, req, res) => { }); } catch (error) { log.error({ err: error, provider }, 'Failed to upload file'); - return res.status(500).send(createErrorResponse(`Could not upload the file. ${error.message || error}`, 'UPLOAD_ERROR')); + return res + .status(500) + .send( + createErrorResponse( + `Could not upload the file. ${error.message || error}`, + 'UPLOAD_ERROR', + ), + ); } }; @@ -249,12 +280,22 @@ const downloadFile = async (req, res) => { const privateUrl = req.query.privateUrl; const log = req.log || logger; - if (!privateUrl) return res.status(400).send(createErrorResponse('Missing privateUrl parameter', 'MISSING_PARAMETER')); + if (!privateUrl) + return res + .status(400) + .send( + createErrorResponse( + 'Missing privateUrl parameter', + 'MISSING_PARAMETER', + ), + ); // Validate path if (!isValidPath(privateUrl)) { log.warn({ privateUrl }, 'Invalid file path requested'); - return res.status(400).send(createErrorResponse('Invalid file path', 'INVALID_PATH')); + return res + .status(400) + .send(createErrorResponse('Invalid file path', 'INVALID_PATH')); } res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin'); @@ -279,7 +320,8 @@ const downloadFile = async (req, res) => { const result = await s3.download(privateUrl, { signal }); if (result.contentType) res.setHeader('Content-Type', result.contentType); - if (result.contentLength) res.setHeader('Content-Length', result.contentLength); + if (result.contentLength) + res.setHeader('Content-Length', result.contentLength); if (typeof result.body.pipe === 'function') { result.body.pipe(res); @@ -290,7 +332,10 @@ const downloadFile = async (req, res) => { res.send(result.body); } - log.debug({ provider, privateUrl, duration: Date.now() - startTime }, 'File downloaded'); + log.debug( + { provider, privateUrl, duration: Date.now() - startTime }, + 'File downloaded', + ); } else if (provider === 'gcloud') { const { bucket, hash } = getGCloudBucket(); const file = bucket.file(`${hash}/${privateUrl}`); @@ -298,7 +343,9 @@ const downloadFile = async (req, res) => { if (exists) { file.createReadStream().pipe(res); } else { - res.status(404).send(createErrorResponse('File not found', 'NOT_FOUND')); + res + .status(404) + .send(createErrorResponse('File not found', 'NOT_FOUND')); } } else { res.download(path.join(config.uploadDir, privateUrl)); @@ -316,17 +363,24 @@ const downloadFile = async (req, res) => { const statusCode = provider === 's3' ? getS3ErrorStatusCode(error) : 500; const errorMessage = getErrorMessage(error, 'download'); - log.error({ - err: error, - provider, - privateUrl, - statusCode, - errorName: error?.name, - errorCode: error?.code, - }, 'Failed to download file'); + log.error( + { + err: error, + provider, + privateUrl, + statusCode, + errorName: error?.name, + errorCode: error?.code, + }, + 'Failed to download file', + ); if (!res.headersSent) { - return res.status(statusCode).send(createErrorResponse(errorMessage, error?.name || 'DOWNLOAD_ERROR')); + return res + .status(statusCode) + .send( + createErrorResponse(errorMessage, error?.name || 'DOWNLOAD_ERROR'), + ); } } }; @@ -339,7 +393,8 @@ const downloadFile = async (req, res) => { * @returns {Promise<{ success: boolean, error?: Error }>} */ const deleteFile = async (privateUrl, options = {}) => { - if (!privateUrl) return { success: false, error: new Error('Missing privateUrl') }; + if (!privateUrl) + return { success: false, error: new Error('Missing privateUrl') }; const { throwOnError = false } = options; const provider = getFileStorageProvider(); @@ -435,11 +490,15 @@ const uploadBuffer = async (privateUrl, buffer, options = {}) => { blobStream.on('finish', resolve); blobStream.end(buffer); }); - return { url: `https://storage.googleapis.com/${bucket.name}/${blob.name}` }; + return { + url: `https://storage.googleapis.com/${bucket.name}/${blob.name}`, + }; } else { const local = getLocalProvider(); await local.upload(privateUrl, buffer); - return { url: `/api/file/download?privateUrl=${encodeURIComponent(privateUrl)}` }; + return { + url: `/api/file/download?privateUrl=${encodeURIComponent(privateUrl)}`, + }; } }; @@ -448,13 +507,15 @@ const uploadBuffer = async (privateUrl, buffer, options = {}) => { // ============================================================================ const sanitizeFolder = (folder) => { - const value = String(folder || '').trim().replace(/^\/+|\/+$/g, ''); - return (!value || value.includes('..')) ? null : value; + const value = String(folder || '') + .trim() + .replace(/^\/+|\/+$/g, ''); + return !value || value.includes('..') ? null : value; }; const sanitizeFilename = (filename) => { const value = path.basename(String(filename || '').trim()); - return (!value || value === '.' || value === '..') ? null : value; + return !value || value === '.' || value === '..' ? null : value; }; const initUploadSession = async (req, res) => { @@ -472,9 +533,20 @@ const initUploadSession = async (req, res) => { const size = Number(req.body?.size); const contentType = String(req.body?.contentType || '').trim(); - if (!folder || !filename) return res.status(400).send(createErrorResponse('Invalid folder or filename', 'INVALID_INPUT')); - if (!Number.isInteger(totalChunks) || totalChunks <= 0) return res.status(400).send(createErrorResponse('Invalid totalChunks', 'INVALID_INPUT')); - if (!Number.isFinite(size) || size < 0) return res.status(400).send(createErrorResponse('Invalid file size', 'INVALID_INPUT')); + if (!folder || !filename) + return res + .status(400) + .send( + createErrorResponse('Invalid folder or filename', 'INVALID_INPUT'), + ); + if (!Number.isInteger(totalChunks) || totalChunks <= 0) + return res + .status(400) + .send(createErrorResponse('Invalid totalChunks', 'INVALID_INPUT')); + if (!Number.isFinite(size) || size < 0) + return res + .status(400) + .send(createErrorResponse('Invalid file size', 'INVALID_INPUT')); const sessionId = sessionManager.createSession({ userId: req.currentUser.id, @@ -485,7 +557,10 @@ const initUploadSession = async (req, res) => { contentType, }); - log.info({ sessionId, folder, filename, totalChunks, size }, 'Upload session initialized'); + log.info( + { sessionId, folder, filename, totalChunks, size }, + 'Upload session initialized', + ); return res.status(200).send({ sessionId, @@ -494,7 +569,14 @@ const initUploadSession = async (req, res) => { }); } catch (error) { log.error({ err: error }, 'Failed to initialize upload session'); - return res.status(500).send(createErrorResponse('Failed to initialize upload session', 'SESSION_INIT_ERROR')); + return res + .status(500) + .send( + createErrorResponse( + 'Failed to initialize upload session', + 'SESSION_INIT_ERROR', + ), + ); } }; @@ -506,7 +588,12 @@ const getUploadSession = async (req, res) => { const sessionManager = getUploadSessionManager(); const session = sessionManager.readMeta(sessionId); - if (!session) return res.status(404).send(createErrorResponse('Upload session not found', 'SESSION_NOT_FOUND')); + if (!session) + return res + .status(404) + .send( + createErrorResponse('Upload session not found', 'SESSION_NOT_FOUND'), + ); if (session.userId !== req.currentUser.id) return res.sendStatus(403); return res.status(200).send({ @@ -518,7 +605,14 @@ const getUploadSession = async (req, res) => { } catch (error) { const log = req.log || logger; log.error({ err: error }, 'Failed to get upload session'); - return res.status(500).send(createErrorResponse('Failed to get upload session', 'SESSION_GET_ERROR')); + return res + .status(500) + .send( + createErrorResponse( + 'Failed to get upload session', + 'SESSION_GET_ERROR', + ), + ); } }; @@ -532,15 +626,27 @@ const uploadChunk = async (req, res) => { const chunkIndex = Number(req.params.chunkIndex); if (!Number.isInteger(chunkIndex) || chunkIndex < 0) { - return res.status(400).send(createErrorResponse('Invalid chunk index', 'INVALID_INPUT')); + return res + .status(400) + .send(createErrorResponse('Invalid chunk index', 'INVALID_INPUT')); } const sessionManager = getUploadSessionManager(); const session = sessionManager.readMeta(sessionId); - if (!session) return res.status(404).send(createErrorResponse('Upload session not found', 'SESSION_NOT_FOUND')); + if (!session) + return res + .status(404) + .send( + createErrorResponse('Upload session not found', 'SESSION_NOT_FOUND'), + ); if (session.userId !== req.currentUser.id) return res.sendStatus(403); - if (chunkIndex >= session.totalChunks) return res.status(400).send(createErrorResponse('Chunk index is out of range', 'INVALID_INPUT')); + if (chunkIndex >= session.totalChunks) + return res + .status(400) + .send( + createErrorResponse('Chunk index is out of range', 'INVALID_INPUT'), + ); // Collect chunk data const chunks = []; @@ -558,7 +664,11 @@ const uploadChunk = async (req, res) => { }); } catch (error) { log.error({ err: error }, 'Failed to upload chunk'); - return res.status(500).send(createErrorResponse('Failed to upload chunk', 'CHUNK_UPLOAD_ERROR')); + return res + .status(500) + .send( + createErrorResponse('Failed to upload chunk', 'CHUNK_UPLOAD_ERROR'), + ); } }; @@ -572,18 +682,32 @@ const finalizeUploadSession = async (req, res) => { const sessionManager = getUploadSessionManager(); const session = sessionManager.readMeta(sessionId); - if (!session) return res.status(404).send(createErrorResponse('Upload session not found', 'SESSION_NOT_FOUND')); + if (!session) + return res + .status(404) + .send( + createErrorResponse('Upload session not found', 'SESSION_NOT_FOUND'), + ); if (session.userId !== req.currentUser.id) return res.sendStatus(403); // Verify all chunks exist for (let i = 0; i < session.totalChunks; i++) { if (!sessionManager.chunkExists(sessionId, i)) { - return res.status(400).send(createErrorResponse(`Missing chunk ${i}`, 'MISSING_CHUNK', { missingChunk: i })); + return res + .status(400) + .send( + createErrorResponse(`Missing chunk ${i}`, 'MISSING_CHUNK', { + missingChunk: i, + }), + ); } } // Assemble file to temp location - const assembledPath = path.join(config.uploadDir, `assembled_${sessionId}_${Date.now()}`); + const assembledPath = path.join( + config.uploadDir, + `assembled_${sessionId}_${Date.now()}`, + ); await sessionManager.assembleChunks(sessionId, assembledPath); const privateUrl = `${session.folder}/${session.filename}`; @@ -594,13 +718,20 @@ 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 }); + const result = await s3.upload(privateUrl, data, { + contentType: session.contentType, + }); publicUrl = result.url; } else if (provider === 'gcloud') { const { bucket, hash } = getGCloudBucket(); const blob = bucket.file(`${hash}/${privateUrl}`); - await pipeline(fs.createReadStream(assembledPath), blob.createWriteStream({ resumable: false })); - publicUrl = format(`https://storage.googleapis.com/${bucket.name}/${blob.name}`); + await pipeline( + fs.createReadStream(assembledPath), + blob.createWriteStream({ resumable: false }), + ); + publicUrl = format( + `https://storage.googleapis.com/${bucket.name}/${blob.name}`, + ); } else { const local = getLocalProvider(); const data = fs.readFileSync(assembledPath); @@ -624,7 +755,14 @@ const finalizeUploadSession = async (req, res) => { }); } catch (error) { log.error({ err: error }, 'Failed to finalize upload session'); - return res.status(500).send(createErrorResponse('Failed to finalize upload session', 'SESSION_FINALIZE_ERROR')); + return res + .status(500) + .send( + createErrorResponse( + 'Failed to finalize upload session', + 'SESSION_FINALIZE_ERROR', + ), + ); } }; @@ -651,7 +789,7 @@ const generatePresignedUrls = async (urls) => { await Promise.all( urls.map(async (url) => { presignedUrls[url] = await s3.getSignedUrl(url, expirySeconds); - }) + }), ); return presignedUrls; diff --git a/backend/src/services/file/UploadSessionManager.js b/backend/src/services/file/UploadSessionManager.js index e60b2f3..805193d 100644 --- a/backend/src/services/file/UploadSessionManager.js +++ b/backend/src/services/file/UploadSessionManager.js @@ -76,7 +76,10 @@ class UploadSessionManager { * Get chunk file path */ getChunkPath(sessionId, chunkIndex) { - return path.join(this.getChunksDir(sessionId), `${String(chunkIndex)}.part`); + return path.join( + this.getChunksDir(sessionId), + `${String(chunkIndex)}.part`, + ); } /** @@ -238,7 +241,9 @@ class UploadSessionManager { return; } - const updatedAt = new Date(meta.updatedAt || meta.createdAt || 0).getTime(); + const updatedAt = new Date( + meta.updatedAt || meta.createdAt || 0, + ).getTime(); if (!updatedAt || now - updatedAt > this.ttlMs) { this.removeSession(sessionId); } diff --git a/backend/src/services/tour_pages.js b/backend/src/services/tour_pages.js index b8d5d06..8a6ffb8 100644 --- a/backend/src/services/tour_pages.js +++ b/backend/src/services/tour_pages.js @@ -2,11 +2,7 @@ * Tour Pages Service * * Extends the factory service with reversed video generation for back navigation transitions. - * - * Supports two back navigation modes: - * 1. target_page: Back button has its own transitionVideoUrl - reversed video generated for it - * 2. history: Back button uses forward element from previous page - reversed video generated - * for forward elements when project has any history-mode back buttons + * Reversed videos are always generated for forward navigation elements to support back navigation. */ const Tour_pagesDBApi = require('../db/api/tour_pages'); @@ -17,8 +13,6 @@ const { downloadToBuffer, uploadBuffer } = require('./file'); const videoProcessing = require('./videoProcessing'); const { logger } = require('../utils/logger'); -// Cache for project history-mode status (cleared per request cycle) -const projectHistoryModeCache = new Map(); const projectRegenInProgress = new Set(); const singleReverseGenerationInProgress = new Set(); const reverseGenerationPromiseByStorageKey = new Map(); @@ -37,10 +31,11 @@ class TourPagesService extends BaseService { */ static async create(data, currentUser) { // Process reversed videos and get updated ui_schema_json - const updatedData = await TourPagesService.processReversedVideosAndUpdateSchema( - data, - currentUser, - ); + const updatedData = + await TourPagesService.processReversedVideosAndUpdateSchema( + data, + currentUser, + ); return super.create(updatedData, currentUser); } @@ -51,13 +46,15 @@ class TourPagesService extends BaseService { static async update(data, id, currentUser) { // Fetch existing page to get projectId (not included in update request body) const existingPage = await Tour_pagesDBApi.findBy({ id }); - const projectId = existingPage?.projectId || data.projectId || data.project_id; + const projectId = + existingPage?.projectId || data.projectId || data.project_id; // Process reversed videos and get updated ui_schema_json - const updatedData = await TourPagesService.processReversedVideosAndUpdateSchema( - { ...data, projectId, id }, - currentUser, - ); + const updatedData = + await TourPagesService.processReversedVideosAndUpdateSchema( + { ...data, projectId, id }, + currentUser, + ); return super.update(updatedData, id, currentUser); } @@ -88,94 +85,16 @@ class TourPagesService extends BaseService { return isForward && hasTarget && element.transitionVideoUrl; } - /** - * Check if any page in the project has a history-mode back button. - * Uses per-request caching to avoid repeated queries. - * - * @param {string} projectId - Project ID - * @returns {Promise} - */ - static async projectHasHistoryModeBackButton(projectId) { - if (!projectId) return false; - - // Check cache first - if (projectHistoryModeCache.has(projectId)) { - return projectHistoryModeCache.get(projectId); - } - - const { rows: pages } = await Tour_pagesDBApi.findAll( - { projectId }, - { attributes: ['id', 'ui_schema_json'] }, - ); - - logger.info( - { projectId, pageCount: pages.length }, - 'Checking project for history-mode back buttons', - ); - - for (const page of pages) { - const uiSchema = - typeof page.ui_schema_json === 'string' - ? JSON.parse(page.ui_schema_json || '{}') - : page.ui_schema_json || {}; - - if (!uiSchema.elements || !Array.isArray(uiSchema.elements)) continue; - - for (const element of uiSchema.elements) { - const isBack = TourPagesService.isBackElement(element); - - logger.debug( - { - pageId: page.id, - elementType: element.type, - navType: element.navType, - navBackMode: element.navBackMode, - isBack, - }, - 'Checking element for history mode', - ); - - if (isBack && element.navBackMode === 'history') { - logger.info( - { pageId: page.id, elementType: element.type }, - 'Found history-mode back button', - ); - projectHistoryModeCache.set(projectId, true); - return true; - } - } - } - - logger.info({ projectId }, 'No history-mode back buttons found in project'); - projectHistoryModeCache.set(projectId, false); - return false; - } - - /** - * Clear project cache (call at end of request or after project changes) - * @param {string} projectId - Project ID to clear, or undefined to clear all - */ - static clearProjectCache(projectId) { - if (projectId) { - projectHistoryModeCache.delete(projectId); - } else { - projectHistoryModeCache.clear(); - } - } - /** * Process reversed videos and update ui_schema_json with reversed URLs. * Returns data with updated ui_schema_json. * - * Handles two cases: - * 1. Back navigation elements with transitionVideoUrl - always generate reversed - * 2. Forward navigation elements when project uses history-mode back navigation + * Generates reversed videos for all navigation elements with transitions. * * @param {Object} data - Page data with ui_schema_json * @param {Object} currentUser - Current user for permissions * @param {Object} options - Processing options - * @param {boolean} options._forceForwardReversed - Force processing forward elements - * @param {boolean} options._skipHistoryModeCheck - Skip initial history mode check + * @param {boolean} options._skipHistoryModeCheck - Skip initial regeneration check */ static async processReversedVideosAndUpdateSchema( data, @@ -202,43 +121,9 @@ class TourPagesService extends BaseService { // Get project ID const projectId = data.projectId || data.project_id || data.project; - // Check if this page being saved has a history-mode back button - // This is used to trigger regeneration of other pages' forward elements - let thisPageHasHistoryMode = false; - - for (const element of uiSchema.elements) { - if ( - TourPagesService.isBackElement(element) && - element.navBackMode === 'history' - ) { - thisPageHasHistoryMode = true; - break; - } - } - - // Check if project already has history-mode back buttons (from other pages) - // Skip this check if we're in regeneration mode to avoid recursion - let projectHasHistoryMode = options._forceForwardReversed || false; - - if (!projectHasHistoryMode && projectId && !options._skipHistoryModeCheck) { - // Invalidate cache since we're about to save changes - TourPagesService.clearProjectCache(projectId); - projectHasHistoryMode = - await TourPagesService.projectHasHistoryModeBackButton(projectId); - } - - // Combined check: process forward elements if project uses history mode OR this page enables it - const shouldProcessForward = - projectHasHistoryMode || thisPageHasHistoryMode; - logger.info( - { - thisPageHasHistoryMode, - projectHasHistoryMode, - shouldProcessForward, - projectId, - }, - 'History mode detection result', + { projectId }, + 'Processing reversed videos for navigation elements', ); let wasModified = false; @@ -247,8 +132,8 @@ class TourPagesService extends BaseService { const isBack = TourPagesService.isBackElement(element); const isForward = TourPagesService.isForwardElementWithTarget(element); - // Determine if this element needs reversed video - const needsReversed = isBack || (shouldProcessForward && isForward); + // Always generate reversed videos for navigation elements with transitions + const needsReversed = isBack || isForward; logger.debug( { @@ -260,7 +145,6 @@ class TourPagesService extends BaseService { hasTransitionVideo: Boolean(element.transitionVideoUrl), targetPageSlug: element.targetPageSlug, targetPageId: element.targetPageId, - shouldProcessForward, }, 'Evaluating element for reversed video', ); @@ -366,7 +250,12 @@ class TourPagesService extends BaseService { * @param {Object} task.currentUser * @param {string} [task.pageId] */ - static enqueueSingleReverseGeneration({ projectId, storageKey, currentUser, pageId }) { + static enqueueSingleReverseGeneration({ + projectId, + storageKey, + currentUser, + pageId, + }) { if (!projectId || !storageKey) return; const taskKey = `${projectId}:${storageKey}`; @@ -392,7 +281,9 @@ class TourPagesService extends BaseService { ); if (!reversedUrl) { - log.warn('Background reversed variant generation finished without result'); + log.warn( + 'Background reversed variant generation finished without result', + ); return; } @@ -435,7 +326,10 @@ class TourPagesService extends BaseService { excludePageId, ); } catch (err) { - logger.error({ err, projectId }, 'Background project regeneration failed'); + logger.error( + { err, projectId }, + 'Background project regeneration failed', + ); } finally { projectRegenInProgress.delete(projectId); } @@ -650,22 +544,26 @@ class TourPagesService extends BaseService { for (const element of uiSchema.elements) { // Process both forward elements AND back elements with their own transition - const isForward = TourPagesService.isForwardElementWithTarget(element); + const isForward = + TourPagesService.isForwardElementWithTarget(element); const isBackWithTransition = TourPagesService.isBackElement(element) && element.transitionVideoUrl; - log.debug({ - pageId: page.id, - elementType: element.type, - navType: element.navType, - isForward, - isBackWithTransition, - hasTransitionVideo: Boolean(element.transitionVideoUrl), - hasReverseVideo: Boolean(element.reverseVideoUrl), - targetPageSlug: element.targetPageSlug, - targetPageId: element.targetPageId, - }, 'Checking element in regeneration'); + log.debug( + { + pageId: page.id, + elementType: element.type, + navType: element.navType, + isForward, + isBackWithTransition, + hasTransitionVideo: Boolean(element.transitionVideoUrl), + hasReverseVideo: Boolean(element.reverseVideoUrl), + targetPageSlug: element.targetPageSlug, + targetPageId: element.targetPageId, + }, + 'Checking element in regeneration', + ); // Skip if neither forward nor back-with-transition if (!isForward && !isBackWithTransition) { diff --git a/backend/src/services/users.js b/backend/src/services/users.js index b20a534..e92bac9 100644 --- a/backend/src/services/users.js +++ b/backend/src/services/users.js @@ -6,7 +6,9 @@ const config = require('../config'); const AuthService = require('./auth'); // Generate base service from factory -const BaseUsersService = createEntityService(UsersDBApi, { entityName: 'Users' }); +const BaseUsersService = createEntityService(UsersDBApi, { + entityName: 'Users', +}); /** * Users service with email invitation functionality diff --git a/backend/src/services/videoProcessing.js b/backend/src/services/videoProcessing.js index d57b390..14e3fd7 100644 --- a/backend/src/services/videoProcessing.js +++ b/backend/src/services/videoProcessing.js @@ -33,13 +33,20 @@ async function reverseVideo(inputBuffer, filename) { await new Promise((resolve, reject) => { ffmpeg(inputPath) .outputOptions([ - '-vf', 'reverse', - '-af', 'areverse', - '-c:v', 'libx264', - '-preset', 'fast', - '-crf', '23', - '-c:a', 'aac', - '-movflags', '+faststart', + '-vf', + 'reverse', + '-af', + 'areverse', + '-c:v', + 'libx264', + '-preset', + 'fast', + '-crf', + '23', + '-c:a', + 'aac', + '-movflags', + '+faststart', ]) .output(outputPath) .on('start', (cmd) => logger.debug({ cmd }, 'FFmpeg command')) diff --git a/frontend/src/components/Constructor/ElementEditorPanel.tsx b/frontend/src/components/Constructor/ElementEditorPanel.tsx index 5c06a50..d8f2cdf 100644 --- a/frontend/src/components/Constructor/ElementEditorPanel.tsx +++ b/frontend/src/components/Constructor/ElementEditorPanel.tsx @@ -322,7 +322,6 @@ export function ElementEditorPanel({ selectedElement.transitionReverseMode || 'auto_reverse' } reverseVideoUrl={selectedElement.reverseVideoUrl || ''} - navBackMode={selectedElement.navBackMode} allowedNavigationTypes={allowedNavigationTypes} iconAssetOptions={assetOptions.icon} transitionVideoOptions={assetOptions.transitionVideo} diff --git a/frontend/src/components/ElementSettings/NavigationSettingsSectionCompact.tsx b/frontend/src/components/ElementSettings/NavigationSettingsSectionCompact.tsx index 755c614..8cebd56 100644 --- a/frontend/src/components/ElementSettings/NavigationSettingsSectionCompact.tsx +++ b/frontend/src/components/ElementSettings/NavigationSettingsSectionCompact.tsx @@ -31,7 +31,6 @@ interface NavigationSettingsSectionCompactProps { transitionVideoUrl: string; transitionReverseMode: 'auto_reverse' | 'separate_video'; reverseVideoUrl: string; - navBackMode?: 'target_page' | 'history'; allowedNavigationTypes: NavigationElementType[]; iconAssetOptions: AssetOption[]; transitionVideoOptions: AssetOption[]; @@ -68,7 +67,6 @@ const NavigationSettingsSectionCompact: React.FC< transitionVideoUrl, transitionReverseMode, reverseVideoUrl, - navBackMode, allowedNavigationTypes, iconAssetOptions, transitionVideoOptions, @@ -186,31 +184,16 @@ const NavigationSettingsSectionCompact: React.FC< )} - {/* Back Navigation Mode - only shown for back buttons */} + {/* Back navigation info text */} {currentKind === 'back' && ( -
- - - {navBackMode === 'history' && ( -

- Returns to the page user came from, using the original forward - transition in reverse. -

- )} -
+

+ Back button returns to the previous page using the original forward + transition in reverse. +

)} - {/* Only show target page and transition settings for forward buttons OR back buttons with target_page mode */} - {(currentKind === 'forward' || navBackMode !== 'history') && ( + {/* Only show target page and transition settings for forward buttons */} + {currentKind === 'forward' && ( <>