Published presentations: three access tiers + per-link reset
Build and Deploy Verso / deploy (push) Successful in 7m57s
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:
+39
-7
@@ -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),
|
||||
|
||||
+36
-2
@@ -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 },
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
+2
-2
@@ -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) {
|
||||
|
||||
+71
-47
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -1942,6 +1942,7 @@
|
||||
"premium_plan_label": "You’re 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",
|
||||
|
||||
Reference in New Issue
Block a user