From deacd119a43f9b5b5641cc7632a7bc1a495eb32c Mon Sep 17 00:00:00 2001 From: "Thomas F. K. Jorna" Date: Thu, 23 Jul 2026 19:18:30 +0200 Subject: [PATCH 1/5] feat: add cms mode --- .../SuperAdminTag/SuperAdminTag.tsx | 30 ++++++ client/components/index.ts | 1 + .../CommunitySettings/CmsSettings.tsx | 102 ++++++++++++++++++ .../CommunitySettings/CommunitySettings.tsx | 17 +++ .../CommunitySettings/UnderlaySettings.tsx | 24 ++++- server/community/model.ts | 21 ++++ server/community/permissions.ts | 16 +-- server/doi/api.ts | 2 + server/doi/queries.ts | 12 ++- server/errorPages/communityNotFound.html | 18 +++- server/routes/pubDocument.tsx | 4 +- server/routes/robots.ts | 7 ++ server/routes/sitemap.ts | 6 ++ server/rss/queries.ts | 5 +- .../utils/citations/generateCitationHtml.ts | 2 +- server/utils/errors.ts | 37 ++++++- server/utils/initData.ts | 13 ++- server/utils/ssr.tsx | 2 +- .../2026_07_23_addCmsModeToCommunities.js | 21 ++++ utils/__tests__/cms.test.ts | 92 ++++++++++++++++ utils/api/schemas/community.ts | 6 ++ utils/canonicalUrls.js | 47 ++++++++ utils/cms.ts | 31 ++++++ utils/crossref/transform/collection.js | 2 +- utils/crossref/transform/community.js | 2 +- utils/crossref/transform/pub.js | 2 +- utils/pubEdge/helpers.ts | 6 +- 27 files changed, 499 insertions(+), 29 deletions(-) create mode 100644 client/components/SuperAdminTag/SuperAdminTag.tsx create mode 100644 client/containers/DashboardSettings/CommunitySettings/CmsSettings.tsx create mode 100644 tools/migrations/2026_07_23_addCmsModeToCommunities.js create mode 100644 utils/__tests__/cms.test.ts create mode 100644 utils/cms.ts diff --git a/client/components/SuperAdminTag/SuperAdminTag.tsx b/client/components/SuperAdminTag/SuperAdminTag.tsx new file mode 100644 index 0000000000..01e7171cce --- /dev/null +++ b/client/components/SuperAdminTag/SuperAdminTag.tsx @@ -0,0 +1,30 @@ +import React from 'react'; + +import { Tag } from '@blueprintjs/core'; + +import { usePageContext } from 'utils/hooks'; + +type Props = { + className?: string; +}; + +/** + * Marks a setting that is only visible or editable to superadmins, so it stays + * obvious which options regular community admins can't see or use yet. Place + * next to the label of any superadmin-gated field or section. Renders nothing + * for non-superadmins, so it's safe on sections that some admins can also see + * (e.g. a configured Underlay integration). + */ +const SuperAdminTag = (props: Props) => { + const { loginData } = usePageContext(); + if (!loginData.isSuperAdmin) { + return null; + } + return ( + + Superadmin only + + ); +}; + +export default SuperAdminTag; diff --git a/client/components/index.ts b/client/components/index.ts index f14238c7b7..fbc1dc06a4 100644 --- a/client/components/index.ts +++ b/client/components/index.ts @@ -108,6 +108,7 @@ export { default as SliderInput } from './SliderInput/SliderInput'; export { default as SpamStatusMenu } from './SpamStatusMenu'; export { default as SubmissionEmail } from './SubmissionEmail/SubmissionEmail'; export { default as SubscriptionButton } from './SubscriptionButton/SubscriptionButton'; +export { default as SuperAdminTag } from './SuperAdminTag/SuperAdminTag'; export { default as TabToShow } from './TabToShow/TabToShow'; export { default as Thread } from './Thread/Thread'; export { default as ThreadInput } from './ThreadInput/ThreadInput'; diff --git a/client/containers/DashboardSettings/CommunitySettings/CmsSettings.tsx b/client/containers/DashboardSettings/CommunitySettings/CmsSettings.tsx new file mode 100644 index 0000000000..f2aa45cc40 --- /dev/null +++ b/client/containers/DashboardSettings/CommunitySettings/CmsSettings.tsx @@ -0,0 +1,102 @@ +import type { Callback, Community } from 'types'; + +import React from 'react'; + +import { Callout, Switch } from '@blueprintjs/core'; + +import { InputField, SettingsSection, SuperAdminTag } from 'components'; +import { usePageContext } from 'utils/hooks'; + +type Props = { + communityData: Community; + updateCommunityData: Callback>; +}; + +const CmsSettings = (props: Props) => { + const { communityData, updateCommunityData } = props; + const { cmsMode, canonicalBaseUrl, canonicalPubUrlTemplate } = communityData; + const { + scopeData: { + activePermissions: { isSuperAdmin }, + }, + } = usePageContext(); + + return ( + + CMS mode + + } + description={ + <> + In CMS mode, PubPub is used only for writing and editing: your community is + visible to members alone, and everyone else sees a not-found page with a sign-in + prompt. If a canonical URL is set, citations, exports, deposits, and connections + from other communities will point to the external site where your content is + published (for example, a site fed by your Underlay collection). + + } + > + { + updateCommunityData({ cmsMode: (evt.target as HTMLInputElement).checked }); + }} + /> + {cmsMode && ( + + Your community is only visible to members. Public visitors will see a + “community not found” page with an option to log in. + + )} + {isSuperAdmin && ( + <> + + Canonical URL + + } + helperText="The external site where this community's content is published, e.g. https://journal.example.org. Canonical tags, citations, exports, and new Crossref deposits will point to it." + type="text" + placeholder="https://journal.example.org" + value={canonicalBaseUrl || ''} + onChange={(evt) => { + updateCommunityData({ + canonicalBaseUrl: evt.target.value.trim() || null, + }); + }} + /> + + Pub URL template + + } + helperText={ + <> + Where a pub lives on the external site, with {'{slug}'}{' '} + standing in for the pub slug, e.g.{' '} + {'https://journal.example.org/articles/{slug}'}. Leave + empty to use the canonical URL + /pub/{'{slug}'}. + + } + type="text" + placeholder="https://journal.example.org/articles/{slug}" + value={canonicalPubUrlTemplate || ''} + onChange={(evt) => { + updateCommunityData({ + canonicalPubUrlTemplate: evt.target.value.trim() || null, + }); + }} + /> + + )} + + ); +}; + +export default CmsSettings; diff --git a/client/containers/DashboardSettings/CommunitySettings/CommunitySettings.tsx b/client/containers/DashboardSettings/CommunitySettings/CommunitySettings.tsx index 23303b34ca..63fa6588d6 100644 --- a/client/containers/DashboardSettings/CommunitySettings/CommunitySettings.tsx +++ b/client/containers/DashboardSettings/CommunitySettings/CommunitySettings.tsx @@ -13,6 +13,7 @@ import { usePageContext, usePendingChanges } from 'utils/hooks'; import DashboardSettingsFrame, { type Subtab } from '../DashboardSettingsFrame'; import AnalyticsSettings from './AnalyticsSettings'; import BasicSettings from './BasicSettings'; +import CmsSettings from './CmsSettings'; import CommunityAdminSettings from './CommunityAdminSettings'; import CommunityOrCollectionLevelPubSettings from './CommunityOrCollectionLevelPubSettings'; import FooterSettings from './FooterSettings'; @@ -198,6 +199,22 @@ const CommunitySettings = (props: Props) => { } as const, ] : ([] as Subtab[])), + ...(pageContext.scopeData.activePermissions.canAdminCommunity || + pageContext.scopeData.activePermissions.isSuperAdmin + ? [ + { + id: 'cms-settings', + title: 'CMS', + icon: 'exchange', + sections: [ + , + ], + } as const, + ] + : ([] as Subtab[])), ].filter((x): x is Subtab => Boolean(x)) satisfies Subtab[]; return ( diff --git a/client/containers/DashboardSettings/CommunitySettings/UnderlaySettings.tsx b/client/containers/DashboardSettings/CommunitySettings/UnderlaySettings.tsx index aeff7c0c40..c44e3266dd 100644 --- a/client/containers/DashboardSettings/CommunitySettings/UnderlaySettings.tsx +++ b/client/containers/DashboardSettings/CommunitySettings/UnderlaySettings.tsx @@ -17,7 +17,7 @@ import { } from '@blueprintjs/core'; import { apiFetch } from 'client/utils/apiFetch'; -import { InputField, SettingsSection } from 'components'; +import { InputField, SettingsSection, SuperAdminTag } from 'components'; type Props = { communityData: Community; @@ -460,7 +460,18 @@ const UnderlaySettings = (_props: Props) => { }; if (loading) { - return Loading…; + return ( + + Push to Underlay + + } + > + Loading… + + ); } const isConfigured = Boolean(org && collection && hasKey); @@ -497,7 +508,14 @@ const UnderlaySettings = (_props: Props) => { : null; return ( - + + Push to Underlay + + } + >

Push this community’s releases and metadata to an Underlay collection. Only new or changed content is transferred. diff --git a/server/community/model.ts b/server/community/model.ts index 89023b8133..27b2cd16ec 100644 --- a/server/community/model.ts +++ b/server/community/model.ts @@ -256,6 +256,27 @@ export class Community extends Model< @Column(DataType.TEXT) declare kfOrgId: string | null; + /** + * CMS mode: the community is only visible to members; public visitors are + * redirected to canonicalBaseUrl (or shown a not-found page if none is set). + */ + @Default(false) + @AllowNull(false) + @Column(DataType.BOOLEAN) + declare cmsMode: CreationOptional; + + /** External canonical home for this community's content, e.g. https://journal.example.org */ + @Column(DataType.TEXT) + declare canonicalBaseUrl: string | null; + + /** + * Template for canonical pub URLs on the external site, with {slug} as the + * pub slug, e.g. https://journal.example.org/articles/{slug}. When unset, + * canonicalBaseUrl + /pub/{slug} is used. + */ + @Column(DataType.TEXT) + declare canonicalPubUrlTemplate: string | null; + @BelongsTo(() => CommunityTemplate, { as: 'template', foreignKey: 'templateId', diff --git a/server/community/permissions.ts b/server/community/permissions.ts index 133a1d38f5..a2973650bd 100644 --- a/server/community/permissions.ts +++ b/server/community/permissions.ts @@ -71,17 +71,19 @@ export const getPermissions = async ({ const canUpdate = scopeData.activePermissions.canManage; const canAdmin = scopeData.activePermissions.canAdmin; - const canExportCommunity = - scopeData.activePermissions.canAdminCommunity || scopeData.activePermissions.isSuperAdmin; + const isSuperAdmin = scopeData.activePermissions.isSuperAdmin; + const canExportCommunity = scopeData.activePermissions.canAdminCommunity || isSuperAdmin; - // only admins can edit analytics settings - const editPropsWithAnalytics = canAdmin - ? ([...editProps, 'analyticsSettings'] as const) - : editProps; + // only admins can edit analytics settings and toggle CMS mode + const adminEditProps = canAdmin ? (['analyticsSettings', 'cmsMode'] as const) : ([] as const); + // only superadmins can point a community at an external canonical URL + const superAdminEditProps = isSuperAdmin + ? (['canonicalBaseUrl', 'canonicalPubUrlTemplate'] as const) + : ([] as const); return { create: true, - update: canUpdate ? editPropsWithAnalytics : false, + update: canUpdate ? [...editProps, ...adminEditProps, ...superAdminEditProps] : false, admin: canAdmin, communityExport: canExportCommunity, }; diff --git a/server/doi/api.ts b/server/doi/api.ts index 7803b6cadf..a1f13c0b57 100644 --- a/server/doi/api.ts +++ b/server/doi/api.ts @@ -14,9 +14,11 @@ export const router = Router(); const assertUserAuthorized = async (target, requestIds) => { const permissions = await getPermissions(requestIds); + console.log('permissions', permissions); const isAuthenticated = (target === 'pub' && permissions.pub) || (target === 'collection' && permissions.collection); + console.log('isAuthenticated', isAuthenticated); if (!isAuthenticated) { throw new ForbiddenError(); diff --git a/server/doi/queries.ts b/server/doi/queries.ts index f935ed542b..ce276df3d0 100644 --- a/server/doi/queries.ts +++ b/server/doi/queries.ts @@ -60,7 +60,17 @@ export const findPub = (pubId) => const findCommunity = (communityId) => Community.findOne({ where: { id: communityId }, - attributes: ['id', 'title', 'issn', 'domain', 'subdomain', 'citeAs', 'publishAs'], + attributes: [ + 'id', + 'title', + 'issn', + 'domain', + 'subdomain', + 'citeAs', + 'publishAs', + 'canonicalBaseUrl', + 'canonicalPubUrlTemplate', + ], }); export const persistCrossrefDepositRecord = async (ids, depositJson) => { diff --git a/server/errorPages/communityNotFound.html b/server/errorPages/communityNotFound.html index f34a39e097..3b2ed5d52b 100644 --- a/server/errorPages/communityNotFound.html +++ b/server/errorPages/communityNotFound.html @@ -70,6 +70,21 @@ height: 150px; opacity: 0.5; } + + .login-button { + display: inline-block; + margin-top: 16px; + padding: 8px 20px; + border-radius: 3px; + background-color: #408fec; + color: #ffffff; + font-size: 15px; + text-decoration: none; + } + + .login-button:hover { + background-color: #2f7fdc; + } @@ -92,9 +107,10 @@

We can't find that Community.

Check to make sure you've typed the URL correctly. If you're certain there should be - a Community here, please make sure you're logged in first and then reload this page, or + a Community here, it may only be visible to its members. If the problem persists, contact us.

+ diff --git a/server/routes/pubDocument.tsx b/server/routes/pubDocument.tsx index d761b34cba..e0a6ffcd8e 100644 --- a/server/routes/pubDocument.tsx +++ b/server/routes/pubDocument.tsx @@ -29,7 +29,7 @@ import { createLogger } from 'server/utils/queryHelpers/communityGet'; import { hostIsValid } from 'server/utils/routes'; import { generateMetaComponents, renderToNodeStream } from 'server/utils/ssr'; import { getCorrectHostname } from 'utils/caching/getCorrectHostname'; -import { pubUrl } from 'utils/canonicalUrls'; +import { canonicalPubUrl } from 'utils/canonicalUrls'; import { getNextCollectionPub } from 'utils/collections/getNextCollectionPub'; import { getPrimaryCollection } from 'utils/collections/primary'; import { getGoogleScholarNotes, getPdfDownloadUrl, getTextAbstract } from 'utils/pub/metadata'; @@ -75,7 +75,7 @@ const renderPubDocument = ( initialData, notes: getGoogleScholarNotes(Object.values(pubData.initialStructuredCitations)), publishedAt: getPubPublishedDate(pubData), - canonicalUrl: pubUrl(initialData.communityData, pubData), + canonicalUrl: canonicalPubUrl(initialData.communityData, pubData), textAbstract: pubData.initialDoc ? getTextAbstract(pubData.initialDoc) : '', title: pubData.title, unlisted: !pubData.isRelease, diff --git a/server/routes/robots.ts b/server/routes/robots.ts index dd701d946d..f47ad4dc6b 100644 --- a/server/routes/robots.ts +++ b/server/routes/robots.ts @@ -16,6 +16,13 @@ const buildRobotsFile = (community) => { Disallow: / `).trim(); } + if (community?.cmsMode) { + // CMS-mode communities are members-only; their canonical home is elsewhere + return stripIndent(` + User-agent: * + Disallow: / + `).trim(); + } if (community) { return stripIndent(` User-agent: * diff --git a/server/routes/sitemap.ts b/server/routes/sitemap.ts index cff29598b1..8cee484f8d 100644 --- a/server/routes/sitemap.ts +++ b/server/routes/sitemap.ts @@ -134,6 +134,9 @@ router.get( } const { communityData } = await getInitialData(req, { isDashboard: true }); + if (communityData.cmsMode) { + return res.sendStatus(404); + } const sitemapFileStream = await getSitemapIndex(communityData, 'sitemap-index.xml'); res.header('Content-Encoding', 'gzip'); @@ -158,6 +161,9 @@ router.get( } const { communityData } = await getInitialData(req, { isDashboard: true }); + if (communityData.cmsMode) { + return res.sendStatus(404); + } const sitemapFileStream = await getSitemapIndex( communityData, sitemapIndexOrSitemapFilename, diff --git a/server/rss/queries.ts b/server/rss/queries.ts index b17f1c249f..2303e9de39 100644 --- a/server/rss/queries.ts +++ b/server/rss/queries.ts @@ -17,7 +17,10 @@ import { Release, } from 'server/models'; import { sequelize } from 'server/sequelize'; -import { communityUrl as getCommunityUrl, pubUrl } from 'utils/canonicalUrls'; +import { + canonicalCommunityUrl as getCommunityUrl, + canonicalPubUrl as pubUrl, +} from 'utils/canonicalUrls'; import { sortByPrimaryStatus } from 'utils/collections/primary'; import { getAllPubContributors, getContributorName } from 'utils/contributors'; import { getFormattedDownloadUrl, getPublicExportUrl } from 'utils/pub/downloads'; diff --git a/server/utils/citations/generateCitationHtml.ts b/server/utils/citations/generateCitationHtml.ts index 5b1c93fc51..123ffac70f 100644 --- a/server/utils/citations/generateCitationHtml.ts +++ b/server/utils/citations/generateCitationHtml.ts @@ -1,7 +1,7 @@ import Cite from 'citation-js'; import * as types from 'types'; -import { pubUrl } from 'utils/canonicalUrls'; +import { canonicalPubUrl as pubUrl } from 'utils/canonicalUrls'; import { type CitationStyleKind, renderJournalCitationForCitations } from 'utils/citations'; import getCollectionDoi from 'utils/collections/getCollectionDoi'; import { getPrimaryCollection } from 'utils/collections/primary'; diff --git a/server/utils/errors.ts b/server/utils/errors.ts index cda1b2470e..de74824412 100644 --- a/server/utils/errors.ts +++ b/server/utils/errors.ts @@ -3,6 +3,7 @@ import type { NextFunction, Request, Response } from 'express'; import type { ForbiddenSlugStatus } from 'types'; import * as Sentry from '@sentry/node'; +import { readFileSync } from 'fs'; import { resolve } from 'path'; import { env } from 'server/env'; @@ -12,6 +13,7 @@ import { isRequestAborted } from '../abort'; export enum PubPubApplicationError { ForbiddenSlug = 'forbidden-slug', CommunityIsSpam = 'community-is-spam', + CommunityIsPrivate = 'community-is-private', } class PubPubBaseError extends Error { @@ -39,6 +41,34 @@ export const PubPubError = { super(PubPubApplicationError.CommunityIsSpam, 'Community is spam'); } }, + /* A CMS-mode community, which is only visible to members */ + CommunityIsPrivateError: class extends PubPubBaseError { + constructor() { + super(PubPubApplicationError.CommunityIsPrivate, 'Community is private'); + } + }, +}; + +let communityNotFoundTemplate: string | null = null; + +/** + * Renders the "community not found" page, adding a login prompt for + * logged-out visitors: gated communities (spam-flagged or CMS mode) serve + * this page to non-members, and members need a way to sign in from it. + */ +const renderCommunityNotFound = (req: Request) => { + if (communityNotFoundTemplate === null) { + communityNotFoundTemplate = readFileSync( + resolve(__dirname, '../errorPages/communityNotFound.html'), + 'utf8', + ); + } + const isLoggedIn = Boolean((req.user as any)?.id); + const loginSection = isLoggedIn + ? '' + : `

Expecting something here?

+ `; + return communityNotFoundTemplate.replace('', loginSection); }; export class HTTPStatusError extends Error { @@ -93,11 +123,10 @@ export const handleErrors = (req: Request, res: Response, next: NextFunction) => if ( err.message === 'Community Not Found' || - err instanceof PubPubError.CommunityIsSpamError + err instanceof PubPubError.CommunityIsSpamError || + err instanceof PubPubError.CommunityIsPrivateError ) { - return res - .status(404) - .sendFile(resolve(__dirname, '../errorPages/communityNotFound.html')); + return res.status(404).send(renderCommunityNotFound(req)); } if (err.message.indexOf('UseCustomDomain:') === 0) { diff --git a/server/utils/initData.ts b/server/utils/initData.ts index 15e2ece972..1f495a8b75 100644 --- a/server/utils/initData.ts +++ b/server/utils/initData.ts @@ -8,6 +8,7 @@ import { isUserMemberOfScope } from 'server/member/queries'; import { UserNotification } from 'server/models'; import { isUserSuperAdmin } from 'server/user/queries'; import { getDismissedUserDismissables } from 'server/userDismissable/queries'; +import { isAuthBypassPath, isCmsGateBypassPath } from 'utils/cms'; import { getAppCommit, isDuqDuq, isProd, isQubQub, shouldForceBasePubPub } from 'utils/environment'; import { PubPubError } from './errors'; @@ -131,7 +132,12 @@ export const getInitialData = async ( : { domain: hostname }; const communityData = await getCommunity(locationData, whereQuery); - if (communityData.spamTag && communityData.spamTag.status !== 'confirmed-not-spam') { + const isSpamGated = + communityData.spamTag && + communityData.spamTag.status !== 'confirmed-not-spam' && + !isAuthBypassPath(req.path); + const isCmsGated = communityData.cmsMode && !isCmsGateBypassPath(req.path); + if (isSpamGated || isCmsGated) { const [isMemberOfCommunity, isSuperadmin] = await Promise.all([ isUserMemberOfScope({ userId: loginData.id, @@ -140,7 +146,10 @@ export const getInitialData = async ( isUserSuperAdmin({ userId: loginData.id }), ]); if (!isMemberOfCommunity && !isSuperadmin) { - throw new PubPubError.CommunityIsSpamError(); + if (isSpamGated) { + throw new PubPubError.CommunityIsSpamError(); + } + throw new PubPubError.CommunityIsPrivateError(); } } diff --git a/server/utils/ssr.tsx b/server/utils/ssr.tsx index d425f365a7..3dcb38d0bb 100644 --- a/server/utils/ssr.tsx +++ b/server/utils/ssr.tsx @@ -367,7 +367,7 @@ export const generateMetaComponents = (metaProps: MetaProps) => { }); outputComponents = [...outputComponents, citationNoteTags]; } - if (!isProd() || unlisted) { + if (!isProd() || unlisted || initialData.communityData.cmsMode) { outputComponents = [ ...outputComponents, , diff --git a/tools/migrations/2026_07_23_addCmsModeToCommunities.js b/tools/migrations/2026_07_23_addCmsModeToCommunities.js new file mode 100644 index 0000000000..ae5e5ac420 --- /dev/null +++ b/tools/migrations/2026_07_23_addCmsModeToCommunities.js @@ -0,0 +1,21 @@ +export const up = async ({ Sequelize, sequelize }) => { + await sequelize.queryInterface.addColumn('Communities', 'cmsMode', { + type: Sequelize.BOOLEAN, + allowNull: false, + defaultValue: false, + }); + await sequelize.queryInterface.addColumn('Communities', 'canonicalBaseUrl', { + type: Sequelize.TEXT, + allowNull: true, + }); + await sequelize.queryInterface.addColumn('Communities', 'canonicalPubUrlTemplate', { + type: Sequelize.TEXT, + allowNull: true, + }); +}; + +export const down = async ({ sequelize }) => { + await sequelize.queryInterface.removeColumn('Communities', 'canonicalPubUrlTemplate'); + await sequelize.queryInterface.removeColumn('Communities', 'canonicalBaseUrl'); + await sequelize.queryInterface.removeColumn('Communities', 'cmsMode'); +}; diff --git a/utils/__tests__/cms.test.ts b/utils/__tests__/cms.test.ts new file mode 100644 index 0000000000..245744feda --- /dev/null +++ b/utils/__tests__/cms.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from 'vitest'; + +import { + canonicalCollectionUrl, + canonicalCommunityUrl, + canonicalPubUrl, +} from 'utils/canonicalUrls'; +import { isAuthBypassPath, isCmsGateBypassPath } from 'utils/cms'; + +const plainCommunity = { subdomain: 'demo', domain: null } as any; +const cmsCommunity = { + subdomain: 'demo', + domain: null, + cmsMode: true, + canonicalBaseUrl: 'https://journal.example.org/', +} as any; +const templatedCommunity = { + ...cmsCommunity, + canonicalPubUrlTemplate: 'https://journal.example.org/articles/{slug}', +} as any; + +describe('canonicalCommunityUrl', () => { + it('falls back to the pubpub community url', () => { + expect(canonicalCommunityUrl(plainCommunity)).toEqual('https://demo.pubpub.org'); + }); + it('uses canonicalBaseUrl without a trailing slash', () => { + expect(canonicalCommunityUrl(cmsCommunity)).toEqual('https://journal.example.org'); + }); +}); + +describe('canonicalCollectionUrl', () => { + it('builds collection urls on the canonical base', () => { + expect(canonicalCollectionUrl(cmsCommunity, { slug: 'issue-1' })).toEqual( + 'https://journal.example.org/issue-1', + ); + }); +}); + +describe('canonicalPubUrl', () => { + it('falls back to the pubpub pub url', () => { + expect(canonicalPubUrl(plainCommunity, { slug: 'my-pub' })).toEqual( + 'https://demo.pubpub.org/pub/my-pub', + ); + }); + it('uses canonicalBaseUrl when set', () => { + expect(canonicalPubUrl(cmsCommunity, { slug: 'my-pub' })).toEqual( + 'https://journal.example.org/pub/my-pub', + ); + }); + it('prefers canonicalPubUrlTemplate', () => { + expect(canonicalPubUrl(templatedCommunity, { slug: 'my-pub' })).toEqual( + 'https://journal.example.org/articles/my-pub', + ); + }); +}); + +describe('isAuthBypassPath', () => { + it('matches auth and legal pages', () => { + expect(isAuthBypassPath('/login')).toBe(true); + expect(isAuthBypassPath('/signup')).toBe(true); + expect(isAuthBypassPath('/password-reset/abc/def')).toBe(true); + expect(isAuthBypassPath('/legal/terms')).toBe(true); + }); + it('does not match the dashboard or content paths', () => { + expect(isAuthBypassPath('/dash')).toBe(false); + expect(isAuthBypassPath('/')).toBe(false); + expect(isAuthBypassPath('/pub/my-pub')).toBe(false); + expect(isAuthBypassPath('/loginish')).toBe(false); + }); +}); + +describe('isCmsGateBypassPath', () => { + it('bypasses admin and auth pages', () => { + expect(isCmsGateBypassPath('/dash')).toBe(true); + expect(isCmsGateBypassPath('/dash/settings')).toBe(true); + expect(isCmsGateBypassPath('/login')).toBe(true); + expect(isCmsGateBypassPath('/signup')).toBe(true); + expect(isCmsGateBypassPath('/password-reset/abc/def')).toBe(true); + expect(isCmsGateBypassPath('/legal/terms')).toBe(true); + }); + it('bypasses robots and sitemaps so crawler rules stay readable', () => { + expect(isCmsGateBypassPath('/robots.txt')).toBe(true); + expect(isCmsGateBypassPath('/sitemap.xml')).toBe(true); + expect(isCmsGateBypassPath('/sitemap-index.xml')).toBe(true); + }); + it('does not bypass content paths', () => { + expect(isCmsGateBypassPath('/')).toBe(false); + expect(isCmsGateBypassPath('/pub/my-pub')).toBe(false); + expect(isCmsGateBypassPath('/dashing-page')).toBe(false); + expect(isCmsGateBypassPath('/loginish')).toBe(false); + }); +}); diff --git a/utils/api/schemas/community.ts b/utils/api/schemas/community.ts index 528b0b879f..4509683695 100644 --- a/utils/api/schemas/community.ts +++ b/utils/api/schemas/community.ts @@ -99,6 +99,12 @@ export const communitySchema = baseSchema.extend({ scopeSummaryId: z.string().uuid().nullable(), templateId: z.string().uuid().nullable(), kfOrgId: z.string().nullable(), + cmsMode: z.boolean().default(false), + canonicalBaseUrl: z.string().url().nullable(), + canonicalPubUrlTemplate: z + .string() + .regex(/\{slug\}/, 'Template must contain {slug}') + .nullable(), accentTextColor: z.string(), analyticsSettings: analyticsSettingsSchema, }) satisfies z.ZodType; diff --git a/utils/canonicalUrls.js b/utils/canonicalUrls.js index ec7e42014b..569e99fc91 100644 --- a/utils/canonicalUrls.js +++ b/utils/canonicalUrls.js @@ -119,6 +119,53 @@ export const bestPubUrl = ({ pubData, communityData }, options = {}) => { return pubShortUrl(pubData); }; +/** + * The public canonical URL for a community. Unlike communityUrl, this honors + * canonicalBaseUrl for CMS-mode communities whose content is published on an + * external site. Use for content-facing URLs (canonical tags, citations, + * exports, deposits, feeds) — NOT for in-app navigation. + * @param {Object} community + * @param {string} community.subdomain + * @param {string | null | undefined} [community.domain] + * @param {string | null | undefined} [community.canonicalBaseUrl] + * @returns {string} + */ +export const canonicalCommunityUrl = (community) => { + if (community.canonicalBaseUrl) { + return community.canonicalBaseUrl.replace(/\/+$/, ''); + } + return communityUrl(community); +}; + +/** + * The public canonical URL for a collection. Honors canonicalBaseUrl for + * CMS-mode communities. Use for content-facing URLs — NOT for in-app + * navigation. + */ +export const canonicalCollectionUrl = (community, collection) => { + return `${canonicalCommunityUrl(community)}/${collection.slug}`; +}; + +/** + * The public canonical URL for a pub. Honors canonicalPubUrlTemplate and + * canonicalBaseUrl for CMS-mode communities. Use for content-facing URLs — + * NOT for in-app navigation. + * @param {import('types').Community | import('server/models').Community | null} community + * @param {PubForUrl} pub + * @param options + * @returns {string} + */ +export const canonicalPubUrl = (community, pub, options = {}) => { + if (community && community.canonicalPubUrlTemplate) { + return community.canonicalPubUrlTemplate.replace(/\{slug\}/g, pub.slug); + } + if (community && community.canonicalBaseUrl) { + const url = pubUrl(community, pub, { ...options, absolute: true }); + return url.replace(communityUrl(community), canonicalCommunityUrl(community)); + } + return pubUrl(community, pub, options); +}; + export const doiUrl = (doi) => `https://doi.org/${doi}`; export const pageUrl = (community, page) => { diff --git a/utils/cms.ts b/utils/cms.ts new file mode 100644 index 0000000000..43735dda64 --- /dev/null +++ b/utils/cms.ts @@ -0,0 +1,31 @@ +/** + * Auth and legal pages stay reachable on gated communities (spam-flagged or + * CMS mode) so that members can sign in from the not-found page instead of + * dead-ending on it. + */ +export const AUTH_BYPASS_PREFIXES = [ + '/login', + '/logout', + '/signup', + '/password-reset', + '/legal', + '/privacy', + '/tos', +]; + +const matchesPrefix = (path: string, prefixes: string[]) => + prefixes.some((prefix) => path === prefix || path.startsWith(`${prefix}/`)); + +export const isAuthBypassPath = (path: string) => matchesPrefix(path, AUTH_BYPASS_PREFIXES); + +/** + * Paths that stay on PubPub even when a CMS-mode community hides itself from + * the public: auth pages, the dashboard, and crawler files (robots.txt and + * sitemaps stay readable so the disallow/noindex rules are served; those + * routes handle CMS mode themselves). + */ +export const isCmsGateBypassPath = (path: string) => + path === '/robots.txt' || + /^\/sitemap[^/]*\.xml$/.test(path) || + isAuthBypassPath(path) || + matchesPrefix(path, ['/dash']); diff --git a/utils/crossref/transform/collection.js b/utils/crossref/transform/collection.js index ee0cc3292f..91cbd5c92e 100644 --- a/utils/crossref/transform/collection.js +++ b/utils/crossref/transform/collection.js @@ -1,4 +1,4 @@ -import { collectionUrl } from 'utils/canonicalUrls'; +import { canonicalCollectionUrl as collectionUrl } from 'utils/canonicalUrls'; import { deserializeMetadata } from 'utils/collections/metadata'; import transformAttributions from './attributions'; diff --git a/utils/crossref/transform/community.js b/utils/crossref/transform/community.js index eadca939f3..bdfe59c844 100644 --- a/utils/crossref/transform/community.js +++ b/utils/crossref/transform/community.js @@ -1,4 +1,4 @@ -import { communityUrl } from 'utils/canonicalUrls'; +import { canonicalCommunityUrl as communityUrl } from 'utils/canonicalUrls'; const getLanguageForCommunity = () => 'en'; diff --git a/utils/crossref/transform/pub.js b/utils/crossref/transform/pub.js index d34f5c48e5..9c39092536 100644 --- a/utils/crossref/transform/pub.js +++ b/utils/crossref/transform/pub.js @@ -1,4 +1,4 @@ -import { pubUrl } from 'utils/canonicalUrls'; +import { canonicalPubUrl as pubUrl } from 'utils/canonicalUrls'; import { extractDoiFromUrl } from 'utils/crossref/parseDoi'; import { getPubPublishedDate } from 'utils/pub/pubDates'; import { relationTypeDefinitions } from 'utils/pubEdge/relations'; diff --git a/utils/pubEdge/helpers.ts b/utils/pubEdge/helpers.ts index 1a5e16d43a..af7966b2d1 100644 --- a/utils/pubEdge/helpers.ts +++ b/utils/pubEdge/helpers.ts @@ -1,7 +1,7 @@ import type { Community, Pub } from 'server/models'; import type * as types from 'types'; -import { pubShortUrl, pubUrl } from 'utils/canonicalUrls'; +import { canonicalPubUrl, pubShortUrl } from 'utils/canonicalUrls'; import { getAllPubContributors } from 'utils/contributors'; import { formatDate } from 'utils/dates'; import { getPubPublishedDate } from 'utils/pub/pubDates'; @@ -22,10 +22,10 @@ export const getUrlForPub = ( communityData: types.Community | Community, ) => { if (communityData.id === pubData.communityId) { - return pubUrl(communityData, pubData); + return canonicalPubUrl(communityData, pubData); } if (pubData.community) { - return pubUrl(pubData.community, pubData); + return canonicalPubUrl(pubData.community, pubData); } return pubShortUrl(pubData); }; From 81294e29f7be679a4eea9ccd5acbdad8019295e8 Mon Sep 17 00:00:00 2001 From: "Thomas F. K. Jorna" Date: Mon, 27 Jul 2026 16:21:37 +0200 Subject: [PATCH 2/5] fix: make whole cms setting superadmin gated --- .../CommunitySettings/CmsSettings.tsx | 85 +++++++++---------- 1 file changed, 42 insertions(+), 43 deletions(-) diff --git a/client/containers/DashboardSettings/CommunitySettings/CmsSettings.tsx b/client/containers/DashboardSettings/CommunitySettings/CmsSettings.tsx index f2aa45cc40..682f207d7e 100644 --- a/client/containers/DashboardSettings/CommunitySettings/CmsSettings.tsx +++ b/client/containers/DashboardSettings/CommunitySettings/CmsSettings.tsx @@ -20,6 +20,9 @@ const CmsSettings = (props: Props) => { activePermissions: { isSuperAdmin }, }, } = usePageContext(); + if (!isSuperAdmin) { + return null; + } return ( { “community not found” page with an option to log in. )} - {isSuperAdmin && ( - <> - - Canonical URL - - } - helperText="The external site where this community's content is published, e.g. https://journal.example.org. Canonical tags, citations, exports, and new Crossref deposits will point to it." - type="text" - placeholder="https://journal.example.org" - value={canonicalBaseUrl || ''} - onChange={(evt) => { - updateCommunityData({ - canonicalBaseUrl: evt.target.value.trim() || null, - }); - }} - /> - - Pub URL template - - } - helperText={ - <> - Where a pub lives on the external site, with {'{slug}'}{' '} - standing in for the pub slug, e.g.{' '} - {'https://journal.example.org/articles/{slug}'}. Leave - empty to use the canonical URL + /pub/{'{slug}'}. - - } - type="text" - placeholder="https://journal.example.org/articles/{slug}" - value={canonicalPubUrlTemplate || ''} - onChange={(evt) => { - updateCommunityData({ - canonicalPubUrlTemplate: evt.target.value.trim() || null, - }); - }} - /> - - )} + + Canonical URL + + } + helperText="The external site where this community's content is published, e.g. https://journal.example.org. Canonical tags, citations, exports, and new Crossref deposits will point to it." + type="text" + placeholder="https://journal.example.org" + value={canonicalBaseUrl || ''} + onChange={(evt) => { + updateCommunityData({ + canonicalBaseUrl: evt.target.value.trim() || null, + }); + }} + /> + + Pub URL template + + } + helperText={ + <> + Where a pub lives on the external site, with {'{slug}'}{' '} + standing in for the pub slug, e.g.{' '} + {'https://journal.example.org/articles/{slug}'}. Leave empty to + use the canonical URL + /pub/{'{slug}'}. + + } + type="text" + placeholder="https://journal.example.org/articles/{slug}" + value={canonicalPubUrlTemplate || ''} + onChange={(evt) => { + updateCommunityData({ + canonicalPubUrlTemplate: evt.target.value.trim() || null, + }); + }} + /> ); }; From f36ca4677691fcc5a376a6d66f59826aa5d51871 Mon Sep 17 00:00:00 2001 From: "Thomas F. K. Jorna" Date: Mon, 27 Jul 2026 16:23:46 +0200 Subject: [PATCH 3/5] fix: apply robo suggestions --- server/community/model.ts | 4 ++-- server/doi/api.ts | 2 -- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/server/community/model.ts b/server/community/model.ts index 27b2cd16ec..c5bd06a7f0 100644 --- a/server/community/model.ts +++ b/server/community/model.ts @@ -257,8 +257,8 @@ export class Community extends Model< declare kfOrgId: string | null; /** - * CMS mode: the community is only visible to members; public visitors are - * redirected to canonicalBaseUrl (or shown a not-found page if none is set). + * CMS mode: the community is only visible to members; + * public visitors are shown a not-found page with a sign-in prompt. */ @Default(false) @AllowNull(false) diff --git a/server/doi/api.ts b/server/doi/api.ts index a1f13c0b57..7803b6cadf 100644 --- a/server/doi/api.ts +++ b/server/doi/api.ts @@ -14,11 +14,9 @@ export const router = Router(); const assertUserAuthorized = async (target, requestIds) => { const permissions = await getPermissions(requestIds); - console.log('permissions', permissions); const isAuthenticated = (target === 'pub' && permissions.pub) || (target === 'collection' && permissions.collection); - console.log('isAuthenticated', isAuthenticated); if (!isAuthenticated) { throw new ForbiddenError(); From 213dba3c31d74ee5ab6f31e7cc38eef0c86dd715 Mon Sep 17 00:00:00 2001 From: "Thomas F. K. Jorna" Date: Mon, 27 Jul 2026 16:23:59 +0200 Subject: [PATCH 4/5] Apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- utils/api/schemas/community.ts | 11 +++++++++++ utils/canonicalUrls.js | 2 +- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/utils/api/schemas/community.ts b/utils/api/schemas/community.ts index 4509683695..1755d2c7fb 100644 --- a/utils/api/schemas/community.ts +++ b/utils/api/schemas/community.ts @@ -104,6 +104,17 @@ export const communitySchema = baseSchema.extend({ canonicalPubUrlTemplate: z .string() .regex(/\{slug\}/, 'Template must contain {slug}') + .refine( + (template) => { + try { + const url = new URL(template.replace(/\{slug\}/g, 'test-slug')); + return url.protocol === 'http:' || url.protocol === 'https:'; + } catch { + return false; + } + }, + 'Template must be a valid absolute http(s) URL', + ) .nullable(), accentTextColor: z.string(), analyticsSettings: analyticsSettingsSchema, diff --git a/utils/canonicalUrls.js b/utils/canonicalUrls.js index 569e99fc91..1c39225ef4 100644 --- a/utils/canonicalUrls.js +++ b/utils/canonicalUrls.js @@ -131,7 +131,7 @@ export const bestPubUrl = ({ pubData, communityData }, options = {}) => { * @returns {string} */ export const canonicalCommunityUrl = (community) => { - if (community.canonicalBaseUrl) { + if (community?.cmsMode && community.canonicalBaseUrl) { return community.canonicalBaseUrl.replace(/\/+$/, ''); } return communityUrl(community); From 4606a45ecd09af7f194db415e99eb78527c82182 Mon Sep 17 00:00:00 2001 From: "Thomas F. K. Jorna" Date: Mon, 27 Jul 2026 17:18:43 +0200 Subject: [PATCH 5/5] fix: tsc --- utils/canonicalUrls.js | 1 + 1 file changed, 1 insertion(+) diff --git a/utils/canonicalUrls.js b/utils/canonicalUrls.js index 1c39225ef4..cdd31efd5f 100644 --- a/utils/canonicalUrls.js +++ b/utils/canonicalUrls.js @@ -126,6 +126,7 @@ export const bestPubUrl = ({ pubData, communityData }, options = {}) => { * exports, deposits, feeds) — NOT for in-app navigation. * @param {Object} community * @param {string} community.subdomain + * @param {boolean} community.cmsMode * @param {string | null | undefined} [community.domain] * @param {string | null | undefined} [community.canonicalBaseUrl] * @returns {string}