diff --git a/services/web/app/src/Features/PublishedPresentation/PublishedPresentationController.mjs b/services/web/app/src/Features/PublishedPresentation/PublishedPresentationController.mjs index 936fc63f13..5b5e296ddc 100644 --- a/services/web/app/src/Features/PublishedPresentation/PublishedPresentationController.mjs +++ b/services/web/app/src/Features/PublishedPresentation/PublishedPresentationController.mjs @@ -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), diff --git a/services/web/app/src/Features/PublishedPresentation/PublishedPresentationManager.mjs b/services/web/app/src/Features/PublishedPresentation/PublishedPresentationManager.mjs index ad2b1074e6..6541e6093a 100644 --- a/services/web/app/src/Features/PublishedPresentation/PublishedPresentationManager.mjs +++ b/services/web/app/src/Features/PublishedPresentation/PublishedPresentationManager.mjs @@ -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, diff --git a/services/web/app/src/models/PublishedPresentation.mjs b/services/web/app/src/models/PublishedPresentation.mjs index f0284868dc..15bf64e6e4 100644 --- a/services/web/app/src/models/PublishedPresentation.mjs +++ b/services/web/app/src/models/PublishedPresentation.mjs @@ -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 }, }, diff --git a/services/web/app/src/router.mjs b/services/web/app/src/router.mjs index 908d5948f2..a0c1873028 100644 --- a/services/web/app/src/router.mjs +++ b/services/web/app/src/router.mjs @@ -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, 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 d5b80a34e6..22d46d6a53 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 @@ -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) { 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 b0989f0dbb..9cee848863 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,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({ published: false }) - const [loading, setLoading] = useState(false) + const [busy, setBusy] = useState(false) const [error, setError] = useState(null) - const [copied, setCopied] = useState<'public' | 'private' | null>(null) + const [copied, setCopied] = useState(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) => { + 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 - ) => ( -
- -
- e.target.select()} - /> - copy(which, url)}> - {copied === which ? t('copied') : t('copy')} - -
-
- ) + 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 (
@@ -95,24 +96,47 @@ export default function PublishPresentationSection() { {t('share_compiled_presentation_info')}

- {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 }) => ( +
+ +
+ e.target.select()} + /> + copy(tier, url)} + disabled={busy} + > + {copied === tier ? t('copied') : t('copy')} + + regenerate(tier)} + disabled={busy} + > + {t('reset_link')} + +
+
+ ))}
{t('publish')} {state.published && ( - + {t('unpublish')} )} diff --git a/services/web/locales/en.json b/services/web/locales/en.json index 12322525ea..be60bf36cf 100644 --- a/services/web/locales/en.json +++ b/services/web/locales/en.json @@ -1942,6 +1942,7 @@ "premium_plan_label": "You’re using Overleaf Premium", "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",