Published presentations: two fixed links (public + private) instead of a toggle
Build and Deploy Verso / deploy (push) Successful in 7m30s

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 <noreply@anthropic.com>
This commit is contained in:
claude
2026-06-01 16:07:37 +00:00
parent 2cb81bd246
commit 4d3ac2b9ea
5 changed files with 85 additions and 110 deletions
@@ -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)) {
@@ -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) {
@@ -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 },
},
@@ -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()
}
@@ -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<PublishState>({ published: false })
const [visibility, setVisibility] = useState<Visibility>('private')
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(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
) => (
<div className="mb-2">
<label className="small fw-bold mb-1 d-block">{label}</label>
<div className="input-group">
<input
type="text"
readOnly
className="form-control"
value={url || ''}
onFocus={e => e.target.select()}
/>
<OLButton variant="secondary" onClick={() => copy(which, url)}>
{copied === which ? t('copied') : t('copy')}
</OLButton>
</div>
</div>
)
return (
<div className="public-access-level mb-4">
@@ -79,45 +95,14 @@ export default function PublishPresentationSection() {
{t('share_compiled_presentation_info')}
</p>
<div className="mb-3">
<label className="me-3">
<input
type="radio"
name="presentation-visibility"
className="me-1"
checked={visibility === 'private'}
onChange={() => setVisibility('private')}
/>
{t('presentation_link_private')}
</label>
<label>
<input
type="radio"
name="presentation-visibility"
className="me-1"
checked={visibility === 'public'}
onChange={() => setVisibility('public')}
/>
{t('presentation_link_public')}
</label>
</div>
{state.published && state.url && (
<div className="input-group mb-2">
<input
type="text"
readOnly
className="form-control"
value={state.url}
onFocus={e => e.target.select()}
/>
<OLButton variant="secondary" onClick={copyLink}>
{copied ? t('copied') : t('copy')}
</OLButton>
</div>
{state.published && (
<>
{linkRow('private', t('presentation_link_private'), state.privateUrl)}
{linkRow('public', t('presentation_link_public'), state.publicUrl)}
</>
)}
<div className="d-flex gap-2">
<div className="d-flex gap-2 mt-2">
<OLButton
variant="primary"
onClick={publish}