Published presentations: two fixed links (public + private) instead of a toggle
Build and Deploy Verso / deploy (push) Successful in 7m30s
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:
+10
-17
@@ -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)) {
|
||||
|
||||
+14
-15
@@ -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 },
|
||||
},
|
||||
|
||||
+7
-8
@@ -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()
|
||||
}
|
||||
|
||||
+44
-59
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user