Published presentations: three access tiers + per-link reset
Build and Deploy Verso / deploy (push) Successful in 7m57s

Adds a project-members-only link tier and independent link rotation.

- Three tokens per project instead of two: publicToken (anyone), loginToken
  (any logged-in user), memberToken (only users who can read the project).
  serve() resolves the token to its tier and enforces accordingly — 'member'
  requires AuthorizationManager.canUserReadProject.
- New POST /project/:id/publish-presentation/regenerate { tier } rotates a
  single tier's token (invalidating only that old link), leaving the snapshot
  and the other links intact.
- Share dialog now shows three links (members / logged-in / anyone), each with
  its own Copy and Reset buttons; Publish refreshes, Unpublish removes all.
  Preview button opens the logged-in-users link.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
claude
2026-06-01 18:19:15 +00:00
parent 539cb877b4
commit 4766071e69
7 changed files with 166 additions and 65 deletions
@@ -3,6 +3,7 @@ import Settings from '@overleaf/settings'
import logger from '@overleaf/logger'
import { expressify } from '@overleaf/promise-utils'
import SessionManager from '../Authentication/SessionManager.mjs'
import AuthorizationManager from '../Authorization/AuthorizationManager.mjs'
import PublishedPresentationManager from './PublishedPresentationManager.mjs'
function _tokenUrl(token) {
@@ -16,7 +17,8 @@ function _serialize(record) {
return {
published: true,
publicUrl: _tokenUrl(record.publicToken),
privateUrl: _tokenUrl(record.privateToken),
loginUrl: _tokenUrl(record.loginToken),
memberUrl: _tokenUrl(record.memberToken),
publishedAt: record.publishedAt,
}
}
@@ -45,6 +47,23 @@ async function status(req, res) {
res.json(_serialize(record))
}
async function regenerate(req, res) {
const projectId = req.params.Project_id
const tier = req.body?.tier
try {
const record = await PublishedPresentationManager.promises.regenerateToken(
projectId,
tier
)
res.json(_serialize(record))
} catch (err) {
logger.error({ err, projectId, tier }, 'failed to regenerate deck link')
res
.status(400)
.json({ message: err.message || 'failed to regenerate link' })
}
}
async function unpublish(req, res) {
const projectId = req.params.Project_id
await PublishedPresentationManager.promises.unpublish(projectId)
@@ -52,7 +71,8 @@ async function unpublish(req, res) {
}
// Public/standalone serving of a published deck and its assets. No editor
// chrome. Anonymous for 'public' links; any logged-in Verso user for 'private'.
// chrome. The token determines the access tier: 'public' is open, 'login'
// needs any logged-in user, 'member' needs read access to the project.
async function serve(req, res) {
const { token } = req.params
const file = req.params.file || 'index.html'
@@ -66,11 +86,22 @@ async function serve(req, res) {
return res.redirect(301, `/p/${encodeURIComponent(token)}/`)
}
// 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 tier = PublishedPresentationManager.tierForToken(record, token)
if (tier !== 'public') {
const userId = SessionManager.getLoggedInUserId(req.session)
if (!userId) {
return res.redirect(`/login?redir=${encodeURIComponent(req.originalUrl)}`)
}
if (tier === 'member') {
const canRead = await AuthorizationManager.promises.canUserReadProject(
userId,
record.project_id,
null
)
if (!canRead) {
return res.status(403).send('You do not have access to this project')
}
}
}
const dir = PublishedPresentationManager.getSnapshotDir(record.storageId)
@@ -94,6 +125,7 @@ async function serve(req, res) {
export default {
publish: expressify(publish),
regenerate: expressify(regenerate),
status: expressify(status),
unpublish: expressify(unpublish),
serve: expressify(serve),
@@ -77,7 +77,8 @@ async function publish(projectId, userId) {
project_id: projectId,
storageId: crypto.randomBytes(16).toString('hex'),
publicToken: crypto.randomBytes(20).toString('hex'),
privateToken: crypto.randomBytes(20).toString('hex'),
loginToken: crypto.randomBytes(20).toString('hex'),
memberToken: crypto.randomBytes(20).toString('hex'),
})
}
@@ -132,22 +133,55 @@ async function unpublish(projectId) {
async function getByToken(token) {
return await PublishedPresentation.findOne({
$or: [{ publicToken: token }, { privateToken: token }],
$or: [
{ publicToken: token },
{ loginToken: token },
{ memberToken: token },
],
})
}
const TIER_FIELDS = {
public: 'publicToken',
login: 'loginToken',
member: 'memberToken',
}
// Which access tier a token grants for a record ('public' | 'login' | 'member').
function tierForToken(record, token) {
if (record.publicToken === token) return 'public'
if (record.loginToken === token) return 'login'
if (record.memberToken === token) return 'member'
return null
}
// Rotate a single tier's token, invalidating the old link for that tier only.
// The snapshot and the other tiers' links are untouched.
async function regenerateToken(projectId, tier) {
const field = TIER_FIELDS[tier]
if (!field) throw new Errors.InvalidError(`unknown link tier: ${tier}`)
const record = await PublishedPresentation.findOne({ project_id: projectId })
if (!record) throw new Errors.NotFoundError('presentation not published')
record[field] = crypto.randomBytes(20).toString('hex')
await record.save()
return record
}
async function getForProject(projectId) {
return await PublishedPresentation.findOne({ project_id: projectId })
}
export default {
getSnapshotDir,
tierForToken,
publish,
regenerateToken,
unpublish,
getByToken,
getForProject,
promises: {
publish,
regenerateToken,
unpublish,
getByToken,
getForProject,
@@ -4,12 +4,15 @@ const { Schema } = mongoose
const { ObjectId } = Schema
// 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.
// One per project, served standalone at /p/:token. Three stable tokens point at
// the same on-disk snapshot (under storageId), one per access tier:
// - publicToken → anyone with the link
// - loginToken → any logged-in Verso user
// - memberToken → only users who can read the project (collaborators)
// All three always work; the author shares whichever they need, and can
// regenerate any one independently (rotating its token invalidates the old
// link for that tier only). Re-publishing overwrites the snapshot and keeps
// the tokens, so shared links stay stable.
const PublishedPresentationSchema = new Schema(
{
project_id: {
@@ -20,7 +23,8 @@ const PublishedPresentationSchema = new Schema(
},
storageId: { type: String, required: true },
publicToken: { type: String, required: true, unique: true },
privateToken: { type: String, required: true, unique: true },
loginToken: { type: String, required: true, unique: true },
memberToken: { type: String, required: true, unique: true },
buildId: { type: String },
publishedAt: { type: Date, default: Date.now },
},
+5
View File
@@ -682,6 +682,11 @@ async function initialize(webRouter, privateApiRouter, publicApiRouter) {
AuthorizationMiddleware.ensureUserCanReadProject,
PublishedPresentationController.publish
)
webRouter.post(
'/project/:Project_id/publish-presentation/regenerate',
AuthorizationMiddleware.ensureUserCanReadProject,
PublishedPresentationController.regenerate
)
webRouter.delete(
'/project/:Project_id/publish-presentation',
AuthorizationMiddleware.ensureUserCanReadProject,
@@ -19,8 +19,8 @@ export default function PresentationPreviewButton() {
// blockers, then redirect it once we have the published URL.
const win = window.open('', '_blank')
postJSON(`/project/${projectId}/publish-presentation`)
.then((data: { privateUrl?: string }) => {
const url = data?.privateUrl
.then((data: { loginUrl?: string }) => {
const url = data?.loginUrl
if (url && win) {
win.location.href = url
} else if (url) {
@@ -11,27 +11,30 @@ import getMeta from '@/utils/meta'
import OLButton from '@/shared/components/ol/ol-button'
import OLNotification from '@/shared/components/ol/ol-notification'
type Tier = 'public' | 'login' | 'member'
type PublishState = {
published: boolean
publicUrl?: string
privateUrl?: string
loginUrl?: string
memberUrl?: string
publishedAt?: string
}
// "Share only the compiled result": publish the compiled HTML/RevealJS deck as
// 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.
// a standalone, linkable page. Three stable links are offered, one per access
// tier (project members / logged-in users / anyone), each independently
// copyable and resettable. Re-publishing refreshes the deck while keeping the
// links; Reset rotates a single link's token, breaking only that old URL.
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 [loading, setLoading] = useState(false)
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
const [copied, setCopied] = useState<'public' | 'private' | null>(null)
const [copied, setCopied] = useState<Tier | null>(null)
useEffect(() => {
if (!projectId) return
@@ -40,53 +43,51 @@ export default function PublishPresentationSection() {
.catch(() => {})
}, [projectId])
const publish = useCallback(() => {
setLoading(true)
const run = useCallback((p: Promise<any>) => {
setBusy(true)
setError(null)
postJSON(`/project/${projectId}/publish-presentation`)
.then((data: PublishState) => setState(data))
p.then((data: PublishState) => setState(data))
.catch(err => setError(getUserFacingMessage(err) ?? 'error'))
.finally(() => setLoading(false))
}, [projectId])
.finally(() => setBusy(false))
}, [])
const publish = useCallback(
() => run(postJSON(`/project/${projectId}/publish-presentation`)),
[projectId, run]
)
const regenerate = useCallback(
(tier: Tier) =>
run(
postJSON(`/project/${projectId}/publish-presentation/regenerate`, {
body: { tier },
})
),
[projectId, run]
)
const unpublish = useCallback(() => {
setLoading(true)
setBusy(true)
setError(null)
deleteJSON(`/project/${projectId}/publish-presentation`)
.then(() => setState({ published: false }))
.catch(err => setError(getUserFacingMessage(err) ?? 'error'))
.finally(() => setLoading(false))
.finally(() => setBusy(false))
}, [projectId])
const copy = useCallback((which: 'public' | 'private', url?: string) => {
const copy = useCallback((tier: Tier, url?: string) => {
if (url && navigator.clipboard) {
navigator.clipboard.writeText(url)
setCopied(which)
setCopied(tier)
setTimeout(() => setCopied(null), 2000)
}
}, [])
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>
)
const tiers: { tier: Tier; label: string; url?: string }[] = [
{ tier: 'member', label: t('presentation_link_members'), url: state.memberUrl },
{ tier: 'login', label: t('presentation_link_private'), url: state.loginUrl },
{ tier: 'public', label: t('presentation_link_public'), url: state.publicUrl },
]
return (
<div className="public-access-level mb-4">
@@ -95,24 +96,47 @@ export default function PublishPresentationSection() {
{t('share_compiled_presentation_info')}
</p>
{state.published && (
<>
{linkRow('private', t('presentation_link_private'), state.privateUrl)}
{linkRow('public', t('presentation_link_public'), state.publicUrl)}
</>
)}
{state.published &&
tiers.map(({ tier, label, url }) => (
<div className="mb-2" key={tier}>
<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(tier, url)}
disabled={busy}
>
{copied === tier ? t('copied') : t('copy')}
</OLButton>
<OLButton
variant="secondary"
onClick={() => regenerate(tier)}
disabled={busy}
>
{t('reset_link')}
</OLButton>
</div>
</div>
))}
<div className="d-flex gap-2 mt-2">
<OLButton
variant="primary"
onClick={publish}
disabled={loading || !projectId}
isLoading={loading}
disabled={busy || !projectId}
isLoading={busy}
>
{t('publish')}
</OLButton>
{state.published && (
<OLButton variant="secondary" onClick={unpublish} disabled={loading}>
<OLButton variant="secondary" onClick={unpublish} disabled={busy}>
{t('unpublish')}
</OLButton>
)}
+2
View File
@@ -1942,6 +1942,7 @@
"premium_plan_label": "Youre using <b>Overleaf Premium</b>",
"preparing_for_export": "Preparing for export…",
"presentation": "Presentation",
"presentation_link_members": "Only project members",
"presentation_link_private": "Only logged-in users",
"presentation_link_public": "Anyone with the link",
"presentation_mode": "Presentation mode",
@@ -2164,6 +2165,7 @@
"resend_link_sso": "Resend SSO invite",
"resend_managed_user_invite": "Resend managed user invite",
"resending_confirmation_code": "Resending confirmation code",
"reset_link": "Reset link",
"reset_password": "Reset Password",
"reset_password_link": "Click this link to reset your password",
"reset_password_sentence_case": "Reset password",