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 <noreply@anthropic.com>
This commit is contained in:
claude
2026-06-01 14:34:06 +00:00
parent 9b01fab383
commit 18f9220e73
5 changed files with 309 additions and 0 deletions
@@ -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),
}
@@ -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),
}
@@ -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 }
+23
View File
@@ -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,
+6
View File
@@ -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