From 18f9220e73f918fbb3424c3c2e3404b8716d6b04 Mon Sep 17 00:00:00 2001 From: claude Date: Mon, 1 Jun 2026 14:34:06 +0000 Subject: [PATCH] Publish presentations as standalone shareable links (backend) Adds the engine + API for publishing a project's compiled HTML/RevealJS deck as a stable, standalone snapshot served at /p/:token, independent of the editor. - PublishedPresentation model: one per project { token, visibility, buildId }, re-publishing keeps the same token so shared links stay stable. - Manager.publish: compiles the project, then copies the HTML deck + its _files assets + referenced media (now included thanks to the OutputFileFinder fix) into a persistent snapshot dir (Settings.path.publishedPresentationsFolder, override with PUBLISHED_PRESENTATIONS_PATH). Logs/aux are excluded. - Routes: GET/POST/DELETE /project/:id/publish-presentation (owner/reader) for status/publish/unpublish; public GET /p/:token(/*) serves the deck full-page. Visibility is enforced in the handler: 'public' = anonymous, 'private' = any logged-in Verso user. CSP is dropped on these responses so reveal.js renders. Frontend entry points (share-modal section + top-right Preview button) follow. Co-Authored-By: Claude Opus 4.8 --- .../PublishedPresentationController.mjs | 91 +++++++++++ .../PublishedPresentationManager.mjs | 153 ++++++++++++++++++ .../app/src/models/PublishedPresentation.mjs | 36 +++++ services/web/app/src/router.mjs | 23 +++ services/web/config/settings.defaults.js | 6 + 5 files changed, 309 insertions(+) create mode 100644 services/web/app/src/Features/PublishedPresentation/PublishedPresentationController.mjs create mode 100644 services/web/app/src/Features/PublishedPresentation/PublishedPresentationManager.mjs create mode 100644 services/web/app/src/models/PublishedPresentation.mjs diff --git a/services/web/app/src/Features/PublishedPresentation/PublishedPresentationController.mjs b/services/web/app/src/Features/PublishedPresentation/PublishedPresentationController.mjs new file mode 100644 index 0000000000..f25ba3bc96 --- /dev/null +++ b/services/web/app/src/Features/PublishedPresentation/PublishedPresentationController.mjs @@ -0,0 +1,91 @@ +import Path from 'node:path' +import Settings from '@overleaf/settings' +import logger from '@overleaf/logger' +import { expressify } from '@overleaf/promise-utils' +import SessionManager from '../Authentication/SessionManager.mjs' +import PublishedPresentationManager from './PublishedPresentationManager.mjs' + +function _publicUrl(token) { + return `${Settings.siteUrl}/p/${token}` +} + +function _serialize(record) { + if (!record) return { published: false } + return { + published: true, + token: record.token, + url: _publicUrl(record.token), + visibility: record.visibility, + publishedAt: record.publishedAt, + } +} + +async function publish(req, res) { + const projectId = req.params.Project_id + const userId = SessionManager.getLoggedInUserId(req.session) + const visibility = + req.body?.visibility === 'public' ? 'public' : req.body?.visibility + const record = await PublishedPresentationManager.promises.publish( + projectId, + userId, + { visibility: visibility === 'public' || visibility === 'private' ? visibility : undefined } + ) + res.json(_serialize(record)) +} + +async function status(req, res) { + const projectId = req.params.Project_id + const record = + await PublishedPresentationManager.promises.getForProject(projectId) + res.json(_serialize(record)) +} + +async function unpublish(req, res) { + const projectId = req.params.Project_id + await PublishedPresentationManager.promises.unpublish(projectId) + res.sendStatus(204) +} + +// Public/standalone serving of a published deck and its assets. No editor +// chrome. Anonymous for 'public' links; any logged-in Verso user for 'private'. +async function serve(req, res) { + const { token } = req.params + const file = req.params.file || 'index.html' + + const record = await PublishedPresentationManager.promises.getByToken(token) + if (!record) return res.status(404).send('Presentation not found') + + if ( + record.visibility === 'private' && + !SessionManager.getLoggedInUserId(req.session) + ) { + return res.redirect( + `/login?redir=${encodeURIComponent(req.originalUrl)}` + ) + } + + const dir = PublishedPresentationManager.getSnapshotDir(token) + const root = Path.resolve(dir) + const target = Path.resolve(root, file) + if (target !== root && !target.startsWith(root + Path.sep)) { + return res.status(400).send('Bad path') + } + + // This is a standalone site (reveal.js uses inline scripts); drop the app's + // Content-Security-Policy so the deck renders as it does in the preview. + res.removeHeader('Content-Security-Policy') + + res.sendFile(target, err => { + if (err) { + if (!res.headersSent) res.status(404).send('Not found') + logger.debug({ err, token, file }, 'published deck file not found') + } + }) +} + +export default { + publish: expressify(publish), + status: expressify(status), + unpublish: expressify(unpublish), + serve: expressify(serve), +} diff --git a/services/web/app/src/Features/PublishedPresentation/PublishedPresentationManager.mjs b/services/web/app/src/Features/PublishedPresentation/PublishedPresentationManager.mjs new file mode 100644 index 0000000000..9daa9575c2 --- /dev/null +++ b/services/web/app/src/Features/PublishedPresentation/PublishedPresentationManager.mjs @@ -0,0 +1,153 @@ +import crypto from 'node:crypto' +import fs from 'node:fs' +import Path from 'node:path' +import { pipeline } from 'node:stream/promises' +import logger from '@overleaf/logger' +import Settings from '@overleaf/settings' +import { fetchStream } from '@overleaf/fetch-utils' +import { callbackify } from 'node:util' +import CompileManager from '../Compile/CompileManager.mjs' +import { getOutputFileURL } from '../Compile/ClsiURLHelpers.mjs' +import { PublishedPresentation } from '../../models/PublishedPresentation.mjs' +import Errors from '../Errors/Errors.js' + +const PUBLISHED_DIR = Settings.path.publishedPresentationsFolder + +// Output files we never want in a published deck: compile logs and LaTeX aux. +// (OutputFileFinder already excludes the source .qmd/.tex; with the HTML-media +// fix it keeps referenced images, so the rest of the output set is the deck.) +const EXCLUDE_REGEX = /\.(log|blg|aux|fls|fdb_latexmk|synctex\.gz)$/i + +function getSnapshotDir(token) { + return Path.join(PUBLISHED_DIR, token) +} + +function _isWithin(root, target) { + const resolvedRoot = Path.resolve(root) + const resolvedTarget = Path.resolve(resolvedRoot, target) + return ( + resolvedTarget === resolvedRoot || + resolvedTarget.startsWith(resolvedRoot + Path.sep) + ) +} + +async function _downloadOutputFile( + projectId, + userId, + buildId, + filePath, + clsiServerId, + destDir +) { + const url = getOutputFileURL(projectId, userId, buildId, filePath, clsiServerId) + const dest = Path.join(destDir, filePath) + if (!_isWithin(destDir, filePath)) { + throw new Error(`unsafe output path: ${filePath}`) + } + await fs.promises.mkdir(Path.dirname(dest), { recursive: true }) + const stream = await fetchStream(url.href) + await pipeline(stream, fs.createWriteStream(dest)) +} + +// Compile the project, then copy the resulting HTML deck + assets into a stable +// on-disk snapshot served at /p/:token. Re-publishing reuses the project's +// existing token. `visibility` (optional) updates the sharing setting; when +// omitted, an existing record keeps its visibility and a new one defaults to +// 'private'. +async function publish(projectId, userId, { visibility } = {}) { + const { outputFiles, clsiServerId, buildId } = + await CompileManager.promises.compile(projectId, userId, {}) + + if (!buildId || !outputFiles?.some(f => f.path === 'output.html')) { + throw new Errors.InvalidError( + 'project did not produce an HTML presentation' + ) + } + + // Output files are stored per-user unless per-user compiles are disabled; + // mirror CompileManager so the download URLs resolve to the right location. + const compileAsUser = Settings.disablePerUserCompiles ? undefined : userId + + let record = await PublishedPresentation.findOne({ project_id: projectId }) + if (!record) { + record = new PublishedPresentation({ + project_id: projectId, + token: crypto.randomBytes(24).toString('hex'), + visibility: visibility || 'private', + }) + } else if (visibility) { + record.visibility = visibility + } + + // Write a fresh snapshot (replace any previous one for this token). + const destDir = getSnapshotDir(record.token) + await fs.promises.rm(destDir, { recursive: true, force: true }) + await fs.promises.mkdir(destDir, { recursive: true }) + + for (const file of outputFiles) { + if (EXCLUDE_REGEX.test(file.path)) continue + await _downloadOutputFile( + projectId, + compileAsUser, + buildId, + file.path, + clsiServerId, + destDir + ) + } + + // Serve the deck at the snapshot root: /p/:token → output.html. + try { + await fs.promises.copyFile( + Path.join(destDir, 'output.html'), + Path.join(destDir, 'index.html') + ) + } catch (err) { + logger.warn({ err, projectId }, 'could not create index.html for deck') + } + + record.buildId = buildId + record.publishedAt = new Date() + await record.save() + + logger.info( + { projectId, token: record.token, visibility: record.visibility }, + 'published presentation' + ) + return record +} + +async function unpublish(projectId) { + const record = await PublishedPresentation.findOne({ project_id: projectId }) + if (!record) return + await fs.promises + .rm(getSnapshotDir(record.token), { recursive: true, force: true }) + .catch(err => + logger.warn({ err, projectId }, 'could not remove deck snapshot') + ) + await PublishedPresentation.deleteOne({ _id: record._id }) +} + +async function getByToken(token) { + return await PublishedPresentation.findOne({ token }) +} + +async function getForProject(projectId) { + return await PublishedPresentation.findOne({ project_id: projectId }) +} + +export default { + getSnapshotDir, + publish, + unpublish, + getByToken, + getForProject, + promises: { + publish, + unpublish, + getByToken, + getForProject, + }, + // callback-style for any legacy callers + publishCb: callbackify(publish), +} diff --git a/services/web/app/src/models/PublishedPresentation.mjs b/services/web/app/src/models/PublishedPresentation.mjs new file mode 100644 index 0000000000..191f35cb5f --- /dev/null +++ b/services/web/app/src/models/PublishedPresentation.mjs @@ -0,0 +1,36 @@ +import mongoose from '../infrastructure/Mongoose.mjs' + +const { Schema } = mongoose +const { ObjectId } = Schema + +// A published snapshot of a project's compiled HTML/RevealJS presentation, +// served standalone at /p/:token. One per project; re-publishing overwrites the +// on-disk snapshot and keeps the same token (so shared links stay stable). +const PublishedPresentationSchema = new Schema( + { + project_id: { + type: ObjectId, + ref: 'Project', + required: true, + unique: true, + }, + token: { type: String, required: true, unique: true }, + // 'public' → anyone with the link + // 'private' → any logged-in Verso user + visibility: { + type: String, + enum: ['public', 'private'], + default: 'private', + }, + buildId: { type: String }, + publishedAt: { type: Date, default: Date.now }, + }, + { minimize: false } +) + +export const PublishedPresentation = mongoose.model( + 'PublishedPresentation', + PublishedPresentationSchema +) + +export { PublishedPresentationSchema } diff --git a/services/web/app/src/router.mjs b/services/web/app/src/router.mjs index 251066a07d..908d5948f2 100644 --- a/services/web/app/src/router.mjs +++ b/services/web/app/src/router.mjs @@ -3,6 +3,7 @@ import ErrorController from './Features/Errors/ErrorController.mjs' import Features from './infrastructure/Features.mjs' import ProjectController from './Features/Project/ProjectController.mjs' import ProjectApiController from './Features/Project/ProjectApiController.mjs' +import PublishedPresentationController from './Features/PublishedPresentation/PublishedPresentationController.mjs' import ProjectListController from './Features/Project/ProjectListController.mjs' import SpellingController from './Features/Spelling/SpellingController.mjs' import EditorRouter from './Features/Editor/EditorRouter.mjs' @@ -669,6 +670,28 @@ async function initialize(webRouter, privateApiRouter, publicApiRouter) { AuthorizationMiddleware.ensureUserCanReadProject, CompileController.deleteAuxFiles ) + + // Publish the compiled presentation as a standalone, shareable snapshot. + webRouter.get( + '/project/:Project_id/publish-presentation', + AuthorizationMiddleware.ensureUserCanReadProject, + PublishedPresentationController.status + ) + webRouter.post( + '/project/:Project_id/publish-presentation', + AuthorizationMiddleware.ensureUserCanReadProject, + PublishedPresentationController.publish + ) + webRouter.delete( + '/project/:Project_id/publish-presentation', + AuthorizationMiddleware.ensureUserCanReadProject, + PublishedPresentationController.unpublish + ) + // Standalone viewer for a published presentation (no editor chrome). + // Visibility is enforced inside the handler: 'public' is anonymous, + // 'private' requires any logged-in Verso user. + webRouter.get('/p/:token', PublishedPresentationController.serve) + webRouter.get('/p/:token/:file(.*)', PublishedPresentationController.serve) webRouter.get( '/project/:Project_id/sync/code', AsyncLocalStorage.middleware, diff --git a/services/web/config/settings.defaults.js b/services/web/config/settings.defaults.js index c598847c83..341e49c4c0 100644 --- a/services/web/config/settings.defaults.js +++ b/services/web/config/settings.defaults.js @@ -787,6 +787,12 @@ module.exports = { // them to disk here). dumpFolder: Path.resolve(__dirname, '../data/dumpFolder'), uploadFolder: Path.resolve(__dirname, '../data/uploads'), + // Verso: persisted snapshots of published presentations, served at + // /p/:token. Point PUBLISHED_PRESENTATIONS_PATH at a persistent volume in + // production so published links survive container restarts/redeploys. + publishedPresentationsFolder: + process.env.PUBLISHED_PRESENTATIONS_PATH || + Path.resolve(__dirname, '../data/published'), }, // Automatic Snapshots