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:
+91
@@ -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 }
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user