diff --git a/packages/host/app/components/operator-mode/publish-realm-modal.gts b/packages/host/app/components/operator-mode/publish-realm-modal.gts index c31f0b9723..4631cfa141 100644 --- a/packages/host/app/components/operator-mode/publish-realm-modal.gts +++ b/packages/host/app/components/operator-mode/publish-realm-modal.gts @@ -24,6 +24,9 @@ import { import { not } from '@cardstack/boxel-ui/helpers'; import { IconX, Warning as WarningIcon } from '@cardstack/boxel-ui/icons'; +import { ensureTrailingSlash } from '@cardstack/runtime-common'; +import { getPublishedRealmDomainOverrides } from '@cardstack/runtime-common/constants'; + import ModalContainer from '@cardstack/host/components/modal-container'; import PrivateDependencyViolationComponent from '@cardstack/host/components/operator-mode/private-dependency-violation'; import WithLoadedRealm from '@cardstack/host/components/with-loaded-realm'; @@ -221,6 +224,65 @@ export default class PublishRealmModal extends Component { return config.publishedRealmBoxelSiteDomain; } + // TODO: Remove with CS-9061 once published realm domain overrides are removed. + private getPublishedRealmOverrideUrl( + publishedRealmURL: string | null, + ): string | null { + let overrideDomain = getPublishedRealmDomainOverrides( + config.publishedRealmDomainOverrides, + )[ensureTrailingSlash(this.currentRealmURL)]; + if (!overrideDomain) { + return null; + } + + if (!publishedRealmURL) { + return null; + } + + let overriddenURL = new URL(publishedRealmURL); + overriddenURL.host = overrideDomain; + return ensureTrailingSlash(overriddenURL.toString()); + } + + get customSubdomainOverrideUrl() { + let publishedRealmURL = + this.claimedDomainPublishedUrl ?? + this.buildPublishedRealmUrl( + `${this.customSubdomainDisplay}.${this.customSubdomainBase}`, + ); + return this.getPublishedRealmOverrideUrl(publishedRealmURL); + } + + get shouldShowCustomSubdomainOverride() { + return ( + !!this.customSubdomainOverrideUrl && + this.realm.canWrite(this.currentRealmURL) + ); + } + + get isCustomSubdomainOverrideSelected() { + if (!this.customSubdomainOverrideUrl) { + return false; + } + return this.selectedPublishedRealmURLs.includes( + this.customSubdomainOverrideUrl, + ); + } + + get isCustomSubdomainOverridePublished() { + if (!this.customSubdomainOverrideUrl) { + return false; + } + return this.hostModeService.isPublished(this.customSubdomainOverrideUrl); + } + + get customSubdomainOverrideLastPublishedTime() { + if (!this.customSubdomainOverrideUrl) { + return null; + } + return this.getFormattedLastPublishedTime(this.customSubdomainOverrideUrl); + } + get customSubdomainDisplay() { if (this.claimedDomain) { return this.claimedDomain.subdomain; @@ -458,6 +520,20 @@ export default class PublishRealmModal extends Component { } } + @action + toggleCustomSubdomainOverride(event: Event) { + const overrideUrl = this.customSubdomainOverrideUrl; + if (!overrideUrl) { + return; + } + const input = event.target as HTMLInputElement; + if (input.checked) { + this.addPublishedRealmUrl(overrideUrl); + } else { + this.removePublishedRealmUrl(overrideUrl); + } + } + private addPublishedRealmUrl(url: string) { if (!this.selectedPublishedRealmURLs.includes(url)) { this.selectedPublishedRealmURLs = [ @@ -619,6 +695,13 @@ export default class PublishRealmModal extends Component { return this.getPublishErrorForUrl(this.claimedDomainPublishedUrl); } + get publishErrorForCustomSubdomainOverride() { + if (!this.customSubdomainOverrideUrl) { + return null; + } + return this.getPublishErrorForUrl(this.customSubdomainOverrideUrl); + } + ensureInitialSelectionsTask = restartableTask( async (claim: ClaimedDomain | null = null) => { await this.realm.ensureRealmMeta(this.currentRealmURL); @@ -1027,6 +1110,99 @@ export default class PublishRealmModal extends Component { {{/if}} + + {{#if this.shouldShowCustomSubdomainOverride}} + {{#let this.customSubdomainOverrideUrl as |overrideUrl|}} + {{#if overrideUrl}} +
+ + +
+ + + +
+ {{overrideUrl}} + {{#if this.isCustomSubdomainOverridePublished}} +
+ {{#if this.customSubdomainOverrideLastPublishedTime}} + Published + {{this.customSubdomainOverrideLastPublishedTime}} + {{/if}} + + {{#if (this.isUnpublishingRealm overrideUrl)}} + + Unpublishing… + {{else}} + + Unpublish + {{/if}} + +
+ {{else}} + Not published yet + {{/if}} +
+
+ {{#if this.isCustomSubdomainOverridePublished}} + + + Open Site + + {{/if}} + {{#if this.publishErrorForCustomSubdomainOverride}} +
+ {{this.publishErrorForCustomSubdomainOverride}} +
+ {{/if}} +
+ {{/if}} + {{/let}} + {{/if}} diff --git a/packages/host/app/config/environment.d.ts b/packages/host/app/config/environment.d.ts index a9b3d0a34e..62fe4f1d45 100644 --- a/packages/host/app/config/environment.d.ts +++ b/packages/host/app/config/environment.d.ts @@ -38,6 +38,7 @@ declare const config: { }; publishedRealmBoxelSpaceDomain: string; publishedRealmBoxelSiteDomain: string; + publishedRealmDomainOverrides: string; defaultSystemCardId: string; cardSizeLimitBytes: number; }; diff --git a/packages/realm-server/handlers/handle-publish-realm.ts b/packages/realm-server/handlers/handle-publish-realm.ts index 7e452568ed..f7467c5852 100644 --- a/packages/realm-server/handlers/handle-publish-realm.ts +++ b/packages/realm-server/handlers/handle-publish-realm.ts @@ -1,6 +1,5 @@ import type Koa from 'koa'; import { - ensureTrailingSlash, fetchUserPermissions, query, SupportedMimeType, @@ -11,10 +10,13 @@ import { asExpressions, param, PUBLISHED_DIRECTORY_NAME, + ensureTrailingSlash, + type DBAdapter, type PublishedRealmTable, fetchRealmPermissions, uuidv4, } from '@cardstack/runtime-common'; +import { getPublishedRealmDomainOverrides } from '@cardstack/runtime-common/constants'; import { ensureDirSync, copySync, readJsonSync, writeJsonSync } from 'fs-extra'; import { resolve, join } from 'path'; import { @@ -33,55 +35,82 @@ import { passwordFromSeed } from '@cardstack/runtime-common/matrix-client'; const log = logger('handle-publish'); -// Workaround to override published realm URLs to support custom domains. Remove in CS-9061. -const PUBLISHED_REALM_DOMAIN_OVERRIDES: Record< - string, - Record -> = { - '@buck:stack.cards': { - 'custombuck.staging.boxel.build': 'custombuck.stack.cards', - }, - '@ctse:stack.cards': { - 'docs.staging.boxel.build': 'docs.stack.cards', - 'home.staging.boxel.build': 'home.stack.cards', - 'whitepaper.staging.boxel.build': 'whitepaper.stack.cards', - }, - '@bucktest:boxel.ai': { - 'custombuck.boxel.site': 'custombuck.boxel.ai', - }, - '@official:boxel.ai': { - 'docs.boxel.site': 'docs.boxel.ai', - 'home.boxel.site': 'home.boxel.ai', - 'whitepaper.boxel.site': 'whitepaper.boxel.ai', - }, +const PUBLISHED_REALM_DOMAIN_OVERRIDES = getPublishedRealmDomainOverrides( + process.env.PUBLISHED_REALM_DOMAIN_OVERRIDES, +); + +type OverrideHost = { + host: string; + hostname: string; + port: string; }; -function maybeOverridePublishedRealmURL( +function parseOverrideHost(rawOverride: string): OverrideHost | null { + try { + let overrideURL = rawOverride.includes('://') + ? new URL(rawOverride) + : new URL(`https://${rawOverride}`); + return { + host: overrideURL.host.toLowerCase(), + hostname: overrideURL.hostname.toLowerCase(), + port: overrideURL.port, + }; + } catch { + return null; + } +} + +async function maybeApplyPublishedRealmOverride( + dbAdapter: DBAdapter, ownerUserId: string, + sourceRealmURL: string, publishedRealmURL: string, -): string { - let userOverrides = PUBLISHED_REALM_DOMAIN_OVERRIDES[ownerUserId]; - if (!userOverrides) { - return publishedRealmURL; +): Promise<{ applied: boolean; publishedRealmURL: string }> { + let overrideDomain = PUBLISHED_REALM_DOMAIN_OVERRIDES[sourceRealmURL]; + if (!overrideDomain) { + return { applied: false, publishedRealmURL }; + } + + let overrideHost = parseOverrideHost(overrideDomain); + if (!overrideHost) { + return { applied: false, publishedRealmURL }; } let publishedURL: URL; try { publishedURL = new URL(publishedRealmURL); } catch { - return publishedRealmURL; + return { applied: false, publishedRealmURL }; } - let overrideDomain = userOverrides[publishedURL.host.toLowerCase()]; - if (!overrideDomain) { - return publishedRealmURL; + let publishedHost = publishedURL.host.toLowerCase(); + let publishedHostname = publishedURL.hostname.toLowerCase(); + let matchesOverride = overrideHost.port + ? publishedHost === overrideHost.host + : publishedHostname === overrideHost.hostname; + if (!matchesOverride) { + return { applied: false, publishedRealmURL }; } - let overriddenURL = new URL(publishedRealmURL); - overriddenURL.host = overrideDomain; + let permissions = await fetchRealmPermissions( + dbAdapter, + new URL(sourceRealmURL), + ); + let effectivePermissions = new Set([ + ...(permissions['*'] ?? []), + ...(permissions['users'] ?? []), + ...(permissions[ownerUserId] ?? []), + ]); + if (!effectivePermissions.has('write')) { + return { applied: false, publishedRealmURL }; + } - let overriddenRealmURL = overriddenURL.toString(); - return ensureTrailingSlash(overriddenRealmURL); + let overriddenURL = new URL(publishedRealmURL); + overriddenURL.host = overrideHost.host; + return { + applied: true, + publishedRealmURL: ensureTrailingSlash(overriddenURL.toString()), + }; } function rewriteHostHomeForPublishedRealm( @@ -149,53 +178,62 @@ export default function handlePublishRealm({ ? json.publishedRealmURL : `${json.publishedRealmURL}/`; - let validPublishedRealmDomains = Object.values( - domainsForPublishedRealms || {}, - ); - try { - let publishedURL = new URL(publishedRealmURL); - if (validPublishedRealmDomains && validPublishedRealmDomains.length > 0) { - let isValidDomain = validPublishedRealmDomains.some((domain) => - publishedURL.host.endsWith(domain), - ); - if (!isValidDomain) { - await sendResponseForBadRequest( - ctxt, - `publishedRealmURL must use a valid domain ending with one of: ${validPublishedRealmDomains.join(', ')}`, - ); - return; - } - } - } catch (e) { - await sendResponseForBadRequest( - ctxt, - 'publishedRealmURL is not a valid URL', - ); - return; - } - let { user: ownerUserId, sessionRoom: tokenSessionRoom } = token; - let permissions = await fetchRealmPermissions( - dbAdapter, - new URL(sourceRealmURL), - ); - if (!permissions[ownerUserId]?.includes('realm-owner')) { - await sendResponseForForbiddenRequest( - ctxt, - `${ownerUserId} does not have enough permission to publish this realm`, - ); - return; - } - let overriddenPublishedRealmURL = maybeOverridePublishedRealmURL( + let overrideResult = await maybeApplyPublishedRealmOverride( + dbAdapter, ownerUserId, + sourceRealmURL, publishedRealmURL, ); - if (overriddenPublishedRealmURL !== publishedRealmURL) { + + if (overrideResult.applied) { log.info( - `Overriding publishedRealmURL for ${ownerUserId} from ${publishedRealmURL} to ${overriddenPublishedRealmURL}`, + `Overriding publishedRealmURL for ${ownerUserId} from ${publishedRealmURL} to ${overrideResult.publishedRealmURL}`, + ); + publishedRealmURL = overrideResult.publishedRealmURL; + } + + if (!overrideResult.applied) { + let validPublishedRealmDomains = Object.values( + domainsForPublishedRealms || {}, ); - publishedRealmURL = overriddenPublishedRealmURL; + try { + let publishedURL = new URL(publishedRealmURL); + if ( + validPublishedRealmDomains && + validPublishedRealmDomains.length > 0 + ) { + let isValidDomain = validPublishedRealmDomains.some((domain) => + publishedURL.host.endsWith(domain), + ); + if (!isValidDomain) { + await sendResponseForBadRequest( + ctxt, + `publishedRealmURL must use a valid domain ending with one of: ${validPublishedRealmDomains.join(', ')}`, + ); + return; + } + } + } catch (e) { + await sendResponseForBadRequest( + ctxt, + 'publishedRealmURL is not a valid URL', + ); + return; + } + + let permissions = await fetchRealmPermissions( + dbAdapter, + new URL(sourceRealmURL), + ); + if (!permissions[ownerUserId]?.includes('realm-owner')) { + await sendResponseForForbiddenRequest( + ctxt, + `${ownerUserId} does not have enough permission to publish this realm`, + ); + return; + } } try { diff --git a/packages/realm-server/server.ts b/packages/realm-server/server.ts index 715a92ef56..4b710494e5 100644 --- a/packages/realm-server/server.ts +++ b/packages/realm-server/server.ts @@ -466,6 +466,9 @@ export class RealmServer { assetsURL: this.assetsURL.href, realmServerURL: this.serverURL.href, cardSizeLimitBytes: this.cardSizeLimitBytes, + publishedRealmDomainOverrides: + process.env.PUBLISHED_REALM_DOMAIN_OVERRIDES ?? + config.publishedRealmDomainOverrides, }); return `${g1}${encodeURIComponent(JSON.stringify(config))}${g3}`; }, diff --git a/packages/runtime-common/constants.ts b/packages/runtime-common/constants.ts index 680a64ba11..76f1b15117 100644 --- a/packages/runtime-common/constants.ts +++ b/packages/runtime-common/constants.ts @@ -86,4 +86,45 @@ export const DEFAULT_PERMISSIONS = Object.freeze([ 'realm-owner', ]) as RealmPermissions['user']; +// Workaround to override published realm URLs to support custom domains. Remove in CS-9061. +export const PUBLISHED_REALM_DOMAIN_OVERRIDES: Record = {}; + +export function parsePublishedRealmDomainOverrides( + rawOverrides: string | undefined, +): Record { + if (!rawOverrides) { + return {}; + } + + try { + let parsed = JSON.parse(rawOverrides); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + return {}; + } + + let overrides: Record = {}; + for (let [key, value] of Object.entries(parsed)) { + if (typeof key === 'string' && typeof value === 'string') { + overrides[key] = value; + } + } + return overrides; + } catch { + return {}; + } +} + +export function getPublishedRealmDomainOverrides( + rawOverrides?: string | Record, +): Record { + let envOverrides = + typeof rawOverrides === 'string' + ? parsePublishedRealmDomainOverrides(rawOverrides) + : (rawOverrides ?? {}); + return { + ...PUBLISHED_REALM_DOMAIN_OVERRIDES, + ...envOverrides, + }; +} + export const PUBLISHED_DIRECTORY_NAME = '_published';