simplified "go back" functionality

This commit is contained in:
Dmitri 2026-04-14 13:17:31 +04:00
parent 5035788ffc
commit 0987c87c1d
30 changed files with 380 additions and 313 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 MiB

View File

@ -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,

View File

@ -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]);
}

View File

@ -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,
};
}

View File

@ -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,

View File

@ -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.',
);
},
};

View File

@ -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.`,
);

View File

@ -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',
);
},
};

View File

@ -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',
),
);
}
});

View File

@ -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) {

View File

@ -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',
);

View File

@ -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;

View File

@ -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);
}

View File

@ -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<boolean>}
*/
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) {

View File

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

View File

@ -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'))

View File

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

View File

@ -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<
)}
</div>
{/* Back Navigation Mode - only shown for back buttons */}
{/* Back navigation info text */}
{currentKind === 'back' && (
<div>
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
Back Navigation Mode
</label>
<select
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={navBackMode || 'target_page'}
onChange={(e) => onChange('navBackMode', e.target.value)}
>
<option value='target_page'>Fixed target page</option>
<option value='history'>Previous page (browser-like)</option>
</select>
{navBackMode === 'history' && (
<p className='mt-1 text-[11px] text-gray-500'>
Returns to the page user came from, using the original forward
transition in reverse.
</p>
)}
</div>
<p className='text-[11px] italic text-gray-500'>
Back button returns to the previous page using the original forward
transition in reverse.
</p>
)}
{/* 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' && (
<>
<div>
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>

View File

@ -46,6 +46,7 @@ import { logger } from '../lib/logger';
import {
resolveNavigationTarget,
isTransitionBlocking,
isBackNavigation,
} from '../lib/navigationHelpers';
import type { TransitionPhase } from '../types/presentation';
import type { CanvasElement } from '../types/constructor';
@ -396,7 +397,7 @@ export default function RuntimePresentation({
resolvedTargetPageId: navTarget?.pageId,
transitionVideoUrl: element.transitionVideoUrl,
hasTransition: Boolean(element.transitionVideoUrl),
navBackMode: element.navBackMode,
isBack: isBackNavigation(element),
previousPageId: navContext.previousPageId,
});

View File

@ -110,7 +110,6 @@ export const TYPE_SPECIFIC_DEFAULTS: Partial<
navDisabled: false,
iconUrl: '',
transitionReverseMode: 'auto_reverse',
navBackMode: 'target_page',
},
tooltip: {
iconUrl: '',
@ -490,7 +489,6 @@ export const buildElementSettings = (
element.transitionReverseMode,
);
addIfNotEmpty(settings, 'reverseVideoUrl', element.reverseVideoUrl);
addIfNotEmpty(settings, 'navBackMode', element.navBackMode);
}
// Tooltip type settings

View File

@ -121,7 +121,7 @@ export const resolveHistoryBackTarget = (
/**
* Resolve target page from element navigation properties.
* Supports both targetPageSlug (new) and targetPageId (legacy).
* Also supports history-based back navigation when navBackMode='history'.
* Back navigation always uses history mode - returns to previous page.
*
* @param element - Element with navigation properties
* @param pages - Available pages to search
@ -133,8 +133,8 @@ export const resolveNavigationTarget = (
pages: RuntimePage[],
context?: NavigationContext,
): NavigationTarget | null => {
// Handle history-based back navigation
if (isBackNavigation(element) && element.navBackMode === 'history') {
// Back navigation ALWAYS uses history mode
if (isBackNavigation(element)) {
return resolveHistoryBackTarget(
pages,
context?.currentPageSlug || '',
@ -142,7 +142,7 @@ export const resolveNavigationTarget = (
);
}
// Standard target_page mode logic
// Forward navigation: resolve target page
const targetPageSlug = element.targetPageSlug;
const legacyTargetPageId = element.targetPageId;
@ -158,15 +158,13 @@ export const resolveNavigationTarget = (
return null;
}
const isBack = isBackNavigation(element);
return {
page: targetPage,
pageId: targetPage.id,
transitionVideoUrl: element.transitionVideoUrl,
transitionReverseMode: element.transitionReverseMode,
reverseVideoUrl: element.reverseVideoUrl,
isBack,
isBack: false,
};
};

View File

@ -33,6 +33,7 @@ import {
resolveNavigationTarget,
hasPlayableTransition,
getNavigationDirection,
isBackNavigation,
} from '../lib/navigationHelpers';
import {
mergeElementWithDefaults,
@ -1034,8 +1035,8 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
const navTarget = resolveNavigationTarget(element, pages, navContext);
if (!navTarget) {
// History mode back buttons need navigation history
if (element.navBackMode === 'history') {
// Back buttons always use history, forward buttons use target page
if (isBackNavigation(element)) {
if (!navContext.previousPageId) {
setErrorMessage(
'No previous page in history. Navigate to another page first.',
@ -1053,17 +1054,16 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
return;
}
// For history mode, use transition from navTarget (the forward element that brought us here)
// For target_page mode, use the element's own transition settings
const transitionSource =
element.navBackMode === 'history'
? {
type: element.type,
transitionVideoUrl: navTarget.transitionVideoUrl,
transitionReverseMode: navTarget.transitionReverseMode,
reverseVideoUrl: navTarget.reverseVideoUrl,
}
: element;
// For back navigation, use transition from navTarget (the forward element that brought us here)
// For forward navigation, use the element's own transition settings
const transitionSource = isBackNavigation(element)
? {
type: element.type,
transitionVideoUrl: navTarget.transitionVideoUrl,
transitionReverseMode: navTarget.transitionReverseMode,
reverseVideoUrl: navTarget.reverseVideoUrl,
}
: element;
// Check if transition can be played using shared helper
if (!hasPlayableTransition(transitionSource, direction)) {

View File

@ -230,8 +230,6 @@ export interface CanvasElement extends BaseCanvasElement {
reverseVideoUrl?: string;
/** Storage key for the reversed transition video (pre-generated by backend) */
transitionReversedStorageKey?: string;
/** Back navigation mode: 'target_page' navigates to fixed slug, 'history' uses page history */
navBackMode?: 'target_page' | 'history';
transitionDurationSec?: number;
// Gallery Carousel Settings
galleryCarouselPrevIconUrl?: string;

View File

@ -84,8 +84,6 @@ export interface NavigableElement {
reverseVideoUrl?: string;
navType?: 'forward' | 'back';
navDisabled?: boolean;
/** Back navigation mode: 'target_page' navigates to fixed slug, 'history' uses page history */
navBackMode?: 'target_page' | 'history';
}
/**