From 4d3ac2b9ea6adc1926bbd396b1732e3e8dd8da8c Mon Sep 17 00:00:00 2001 From: claude Date: Mon, 1 Jun 2026 16:07:37 +0000 Subject: [PATCH] Published presentations: two fixed links (public + private) instead of a toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the single token + visibility toggle with two stable tokens per project pointing at the same snapshot: - publicToken → anyone with the link - privateToken → any logged-in Verso user This fixes both reported issues: changing visibility no longer mutates a link (there's no toggle — both links always exist), and a public link can never become private by accident. It also fixes public links redirecting to login: access is now decided purely by which token was used (public token = open), not a per-record flag. - Model: storageId (snapshot dir) + publicToken + privateToken; drop token/ visibility. - Manager.publish: mints both tokens once and reuses them on re-publish; serve resolves a token to its record and treats the public token as open. - Controller: returns { publicUrl, privateUrl }. - Share dialog: shows the private and public links side by side, each with its own copy button; Publish refreshes, Unpublish removes. Preview button opens the private link. Co-Authored-By: Claude Opus 4.8 --- .../PublishedPresentationController.mjs | 27 ++--- .../PublishedPresentationManager.mjs | 29 +++-- .../app/src/models/PublishedPresentation.mjs | 21 ++-- .../toolbar/presentation-preview-button.tsx | 15 ++- .../publish-presentation-section.tsx | 103 ++++++++---------- 5 files changed, 85 insertions(+), 110 deletions(-) diff --git a/services/web/app/src/Features/PublishedPresentation/PublishedPresentationController.mjs b/services/web/app/src/Features/PublishedPresentation/PublishedPresentationController.mjs index 3ae499bdac..936fc63f13 100644 --- a/services/web/app/src/Features/PublishedPresentation/PublishedPresentationController.mjs +++ b/services/web/app/src/Features/PublishedPresentation/PublishedPresentationController.mjs @@ -5,7 +5,7 @@ import { expressify } from '@overleaf/promise-utils' import SessionManager from '../Authentication/SessionManager.mjs' import PublishedPresentationManager from './PublishedPresentationManager.mjs' -function _publicUrl(token) { +function _tokenUrl(token) { // Trailing slash so the deck's relative asset paths (e.g. main_files/...) // resolve under /p/:token/ rather than /p/. return `${Settings.siteUrl}/p/${token}/` @@ -15,9 +15,8 @@ function _serialize(record) { if (!record) return { published: false } return { published: true, - token: record.token, - url: _publicUrl(record.token), - visibility: record.visibility, + publicUrl: _tokenUrl(record.publicToken), + privateUrl: _tokenUrl(record.privateToken), publishedAt: record.publishedAt, } } @@ -25,14 +24,10 @@ function _serialize(record) { async function publish(req, res) { const projectId = req.params.Project_id const userId = SessionManager.getLoggedInUserId(req.session) - const requested = req.body?.visibility - const visibility = - requested === 'public' || requested === 'private' ? requested : undefined try { const record = await PublishedPresentationManager.promises.publish( projectId, - userId, - { visibility } + userId ) res.json(_serialize(record)) } catch (err) { @@ -71,16 +66,14 @@ async function serve(req, res) { return res.redirect(301, `/p/${encodeURIComponent(token)}/`) } - if ( - record.visibility === 'private' && - !SessionManager.getLoggedInUserId(req.session) - ) { - return res.redirect( - `/login?redir=${encodeURIComponent(req.originalUrl)}` - ) + // The token itself determines access: the public token is open, the private + // token requires any logged-in Verso user. + const isPublic = record.publicToken === token + if (!isPublic && !SessionManager.getLoggedInUserId(req.session)) { + return res.redirect(`/login?redir=${encodeURIComponent(req.originalUrl)}`) } - const dir = PublishedPresentationManager.getSnapshotDir(token) + const dir = PublishedPresentationManager.getSnapshotDir(record.storageId) const root = Path.resolve(dir) const target = Path.resolve(root, file) if (target !== root && !target.startsWith(root + Path.sep)) { diff --git a/services/web/app/src/Features/PublishedPresentation/PublishedPresentationManager.mjs b/services/web/app/src/Features/PublishedPresentation/PublishedPresentationManager.mjs index 3068899bf1..ad2b1074e6 100644 --- a/services/web/app/src/Features/PublishedPresentation/PublishedPresentationManager.mjs +++ b/services/web/app/src/Features/PublishedPresentation/PublishedPresentationManager.mjs @@ -18,8 +18,8 @@ const PUBLISHED_DIR = Settings.path.publishedPresentationsFolder // 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 getSnapshotDir(storageId) { + return Path.join(PUBLISHED_DIR, storageId) } function _isWithin(root, target) { @@ -51,10 +51,8 @@ async function _downloadOutputFile( // 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 } = {}) { +// existing tokens, so shared links stay stable. +async function publish(projectId, userId) { const { status, outputFiles, clsiServerId, buildId } = await CompileManager.promises.compile(projectId, userId, { bypassRecentCompileCheck: true, @@ -77,15 +75,14 @@ async function publish(projectId, userId, { visibility } = {}) { if (!record) { record = new PublishedPresentation({ project_id: projectId, - token: crypto.randomBytes(24).toString('hex'), - visibility: visibility || 'private', + storageId: crypto.randomBytes(16).toString('hex'), + publicToken: crypto.randomBytes(20).toString('hex'), + privateToken: crypto.randomBytes(20).toString('hex'), }) - } else if (visibility) { - record.visibility = visibility } - // Write a fresh snapshot (replace any previous one for this token). - const destDir = getSnapshotDir(record.token) + // Write a fresh snapshot (replace any previous one for this project). + const destDir = getSnapshotDir(record.storageId) await fs.promises.rm(destDir, { recursive: true, force: true }) await fs.promises.mkdir(destDir, { recursive: true }) @@ -116,7 +113,7 @@ async function publish(projectId, userId, { visibility } = {}) { await record.save() logger.info( - { projectId, token: record.token, visibility: record.visibility }, + { projectId, storageId: record.storageId }, 'published presentation' ) return record @@ -126,7 +123,7 @@ 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 }) + .rm(getSnapshotDir(record.storageId), { recursive: true, force: true }) .catch(err => logger.warn({ err, projectId }, 'could not remove deck snapshot') ) @@ -134,7 +131,9 @@ async function unpublish(projectId) { } async function getByToken(token) { - return await PublishedPresentation.findOne({ token }) + return await PublishedPresentation.findOne({ + $or: [{ publicToken: token }, { privateToken: token }], + }) } async function getForProject(projectId) { diff --git a/services/web/app/src/models/PublishedPresentation.mjs b/services/web/app/src/models/PublishedPresentation.mjs index 191f35cb5f..f0284868dc 100644 --- a/services/web/app/src/models/PublishedPresentation.mjs +++ b/services/web/app/src/models/PublishedPresentation.mjs @@ -3,9 +3,13 @@ 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). +// A published snapshot of a project's compiled HTML/RevealJS presentation. +// One per project, served standalone at /p/:token. There are two stable tokens +// pointing at the same on-disk snapshot (under storageId): +// - publicToken → anyone with the link +// - privateToken → any logged-in Verso user +// Both always work; the author shares whichever they need. Re-publishing +// overwrites the snapshot and keeps both tokens, so shared links stay stable. const PublishedPresentationSchema = new Schema( { project_id: { @@ -14,14 +18,9 @@ const PublishedPresentationSchema = new Schema( 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', - }, + storageId: { type: String, required: true }, + publicToken: { type: String, required: true, unique: true }, + privateToken: { type: String, required: true, unique: true }, buildId: { type: String }, publishedAt: { type: Date, default: Date.now }, }, diff --git a/services/web/frontend/js/features/ide-react/components/toolbar/presentation-preview-button.tsx b/services/web/frontend/js/features/ide-react/components/toolbar/presentation-preview-button.tsx index d6f4cf3ce5..d5b80a34e6 100644 --- a/services/web/frontend/js/features/ide-react/components/toolbar/presentation-preview-button.tsx +++ b/services/web/frontend/js/features/ide-react/components/toolbar/presentation-preview-button.tsx @@ -18,14 +18,13 @@ export default function PresentationPreviewButton() { // Open the tab synchronously inside the click handler to avoid popup // blockers, then redirect it once we have the published URL. const win = window.open('', '_blank') - postJSON(`/project/${projectId}/publish-presentation`, { - body: { visibility: 'private' }, - }) - .then((data: { url?: string }) => { - if (data?.url && win) { - win.location.href = data.url - } else if (data?.url) { - window.open(data.url, '_blank') + postJSON(`/project/${projectId}/publish-presentation`) + .then((data: { privateUrl?: string }) => { + const url = data?.privateUrl + if (url && win) { + win.location.href = url + } else if (url) { + window.open(url, '_blank') } else if (win) { win.close() } diff --git a/services/web/frontend/js/features/share-project-modal/components/publish-presentation-section.tsx b/services/web/frontend/js/features/share-project-modal/components/publish-presentation-section.tsx index d7ad420cdd..b0989f0dbb 100644 --- a/services/web/frontend/js/features/share-project-modal/components/publish-presentation-section.tsx +++ b/services/web/frontend/js/features/share-project-modal/components/publish-presentation-section.tsx @@ -11,49 +11,43 @@ import getMeta from '@/utils/meta' import OLButton from '@/shared/components/ol/ol-button' import OLNotification from '@/shared/components/ol/ol-notification' -type Visibility = 'public' | 'private' - type PublishState = { published: boolean - url?: string - visibility?: Visibility + publicUrl?: string + privateUrl?: string publishedAt?: string } // "Share only the compiled result": publish the compiled HTML/RevealJS deck as -// a standalone, linkable page (public or logged-in-users-only), independent of -// the editor. Re-publishing keeps the same link. +// a standalone, linkable page. Two stable links are offered side by side — a +// private one (logged-in users only) and a public one (anyone with the link) — +// and the author shares whichever they need. Re-publishing refreshes the deck +// while keeping both links. export default function PublishPresentationSection() { const { t } = useTranslation() const { project } = useProjectContext() const projectId = project?._id || getMeta('ol-project_id') const [state, setState] = useState({ published: false }) - const [visibility, setVisibility] = useState('private') const [loading, setLoading] = useState(false) const [error, setError] = useState(null) - const [copied, setCopied] = useState(false) + const [copied, setCopied] = useState<'public' | 'private' | null>(null) useEffect(() => { if (!projectId) return getJSON(`/project/${projectId}/publish-presentation`) - .then((data: PublishState) => { - setState(data) - if (data.visibility) setVisibility(data.visibility) - }) + .then((data: PublishState) => setState(data)) .catch(() => {}) }, [projectId]) const publish = useCallback(() => { setLoading(true) setError(null) - postJSON(`/project/${projectId}/publish-presentation`, { - body: { visibility }, - }) + postJSON(`/project/${projectId}/publish-presentation`) .then((data: PublishState) => setState(data)) .catch(err => setError(getUserFacingMessage(err) ?? 'error')) .finally(() => setLoading(false)) - }, [projectId, visibility]) + }, [projectId]) const unpublish = useCallback(() => { setLoading(true) @@ -64,13 +58,35 @@ export default function PublishPresentationSection() { .finally(() => setLoading(false)) }, [projectId]) - const copyLink = useCallback(() => { - if (state.url && navigator.clipboard) { - navigator.clipboard.writeText(state.url) - setCopied(true) - setTimeout(() => setCopied(false), 2000) + const copy = useCallback((which: 'public' | 'private', url?: string) => { + if (url && navigator.clipboard) { + navigator.clipboard.writeText(url) + setCopied(which) + setTimeout(() => setCopied(null), 2000) } - }, [state.url]) + }, []) + + const linkRow = ( + which: 'public' | 'private', + label: string, + url?: string + ) => ( +
+ +
+ e.target.select()} + /> + copy(which, url)}> + {copied === which ? t('copied') : t('copy')} + +
+
+ ) return (
@@ -79,45 +95,14 @@ export default function PublishPresentationSection() { {t('share_compiled_presentation_info')}

-
- - -
- - {state.published && state.url && ( -
- e.target.select()} - /> - - {copied ? t('copied') : t('copy')} - -
+ {state.published && ( + <> + {linkRow('private', t('presentation_link_private'), state.privateUrl)} + {linkRow('public', t('presentation_link_public'), state.publicUrl)} + )} -
+