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 af8aeeff1a..2f0fa73797 100644 --- a/packages/host/app/components/operator-mode/publish-realm-modal.gts +++ b/packages/host/app/components/operator-mode/publish-realm-modal.gts @@ -101,6 +101,12 @@ export default class PublishRealmModal extends Component { @tracked private customSubdomainError: string | null = null; @tracked private isCheckingCustomSubdomain = false; @tracked private claimedDomain: ClaimedDomain | null = null; + // Server-issued random path segment for the "Unlisted Link" target + // (`.//`). Loaded from the + // realm-server on open (`loadUnlistedPathTask`) — the server owns the slug so + // it can't be hand-picked — and replaced via "New link" + // (`regenerateUnlistedLinkTask`). Null while loading. + @tracked private unlistedPathSegment: string | null = null; @tracked private privateDependencyCheckError: string | null = null; @tracked private privateDependencyViolations: @@ -119,6 +125,7 @@ export default class PublishRealmModal extends Component { super(owner, args); this.ensureInitialSelectionsTask.perform(); this.fetchBoxelClaimedDomain.perform(); + this.loadUnlistedPathTask.perform(); this.checkPrivateDependenciesTask.perform(); this.checkDanglingRoutingRulesTask.perform(); } @@ -245,6 +252,71 @@ export default class PublishRealmModal extends Component { return this.selectedPublishedRealmURLs.includes(this.subdirectoryRealmUrl); } + // The "Unlisted Link" target: the user's own space subdomain with a random + // path segment instead of the realm name, e.g. + // `https://.//`. Like the Boxel Space target + // it is namespaced to the owner, so it needs no claim/availability check. + get unlistedRealmUrl(): string | null { + if (!this.unlistedPathSegment) { + return null; + } + return resolvePublishedRealmUrl( + { type: 'subdirectory', name: this.unlistedPathSegment }, + { + protocol: this.getProtocol(), + matrixUsername: this.getMatrixUsername(), + spaceDomain: this.getDefaultPublishedRealmDomain(), + }, + ); + } + + get unlistedRealmParts() { + return { + baseUrl: `${this.getProtocol()}://${this.getMatrixUsername()}.${this.getDefaultPublishedRealmDomain()}/`, + pathSegment: this.unlistedPathSegment ?? '', + }; + } + + get isLoadingUnlistedLink() { + return this.loadUnlistedPathTask.isRunning || !this.unlistedPathSegment; + } + + get isRegeneratingUnlistedLink() { + return this.regenerateUnlistedLinkTask.isRunning; + } + + get isUnlistedCheckboxDisabled() { + return this.isLoadingUnlistedLink || this.isUnpublishingAnyRealms; + } + + get isUnlistedRealmSelected() { + return ( + !!this.unlistedRealmUrl && + this.selectedPublishedRealmURLs.includes(this.unlistedRealmUrl) + ); + } + + get isUnlistedRealmPublished() { + return ( + !!this.unlistedRealmUrl && + this.hostModeService.isPublished(this.unlistedRealmUrl) + ); + } + + get unlistedLastPublishedTime() { + if (!this.unlistedRealmUrl) { + return null; + } + return this.getFormattedLastPublishedTime(this.unlistedRealmUrl); + } + + get publishErrorForUnlistedLink() { + if (!this.unlistedRealmUrl) { + return null; + } + return this.getPublishErrorForUrl(this.unlistedRealmUrl); + } + get isCustomSubdomainSelected() { if (!this.claimedDomainPublishedUrl) { return false; @@ -582,6 +654,56 @@ export default class PublishRealmModal extends Component { } } + @action + toggleUnlistedDomain(event: Event) { + const unlistedUrl = this.unlistedRealmUrl; + if (!unlistedUrl) { + return; + } + const input = event.target as HTMLInputElement; + if (input.checked) { + this.addPublishedRealmUrl(unlistedUrl); + } else { + this.removePublishedRealmUrl(unlistedUrl); + } + } + + // Loads the realm's server-issued unlisted-link slug, allocating one if none + // exists yet. The server owns the slug, so the client never generates it. + private loadUnlistedPathTask = restartableTask(async () => { + try { + let { slug } = await this.realmServer.allocateUnlistedPath( + this.currentRealmURL, + ); + this.unlistedPathSegment = slug; + } catch (error) { + console.error('Failed to load unlisted link', error); + } + }); + + // Roll a fresh unlisted link via the server. Only meaningful before it is + // published — once published the URL is fixed. + private regenerateUnlistedLinkTask = restartableTask(async () => { + if (this.isUnlistedRealmPublished) { + return; + } + const wasSelected = this.isUnlistedRealmSelected; + const previousUrl = this.unlistedRealmUrl; + try { + let { slug } = await this.realmServer.allocateUnlistedPath( + this.currentRealmURL, + { regenerate: true }, + ); + this.removePublishedRealmUrl(previousUrl ?? undefined); + this.unlistedPathSegment = slug; + if (wasSelected && this.unlistedRealmUrl) { + this.addPublishedRealmUrl(this.unlistedRealmUrl); + } + } catch (error) { + console.error('Failed to regenerate unlisted link', error); + } + }); + @action toggleCustomSubdomain(event: Event) { if (this.claimedDomain) { @@ -1021,6 +1143,116 @@ export default class PublishRealmModal extends Component { {{/if}} +
+ + + +
+ + + + {{#if this.unlistedRealmUrl}} +
+ + {{this.unlistedRealmParts.baseUrl}}{{this.unlistedRealmParts.pathSegment}}/ + + {{#if this.isUnlistedRealmPublished}} +
+ Published + {{this.unlistedLastPublishedTime}} + + {{#if (this.isUnpublishingRealm this.unlistedRealmUrl)}} + + Unpublishing… + {{else}} + + Unpublish + {{/if}} + +
+ {{/if}} +
+ {{else}} + + Generating link… + + {{/if}} +
+ {{#if this.unlistedRealmUrl}} + {{#if this.isUnlistedRealmPublished}} + + + Open Site + + {{else}} + + {{#if this.isRegeneratingUnlistedLink}} + + Generating… + {{else}} + New link + {{/if}} + + {{/if}} + {{/if}} + {{#if this.publishErrorForUnlistedLink}} +
+ {{this.publishErrorForUnlistedLink}} +
+ {{/if}} +
+
{ + await this.login(); + + let response = await this.authedFetch( + `${this.url.href}_unlisted-realm-path`, + { + method: 'POST', + headers: { + Accept: SupportedMimeType.JSONAPI, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + sourceRealmURL, + regenerate: options.regenerate ?? false, + }), + }, + ); + + if (!response.ok) { + let errorText = await response.text(); + throw new Error( + `Allocate unlisted link failed: ${response.status} - ${errorText}`, + ); + } + + let { + data: { attributes }, + } = (await response.json()) as { + data: { attributes: { sourceRealmURL: string; slug: string } }; + }; + return { sourceRealmURL: attributes.sourceRealmURL, slug: attributes.slug }; + } + async deleteBoxelClaimedDomain(claimedDomainId: string): Promise { await this.login(); diff --git a/packages/host/config/schema/1780620712404_schema.sql b/packages/host/config/schema/1780620712404_schema.sql deleted file mode 100644 index 6a56a20d82..0000000000 --- a/packages/host/config/schema/1780620712404_schema.sql +++ /dev/null @@ -1,188 +0,0 @@ --- This is auto-generated by packages/realm-server/scripts/convert-to-sqlite.ts --- Please don't directly modify this file - - CREATE TABLE IF NOT EXISTS bot_commands ( - id NOT NULL, - bot_id NOT NULL, - command TEXT NOT NULL, - command_filter BLOB NOT NULL, - created_at NOT NULL, - PRIMARY KEY ( id ) -); - - CREATE TABLE IF NOT EXISTS bot_registrations ( - id NOT NULL, - username TEXT NOT NULL, - created_at NOT NULL, - PRIMARY KEY ( id ) -); - - CREATE TABLE IF NOT EXISTS boxel_index ( - url TEXT NOT NULL, - file_alias TEXT NOT NULL, - type TEXT NOT NULL, - realm_version INTEGER NOT NULL, - realm_url TEXT NOT NULL, - pristine_doc BLOB, - search_doc BLOB, - error_doc BLOB, - deps BLOB DEFAULT '[]', - types BLOB, - isolated_html TEXT, - indexed_at, - is_deleted BOOLEAN, - last_modified, - embedded_html BLOB, - atom_html TEXT, - fitted_html BLOB, - display_names BLOB, - resource_created_at, - icon_html TEXT, - head_html TEXT, - has_error BOOLEAN DEFAULT false NOT NULL, - last_known_good_deps BLOB, - markdown TEXT, - diagnostics BLOB, - PRIMARY KEY ( url, realm_url, type ) -); - - CREATE TABLE IF NOT EXISTS boxel_index_working ( - url TEXT NOT NULL, - file_alias TEXT NOT NULL, - type TEXT NOT NULL, - realm_version INTEGER NOT NULL, - realm_url TEXT NOT NULL, - pristine_doc BLOB, - search_doc BLOB, - error_doc BLOB, - deps BLOB DEFAULT '[]', - types BLOB, - icon_html TEXT, - isolated_html TEXT, - indexed_at, - is_deleted BOOLEAN, - last_modified, - embedded_html BLOB, - atom_html TEXT, - fitted_html BLOB, - display_names BLOB, - resource_created_at, - head_html TEXT, - has_error BOOLEAN DEFAULT false NOT NULL, - last_known_good_deps BLOB, - markdown TEXT, - diagnostics BLOB, - job_id INTEGER, - PRIMARY KEY ( url, realm_url, type ) -); - - CREATE TABLE IF NOT EXISTS incoming_webhooks ( - id NOT NULL, - username TEXT NOT NULL, - webhook_path TEXT NOT NULL, - verification_type TEXT NOT NULL, - verification_config BLOB NOT NULL, - signing_secret TEXT NOT NULL, - created_at NOT NULL, - updated_at NOT NULL, - PRIMARY KEY ( id ) -); - - CREATE TABLE IF NOT EXISTS job_scoped_instance_cache ( - job_id TEXT NOT NULL, - url TEXT NOT NULL, - result TEXT NOT NULL, - created_at DEFAULT CURRENT_TIMESTAMP NOT NULL, - PRIMARY KEY ( job_id, url ) -); - - CREATE TABLE IF NOT EXISTS module_transpile_cache ( - realm_url TEXT NOT NULL, - canonical_path TEXT NOT NULL, - body TEXT, - headers BLOB, - dependency_keys BLOB, - generation DEFAULT 0 NOT NULL, - created_at, - PRIMARY KEY ( realm_url, canonical_path ) -); - - CREATE TABLE IF NOT EXISTS modules ( - url TEXT NOT NULL, - cache_scope TEXT NOT NULL, - auth_user_id TEXT NOT NULL, - resolved_realm_url TEXT NOT NULL, - definitions BLOB, - deps BLOB, - error_doc BLOB, - created_at, - file_alias TEXT, - url_hash TEXT GENERATED ALWAYS AS (url) STORED NOT NULL, - diagnostics BLOB, - PRIMARY KEY ( url, cache_scope, auth_user_id ) -); - - CREATE TABLE IF NOT EXISTS realm_file_meta ( - realm_url TEXT NOT NULL, - file_path TEXT NOT NULL, - created_at INTEGER NOT NULL, - content_hash TEXT, - content_size INTEGER, - PRIMARY KEY ( realm_url, file_path ) -); - - CREATE TABLE IF NOT EXISTS realm_meta ( - realm_url TEXT NOT NULL, - realm_version INTEGER NOT NULL, - value BLOB NOT NULL, - indexed_at, - PRIMARY KEY ( realm_url, realm_version ) -); - - CREATE TABLE IF NOT EXISTS realm_metadata ( - url TEXT NOT NULL, - show_as_catalog BOOLEAN, - publishable BOOLEAN, - created_at DEFAULT CURRENT_TIMESTAMP NOT NULL, - updated_at DEFAULT CURRENT_TIMESTAMP NOT NULL, - PRIMARY KEY ( url ) -); - - CREATE TABLE IF NOT EXISTS realm_registry ( - id DEFAULT (hex(randomblob(16))) NOT NULL, - url TEXT NOT NULL, - kind TEXT NOT NULL, - disk_id TEXT NOT NULL, - owner_username TEXT NOT NULL, - source_url TEXT, - last_published_at, - pinned BOOLEAN DEFAULT false NOT NULL, - created_at DEFAULT CURRENT_TIMESTAMP NOT NULL, - updated_at DEFAULT CURRENT_TIMESTAMP NOT NULL, - PRIMARY KEY ( id ) -); - - CREATE TABLE IF NOT EXISTS realm_user_permissions ( - realm_url TEXT NOT NULL, - username TEXT NOT NULL, - read BOOLEAN NOT NULL, - write BOOLEAN NOT NULL, - realm_owner BOOLEAN DEFAULT false NOT NULL, - PRIMARY KEY ( realm_url, username ) -); - - CREATE TABLE IF NOT EXISTS realm_versions ( - realm_url TEXT NOT NULL, - current_version INTEGER NOT NULL, - PRIMARY KEY ( realm_url ) -); - - CREATE TABLE IF NOT EXISTS webhook_commands ( - id NOT NULL, - incoming_webhook_id NOT NULL, - command TEXT NOT NULL, - command_filter BLOB, - created_at NOT NULL, - updated_at NOT NULL, - PRIMARY KEY ( id ) -); \ No newline at end of file diff --git a/packages/host/config/schema/1780627635271_schema.sql b/packages/host/config/schema/1780713642519_schema.sql similarity index 100% rename from packages/host/config/schema/1780627635271_schema.sql rename to packages/host/config/schema/1780713642519_schema.sql diff --git a/packages/host/tests/acceptance/host-submode-test.gts b/packages/host/tests/acceptance/host-submode-test.gts index 9fe5591fbb..237d94bc2d 100644 --- a/packages/host/tests/acceptance/host-submode-test.gts +++ b/packages/host/tests/acceptance/host-submode-test.gts @@ -86,6 +86,23 @@ function withUpdatedTestRealmInfo( }; } +// Stubs the realm-server `allocateUnlistedPath` method (which normally hits the +// server that owns the random slug), returning the given slug(s) — successive +// calls walk the list and then stick on the last entry, so passing two slugs +// covers the initial load + a "New link" regenerate. Returns a restore function. +function stubUnlistedPath(slugs: string | string[]): () => void { + let realmServer = getService('realm-server') as any; + let original = realmServer.allocateUnlistedPath; + let queue = Array.isArray(slugs) ? [...slugs] : [slugs]; + realmServer.allocateUnlistedPath = async (sourceRealmURL: string) => { + let slug = queue.length > 1 ? queue.shift()! : queue[0]; + return { sourceRealmURL, slug }; + }; + return () => { + realmServer.allocateUnlistedPath = original; + }; +} + module('Acceptance | host submode', function (hooks) { setupApplicationTest(hooks); setupLocalIndexing(hooks); @@ -671,6 +688,13 @@ module('Acceptance | host submode', function (hooks) { getService('realm-server').publishRealm = publishRealm; getService('realm-server').unpublishRealm = unpublishRealm; + // The publish modal asks the server for the unlisted-link slug on open; + // default it so the unlisted card renders a URL (not a stuck "Generating + // link…") in tests that don't exercise it. Tests that do use + // `stubUnlistedPath` to control the slug. + getService('realm-server').allocateUnlistedPath = async ( + sourceRealmURL: string, + ) => ({ sourceRealmURL, slug: 'defaultunlistedab' }); }); test('can publish realm', async function (assert) { @@ -802,6 +826,141 @@ module('Acceptance | host submode', function (hooks) { assert.dom('[data-test-publish-button]').isDisabled(); }); + test('can publish an unlisted link', async function (assert) { + // The server owns the random slug; the modal just renders what the + // `_unlisted-realm-path` endpoint returns. + let restoreUnlisted = stubUnlistedPath('k7f3qz9pbcdmnpqr'); + let unlistedUrl = `https://testuser.${publishedSpaceHost}/k7f3qz9pbcdmnpqr/`; + try { + await visitOperatorMode({ + submode: 'host', + trail: [`${testRealmURL}Person/1.json`], + }); + + await click('[data-test-publish-realm-button]'); + await waitFor('[data-test-publish-realm-modal]'); + await waitFor('[data-test-unlisted-link-url]'); + + // The unlisted link is the user's own space subdomain with the + // server-issued slug as the path, not the realm name. + assert.dom('[data-test-unlisted-link-url]').hasText(unlistedUrl); + + assert.dom('[data-test-unlisted-link-checkbox]').isNotChecked(); + await click('[data-test-unlisted-link-checkbox]'); + assert.dom('[data-test-unlisted-link-checkbox]').isChecked(); + assert.dom('[data-test-publish-button]').isNotDisabled(); + + await click('[data-test-publish-button]'); + publishDeferred.fulfill(); + await waitUntil(() => { + return !document.querySelector( + '[data-test-publish-realm-button].publishing', + ); + }); + + await click('[data-test-publish-realm-button]'); + assert + .dom( + '[data-test-publish-realm-modal] [data-test-open-unlisted-link-button]', + ) + .hasAttribute('href', unlistedUrl) + .hasAttribute('target', '_blank'); + } finally { + restoreUnlisted(); + } + }); + + test('unlisted link checkbox can be checked and unchecked', async function (assert) { + let restoreUnlisted = stubUnlistedPath('k7f3qz9pbcdmnpqr'); + try { + await visitOperatorMode({ + submode: 'host', + trail: [`${testRealmURL}Person/1.json`], + }); + + await click('[data-test-publish-realm-button]'); + await waitFor('[data-test-unlisted-link-url]'); + + assert.dom('[data-test-unlisted-link-checkbox]').isNotChecked(); + assert.dom('[data-test-publish-button]').isDisabled(); + + await click('[data-test-unlisted-link-checkbox]'); + assert.dom('[data-test-unlisted-link-checkbox]').isChecked(); + assert.dom('[data-test-publish-button]').isNotDisabled(); + + await click('[data-test-unlisted-link-checkbox]'); + assert.dom('[data-test-unlisted-link-checkbox]').isNotChecked(); + assert.dom('[data-test-publish-button]').isDisabled(); + } finally { + restoreUnlisted(); + } + }); + + test('can regenerate the unlisted link before publishing', async function (assert) { + let restoreUnlisted = stubUnlistedPath([ + 'firstslug00000000', + 'secondslug0000000', + ]); + try { + await visitOperatorMode({ + submode: 'host', + trail: [`${testRealmURL}Person/1.json`], + }); + + await click('[data-test-publish-realm-button]'); + await waitFor('[data-test-unlisted-link-url]'); + + assert + .dom('[data-test-unlisted-link-url]') + .hasText( + `https://testuser.${publishedSpaceHost}/firstslug00000000/`, + ); + + await click('[data-test-regenerate-unlisted-link-button]'); + + assert + .dom('[data-test-unlisted-link-url]') + .hasText( + `https://testuser.${publishedSpaceHost}/secondslug0000000/`, + ); + } finally { + restoreUnlisted(); + } + }); + + test('preselects a previously published unlisted link on refresh', async function (assert) { + let now = Date.now(); + let slug = 'k7f3qz9pbcdmnpqr'; + let unlistedUrl = `https://testuser.${publishedSpaceHost}/${slug}/`; + + // The server returns the realm's existing slug, so the modal shows the + // same URL that was previously published. + let restoreUnlisted = stubUnlistedPath(slug); + let restoreRealmInfo = withUpdatedTestRealmInfo({ + lastPublishedAt: { + [unlistedUrl]: String(now), + }, + }); + + try { + await visitOperatorMode({ + submode: 'host', + trail: [`${testRealmURL}Person/1.json`], + }); + + await click('[data-test-publish-realm-button]'); + await waitFor('[data-test-publish-realm-modal]'); + await waitFor('[data-test-unlisted-link-url]'); + + assert.dom('[data-test-unlisted-link-url]').hasText(unlistedUrl); + assert.dom('[data-test-unlisted-link-checkbox]').isChecked(); + assert.dom('[data-test-publish-button]').isNotDisabled(); + } finally { + restoreRealmInfo(); + restoreUnlisted(); + } + }); + test('can unpublish realm', async function (assert) { let restoreRealmInfo = withUpdatedTestRealmInfo({ lastPublishedAt: { @@ -1104,7 +1263,7 @@ module('Acceptance | host submode', function (hooks) { await waitFor('[data-test-publish-realm-modal]'); let customDomainOption = - '[data-test-publish-realm-modal] .domain-option:nth-of-type(2)'; + '[data-test-publish-realm-modal] .domain-option:nth-of-type(3)'; await waitFor(`${customDomainOption} .realm-icon`); assert @@ -1391,7 +1550,7 @@ module('Acceptance | host submode', function (hooks) { .containsText('Published'); assert .dom( - '[data-test-publish-realm-modal] .domain-option:nth-of-type(2)', + '[data-test-publish-realm-modal] .domain-option:nth-of-type(3)', ) .containsText('Published'); @@ -1438,7 +1597,7 @@ module('Acceptance | host submode', function (hooks) { assert.dom('[data-test-custom-subdomain-checkbox]').isChecked(); assert .dom( - '[data-test-publish-realm-modal] .domain-option:nth-of-type(2)', + '[data-test-publish-realm-modal] .domain-option:nth-of-type(3)', ) .containsText('Not published yet'); @@ -1482,7 +1641,7 @@ module('Acceptance | host submode', function (hooks) { // Custom subdomain should show as published assert .dom( - '[data-test-publish-realm-modal] .domain-option:nth-of-type(2) .last-published-at', + '[data-test-publish-realm-modal] .domain-option:nth-of-type(3) .last-published-at', ) .containsText('Published 2 days ago'); @@ -1504,12 +1663,12 @@ module('Acceptance | host submode', function (hooks) { // Should show "Not published yet" after unpublishing assert .dom( - '[data-test-publish-realm-modal] .domain-option:nth-of-type(2) .not-published-yet', + '[data-test-publish-realm-modal] .domain-option:nth-of-type(3) .not-published-yet', ) .exists(); assert .dom( - '[data-test-publish-realm-modal] .domain-option:nth-of-type(2)', + '[data-test-publish-realm-modal] .domain-option:nth-of-type(3)', ) .containsText('Not published yet'); } finally { diff --git a/packages/matrix/tests/publish-realm.spec.ts b/packages/matrix/tests/publish-realm.spec.ts index 60e8d18b73..2f29251f66 100644 --- a/packages/matrix/tests/publish-realm.spec.ts +++ b/packages/matrix/tests/publish-realm.spec.ts @@ -71,6 +71,43 @@ test.describe('Publish realm', () => { await page.bringToFront(); }); + test('it can publish an unlisted link', async ({ page }) => { + await openPublishRealmModal(page); + + await page.waitForSelector('[data-test-unlisted-link-url]'); + let publishedURL = ( + await page.locator('[data-test-unlisted-link-url]').innerText() + ).replace(/\s+/g, ''); + + // The unlisted link is the user's own space subdomain with a random path. + expect(publishedURL).toMatch( + new RegExp(`^https://${user.username}\\.localhost:4205/[a-z0-9]+/$`), + ); + + await page.locator('[data-test-unlisted-link-checkbox]').click(); + await page.locator('[data-test-publish-button]').click(); + await page.waitForSelector( + '[data-test-publish-realm-modal] [data-test-open-unlisted-link-button]', + ); + + let newTabPromise = page.waitForEvent('popup'); + await page + .locator( + '[data-test-publish-realm-modal] [data-test-open-unlisted-link-button]', + ) + .click(); + + let newTab = await newTabPromise; + await newTab.waitForLoadState(); + + await expect(newTab).toHaveURL(publishedURL); + await expect( + newTab.locator(`[data-test-card="${publishedURL}index"]`), + ).toBeVisible(); + await newTab.close(); + await page.bringToFront(); + }); + test('it validates, claims, and publishes to a custom subdomain', async ({ page, }) => { diff --git a/packages/postgres/migrations/1780713642519_create-unlisted-realm-paths.js b/packages/postgres/migrations/1780713642519_create-unlisted-realm-paths.js new file mode 100644 index 0000000000..1c79b61cc6 --- /dev/null +++ b/packages/postgres/migrations/1780713642519_create-unlisted-realm-paths.js @@ -0,0 +1,50 @@ +exports.shorthands = undefined; + +// Stores the server-issued random path segment for each source realm's +// "unlisted link" publish target (`.//`). The slug +// is generated and owned by the server so the unguessable string can't be +// chosen by a client via direct API calls; the publish handler only allows a +// subdirectory publish to this slug (or the realm name). +exports.up = (pgm) => { + pgm.createTable('unlisted_realm_paths', { + id: { + type: 'uuid', + primaryKey: true, + default: pgm.func('gen_random_uuid()'), + }, + source_realm_url: { + type: 'varchar', + notNull: true, + }, + slug: { + type: 'varchar', + notNull: true, + }, + owner_user_id: { + type: 'varchar', + notNull: true, + }, + created_at: { + type: 'timestamp', + notNull: true, + default: pgm.func('now()'), + }, + updated_at: { + type: 'timestamp', + notNull: true, + default: pgm.func('now()'), + }, + }); + + pgm.createIndex('unlisted_realm_paths', ['source_realm_url'], { + unique: true, + name: 'unlisted_realm_paths_source_url_unique_index', + }); +}; + +exports.down = (pgm) => { + pgm.dropIndex('unlisted_realm_paths', ['source_realm_url'], { + name: 'unlisted_realm_paths_source_url_unique_index', + }); + pgm.dropTable('unlisted_realm_paths'); +}; diff --git a/packages/realm-server/handlers/handle-delete-realm.ts b/packages/realm-server/handlers/handle-delete-realm.ts index 92b28c1ef1..15af19563d 100644 --- a/packages/realm-server/handlers/handle-delete-realm.ts +++ b/packages/realm-server/handlers/handle-delete-realm.ts @@ -208,6 +208,15 @@ export default function handleDeleteRealm({ ` AND removed_at IS NULL`, ]); + // Server-issued unlisted-link slug for this realm. Hard-delete it so a + // realm later recreated at the same endpoint can't reuse the old + // unguessable slug — which would expose the new realm to anyone holding + // the previous unlisted URL. + await q([ + `DELETE FROM unlisted_realm_paths WHERE source_realm_url = `, + param(realmURL), + ]); + await removeRealmPermissions(dbAdapter, parsedRealmURL, txQuerier); await removeRealmDatabaseArtifacts({ dbAdapter, diff --git a/packages/realm-server/handlers/handle-publish-realm.ts b/packages/realm-server/handlers/handle-publish-realm.ts index aa16dbfd93..c232d1a63e 100644 --- a/packages/realm-server/handlers/handle-publish-realm.ts +++ b/packages/realm-server/handlers/handle-publish-realm.ts @@ -14,7 +14,9 @@ import { fetchRealmPermissions, uuidv4, userInitiatedPriority, + deriveRealmName, } from '@cardstack/runtime-common'; +import { getUnlistedSlug } from '../lib/unlisted-realm-path.ts'; import { getPublishedRealmDomainOverrides } from '@cardstack/runtime-common/constants'; import { join } from 'path'; @@ -39,7 +41,10 @@ import { createJWT } from '../jwt.ts'; import type { CreateRoutesArgs } from '../routes.ts'; import type { RealmServerTokenClaim } from '../utils/jwt.ts'; import { registerUser } from '../synapse.ts'; -import { passwordFromSeed } from '@cardstack/runtime-common/matrix-client'; +import { + getMatrixUsername, + passwordFromSeed, +} from '@cardstack/runtime-common/matrix-client'; import { enqueueReindexRealmJob } from '@cardstack/runtime-common/jobs/reindex-realm'; import { upsertPublishedRealmInRegistry } from '../lib/realm-registry-writes.ts'; @@ -315,6 +320,35 @@ export default function handlePublishRealm({ ); return; } + + // Within the owner's own published space (`.`), a + // subdirectory publish may target only the realm-name path (the "Your + // Boxel Space" target) or the server-issued unlisted-link slug — never an + // arbitrary, client-chosen path — so the unlisted link's unguessable + // path can't be hand-picked through a direct API call. Publishes to any + // other host (claimed custom domains, etc.) are left permissive. + let spaceDomain = domainsForPublishedRealms?.boxelSpace; + let matrixUsername = getMatrixUsername(ownerUserId); + let publishedURLForPath = new URL(publishedRealmURL); + let isOwnerSpaceHost = + !!spaceDomain && + (publishedURLForPath.host === `${matrixUsername}.${spaceDomain}` || + publishedURLForPath.hostname === `${matrixUsername}.${spaceDomain}`); + if (isOwnerSpaceHost) { + let publishedPath = publishedURLForPath.pathname + .split('/') + .filter(Boolean) + .join('/'); + let realmName = deriveRealmName(sourceRealmURL); + let unlistedSlug = await getUnlistedSlug(dbAdapter, sourceRealmURL); + if (publishedPath !== realmName && publishedPath !== unlistedSlug) { + await sendResponseForBadRequest( + ctxt, + 'publishedRealmURL path must be the realm name or the server-issued unlisted link', + ); + return; + } + } } try { diff --git a/packages/realm-server/handlers/handle-unlisted-realm-path.ts b/packages/realm-server/handlers/handle-unlisted-realm-path.ts new file mode 100644 index 0000000000..ba9c5b563f --- /dev/null +++ b/packages/realm-server/handlers/handle-unlisted-realm-path.ts @@ -0,0 +1,113 @@ +import type Koa from 'koa'; +import { + ensureTrailingSlash, + fetchRealmPermissions, + generateObscureSlug, + SupportedMimeType, +} from '@cardstack/runtime-common'; +import { + fetchRequestFromContext, + sendResponseForBadRequest, + sendResponseForForbiddenRequest, + sendResponseForSystemError, + setContextResponse, +} from '../middleware/index.ts'; +import type { RealmServerTokenClaim } from '../utils/jwt.ts'; +import type { CreateRoutesArgs } from '../routes.ts'; +import { + allocateUnlistedSlug, + regenerateUnlistedSlug, +} from '../lib/unlisted-realm-path.ts'; + +// Returns the server-issued random path segment ("slug") for a source realm's +// unlisted link, generating and persisting one on first request. Pass +// `regenerate: true` to mint a fresh slug. The slug is always generated here, on +// the server — clients never supply it — so the unlisted link's unguessability +// can't be undermined by a hand-crafted request. +export default function handleUnlistedRealmPathRequest({ + dbAdapter, +}: CreateRoutesArgs): (ctxt: Koa.Context, next: Koa.Next) => Promise { + return async function (ctxt: Koa.Context, _next: Koa.Next) { + try { + let token = ctxt.state.token as RealmServerTokenClaim; + if (!token) { + await sendResponseForSystemError( + ctxt, + 'token is required to allocate an unlisted realm path', + ); + return; + } + let { user: ownerUserId } = token; + + let request = await fetchRequestFromContext(ctxt); + let body = await request.text(); + let json: Record; + try { + json = JSON.parse(body); + } catch (_error) { + await sendResponseForBadRequest( + ctxt, + 'Request body is not valid JSON - invalid JSON', + ); + return; + } + + if (!json.sourceRealmURL) { + await sendResponseForBadRequest(ctxt, 'sourceRealmURL is required'); + return; + } + let sourceRealmURL = ensureTrailingSlash(json.sourceRealmURL); + let regenerate = json.regenerate === true; + + let permissions = await fetchRealmPermissions( + dbAdapter, + new URL(sourceRealmURL), + ); + if (!permissions[ownerUserId]?.includes('realm-owner')) { + await sendResponseForForbiddenRequest( + ctxt, + `${ownerUserId} does not have enough permission to allocate an unlisted link for this realm`, + ); + return; + } + + let slug: string; + if (regenerate) { + // Explicit "New link" — overwrite any existing slug. + slug = generateObscureSlug(); + await regenerateUnlistedSlug(dbAdapter, { + sourceRealmURL, + slug, + ownerUserId, + }); + } else { + // First-time/idempotent allocation — insert a fresh slug, or return the + // one already stored, so concurrent requests can't clobber each other. + slug = await allocateUnlistedSlug(dbAdapter, { + sourceRealmURL, + candidateSlug: generateObscureSlug(), + ownerUserId, + }); + } + + await setContextResponse( + ctxt, + new Response( + JSON.stringify({ + data: { + type: 'unlisted-realm-path', + attributes: { sourceRealmURL, slug }, + }, + }), + { + status: 200, + headers: { 'content-type': SupportedMimeType.JSONAPI }, + }, + ), + ); + } catch (error) { + console.error('Error allocating unlisted realm path:', error); + await sendResponseForSystemError(ctxt, 'Internal server error'); + } + }; +} diff --git a/packages/realm-server/lib/unlisted-realm-path.ts b/packages/realm-server/lib/unlisted-realm-path.ts new file mode 100644 index 0000000000..5c8f8c9e81 --- /dev/null +++ b/packages/realm-server/lib/unlisted-realm-path.ts @@ -0,0 +1,59 @@ +import { query, param, type DBAdapter } from '@cardstack/runtime-common'; + +// Read/write helpers for `unlisted_realm_paths` — the server-issued random path +// segment for a source realm's "unlisted link" publish target. The slug is +// generated server-side (never supplied by the client) so the unguessable +// string can't be hand-picked through direct API calls, and the publish handler +// consults it to reject subdirectory publishes to any other path. + +export async function getUnlistedSlug( + dbAdapter: DBAdapter, + sourceRealmURL: string, +): Promise { + let rows = (await query(dbAdapter, [ + `SELECT slug FROM unlisted_realm_paths WHERE source_realm_url =`, + param(sourceRealmURL), + ])) as { slug: string }[]; + return rows[0]?.slug ?? null; +} + +// Allocates the realm's unlisted slug without clobbering an existing one: insert +// `candidateSlug`, or — if a row already exists — return the stored slug +// unchanged. The no-op `DO UPDATE` makes `RETURNING` yield the existing row on +// conflict, so two racing first-time allocations both converge on whichever slug +// committed first (the other's candidate is discarded). This keeps a slug shown +// in one tab from being silently replaced by a concurrent allocation in another +// — which would otherwise make `handle-publish-realm` reject the first link. +export async function allocateUnlistedSlug( + dbAdapter: DBAdapter, + args: { sourceRealmURL: string; candidateSlug: string; ownerUserId: string }, +): Promise { + let rows = (await query(dbAdapter, [ + `INSERT INTO unlisted_realm_paths (source_realm_url, slug, owner_user_id) VALUES (`, + param(args.sourceRealmURL), + `,`, + param(args.candidateSlug), + `,`, + param(args.ownerUserId), + `) ON CONFLICT (source_realm_url) DO UPDATE SET source_realm_url = EXCLUDED.source_realm_url RETURNING slug`, + ])) as { slug: string }[]; + return rows[0].slug; +} + +// Overwrites the realm's unlisted slug. Used only for an explicit "New link" +// regeneration — never for first-time allocation, which must not clobber a slug +// a concurrent request may already be displaying (see allocateUnlistedSlug). +export async function regenerateUnlistedSlug( + dbAdapter: DBAdapter, + args: { sourceRealmURL: string; slug: string; ownerUserId: string }, +): Promise { + await query(dbAdapter, [ + `INSERT INTO unlisted_realm_paths (source_realm_url, slug, owner_user_id) VALUES (`, + param(args.sourceRealmURL), + `,`, + param(args.slug), + `,`, + param(args.ownerUserId), + `) ON CONFLICT (source_realm_url) DO UPDATE SET slug = EXCLUDED.slug, owner_user_id = EXCLUDED.owner_user_id, updated_at = now()`, + ]); +} diff --git a/packages/realm-server/routes.ts b/packages/realm-server/routes.ts index ab5567bb62..c57996789c 100644 --- a/packages/realm-server/routes.ts +++ b/packages/realm-server/routes.ts @@ -41,6 +41,7 @@ import handleRealmAuth from './handlers/handle-realm-auth.ts'; import handleGetBoxelClaimedDomainRequest from './handlers/handle-get-boxel-claimed-domain.ts'; import handleClaimBoxelDomainRequest from './handlers/handle-claim-boxel-domain.ts'; import handleDeleteBoxelClaimedDomainRequest from './handlers/handle-delete-boxel-claimed-domain.ts'; +import handleUnlistedRealmPathRequest from './handlers/handle-unlisted-realm-path.ts'; import handlePrerenderProxy from './handlers/handle-prerender-proxy.ts'; import handleSearch from './handlers/handle-search.ts'; import handleSearchV2 from './handlers/handle-search-v2.ts'; @@ -325,6 +326,11 @@ export function createRoutes(args: CreateRoutesArgs) { jwtMiddleware(args.realmSecretSeed), handleDeleteBoxelClaimedDomainRequest(args), ); + router.post( + '/_unlisted-realm-path', + jwtMiddleware(args.realmSecretSeed), + handleUnlistedRealmPathRequest(args), + ); // Matrix tests don't need the GitHub PR integration, and skipping this route // keeps the realm server from loading Octokit's ESM entrypoint during boot. if (process.env.DISABLE_GITHUB_PR_ROUTE !== 'true') { diff --git a/packages/realm-server/tests/index.ts b/packages/realm-server/tests/index.ts index 40d7c9366d..26c773f56d 100644 --- a/packages/realm-server/tests/index.ts +++ b/packages/realm-server/tests/index.ts @@ -295,6 +295,7 @@ const ALL_TEST_FILES: string[] = [ './coerce-error-message-test', './realm-operations-test', './resolve-published-realm-url-test', + './unlisted-realm-path-test', './fallback-models-test', './host-routing-validation-test', './normalize-realm-meta-value-test', diff --git a/packages/realm-server/tests/publish-unpublish-realm-test.ts b/packages/realm-server/tests/publish-unpublish-realm-test.ts index 4a058a1d23..95b0bac3a3 100644 --- a/packages/realm-server/tests/publish-unpublish-realm-test.ts +++ b/packages/realm-server/tests/publish-unpublish-realm-test.ts @@ -180,6 +180,38 @@ module(basename(__filename), function () { `); }); + test('POST /_publish-realm rejects a hand-picked subdirectory under the owner space', async function (assert) { + // `mango` is the owner, so `mango.localhost` is their own space; the path + // here is neither the realm name nor a server-issued unlisted slug, so + // the unguessable unlisted path can't be chosen via a direct API call. + let response = await request + .post('/_publish-realm') + .set('Accept', 'application/vnd.api+json') + .set('Content-Type', 'application/json') + .set( + 'Authorization', + `Bearer ${createRealmServerJWT( + { user: ownerUserId, sessionRoom: 'session-room-test' }, + realmSecretSeed, + )}`, + ) + .send( + JSON.stringify({ + sourceRealmURL: sourceRealmUrlString, + publishedRealmURL: + 'http://mango.localhost:4445/hand-picked-path/', + }), + ); + + assert.strictEqual(response.status, 400, 'HTTP 400 status'); + assert.ok( + response.text.includes( + 'must be the realm name or the server-issued unlisted link', + ), + 'error explains the path restriction', + ); + }); + test('POST /_publish-realm can publish realm successfully', async function (assert) { let response = await request .post('/_publish-realm') diff --git a/packages/realm-server/tests/resolve-published-realm-url-test.ts b/packages/realm-server/tests/resolve-published-realm-url-test.ts index bf641bf5cf..41891c8f94 100644 --- a/packages/realm-server/tests/resolve-published-realm-url-test.ts +++ b/packages/realm-server/tests/resolve-published-realm-url-test.ts @@ -2,6 +2,8 @@ import { module, test } from 'qunit'; import { basename } from 'path'; import { deriveRealmName, + generateObscureSlug, + OBSCURE_SLUG_LENGTH, resolvePublishedRealmUrl, } from '@cardstack/runtime-common'; @@ -212,5 +214,28 @@ module(basename(__filename), function () { /Unknown publish target type/, ); }); + + // generateObscureSlug + test('generateObscureSlug produces a fixed-length URL-path-safe string', async function (assert) { + for (let i = 0; i < 50; i++) { + let slug = generateObscureSlug(); + assert.strictEqual( + slug.length, + OBSCURE_SLUG_LENGTH, + 'has the expected length', + ); + assert.ok( + /^[a-z0-9]+$/.test(slug), + `"${slug}" is lowercase alphanumeric (safe as a URL path segment)`, + ); + } + }); + + test('generateObscureSlug is not deterministic', async function (assert) { + let values = new Set( + Array.from({ length: 20 }, () => generateObscureSlug()), + ); + assert.strictEqual(values.size, 20, 'all generated values are distinct'); + }); }); }); diff --git a/packages/realm-server/tests/server-endpoints/delete-realm-test.ts b/packages/realm-server/tests/server-endpoints/delete-realm-test.ts index 52067e97bb..86c0b1b888 100644 --- a/packages/realm-server/tests/server-endpoints/delete-realm-test.ts +++ b/packages/realm-server/tests/server-endpoints/delete-realm-test.ts @@ -5,6 +5,7 @@ import { v4 as uuidv4 } from 'uuid'; import { asExpressions, + deriveRealmName, insert, insertPermissions, PUBLISHED_DIRECTORY_NAME, @@ -100,7 +101,12 @@ module(`server-endpoints/${basename(__filename)}`, function (hooks) { let ownerUserId = `@${owner}:localhost`; let realmURL = await createRealmFor(ownerUserId); let realmPath = new URL(realmURL).pathname.split('/').filter(Boolean); - let publishedRealmURL = `http://${owner}.localhost:4445/published-${uuidv4()}/`; + // Publishing to the owner's own space is restricted to the realm-name path + // (the "Your Boxel Space" target) or a server-issued unlisted slug, so use + // the realm name here. + let publishedRealmURL = `http://${owner}.localhost:4445/${deriveRealmName( + realmURL, + )}/`; let unrelatedRealmURL = `http://papaya.localhost:4445/unrelated-${uuidv4()}/`; let user = await insertUser( @@ -272,6 +278,20 @@ module(`server-endpoints/${basename(__filename)}`, function (hooks) { insert('claimed_domains_for_sites', nameExpressions, valueExpressions), ); + let unlisted = asExpressions({ + source_realm_url: realmURL, + slug: 'deleteslugexample', + owner_user_id: ownerUserId, + }); + await query( + context.dbAdapter, + insert( + 'unlisted_realm_paths', + unlisted.nameExpressions, + unlisted.valueExpressions, + ), + ); + // Phase 3: source realm is mounted lazily. The publish handler // above called reconciler.lookupOrMount(sourceRealmURL), so by now // the source realm is in realms[]. @@ -597,6 +617,15 @@ module(`server-endpoints/${basename(__filename)}`, function (hooks) { 'claimed domains are soft deleted', ); + let unlistedPaths = (await context.dbAdapter.execute( + `SELECT slug FROM unlisted_realm_paths WHERE source_realm_url = '${realmURL}'`, + )) as { slug: string }[]; + assert.strictEqual( + unlistedPaths.length, + 0, + 'unlisted-link slug is removed so a recreated realm cannot reuse it', + ); + assert.notOk( context.testRealmServer.testingOnlyRealms.find( (realm) => realm.url === realmURL, @@ -615,7 +644,9 @@ module(`server-endpoints/${basename(__filename)}`, function (hooks) { let owner = `mango-${uuidv4()}`; let ownerUserId = `@${owner}:localhost`; let realmURL = await createRealmFor(ownerUserId); - let publishedRealmURL = `http://${owner}.localhost:4445/published-${uuidv4()}/`; + let publishedRealmURL = `http://${owner}.localhost:4445/${deriveRealmName( + realmURL, + )}/`; await insertUser( context.dbAdapter, diff --git a/packages/realm-server/tests/unlisted-realm-path-test.ts b/packages/realm-server/tests/unlisted-realm-path-test.ts new file mode 100644 index 0000000000..d98b16feb1 --- /dev/null +++ b/packages/realm-server/tests/unlisted-realm-path-test.ts @@ -0,0 +1,183 @@ +import { module, test } from 'qunit'; +import { basename, join } from 'path'; +import type { PgAdapter } from '@cardstack/postgres'; +import { query, param } from '@cardstack/runtime-common'; +import { + setupDB, + insertUser, + runTestRealmServer, + createVirtualNetwork, + fixtureDir, + matrixURL, + closeServer, + realmSecretSeed, +} from './helpers/index.ts'; +import type { RealmServerTokenClaim } from '../utils/jwt.ts'; +import { createJWT as createRealmServerJWT } from '../utils/jwt.ts'; +import type { SuperTest, Test } from 'supertest'; +import supertest from 'supertest'; +import type { RealmHttpServer as Server } from '../server.ts'; +import { dirSync, type DirResult } from 'tmp'; +import { copySync, ensureDirSync } from 'fs-extra'; + +const testRealmURL = new URL('http://127.0.0.1:0/test/'); +const ownerUserId = 'matrix-owner-id'; +const sourceRealmURL = 'https://test-realm.example/owner/my-realm/'; + +module(basename(__filename), function () { + module('unlisted realm path endpoint', function (hooks) { + let testRealmServer: Server; + let request: SuperTest; + let dir: DirResult; + let dbAdapter: PgAdapter; + let defaultToken: RealmServerTokenClaim; + + hooks.beforeEach(async function () { + dir = dirSync(); + }); + + setupDB(hooks, { + beforeEach: async (_dbAdapter, publisher, runner) => { + dbAdapter = _dbAdapter; + let testRealmDir = join(dir.name, 'realm_server_unlisted', 'test'); + ensureDirSync(testRealmDir); + copySync(fixtureDir('simple'), testRealmDir); + + testRealmServer = ( + await runTestRealmServer({ + virtualNetwork: createVirtualNetwork(), + testRealmDir, + realmsRootPath: join(dir.name, 'realm_server_unlisted'), + realmURL: testRealmURL, + dbAdapter, + publisher, + runner, + matrixURL, + }) + ).testRealmHttpServer; + request = supertest(testRealmServer); + + await insertUser( + dbAdapter, + ownerUserId, + 'test-user', + 'test-user@example.com', + ); + // Grant the caller realm-owner permission on the source realm so the + // ownership check in the handler passes. + await dbAdapter.execute( + `INSERT INTO realm_user_permissions (realm_url, username, read, write, realm_owner) VALUES ('${sourceRealmURL}', '${ownerUserId}', true, true, true)`, + ); + defaultToken = { user: ownerUserId, sessionRoom: 'test-session' }; + }, + afterEach: async () => { + await closeServer(testRealmServer); + }, + }); + + async function post(token: RealmServerTokenClaim | null, body?: any) { + let builder = request + .post('/_unlisted-realm-path') + .set('Accept', 'application/vnd.api+json') + .set('Content-Type', 'application/json'); + if (token) { + builder = builder.set( + 'Authorization', + `Bearer ${createRealmServerJWT(token, realmSecretSeed)}`, + ); + } + if (body !== undefined) { + builder = builder.send(body); + } + return await builder; + } + + test('returns 400 when sourceRealmURL is missing', async function (assert) { + let response = await post(defaultToken, {}); + assert.strictEqual( + response.status, + 400, + 'rejects a request with no realm', + ); + }); + + test('returns 403 when the caller is not the realm owner', async function (assert) { + let response = await post(defaultToken, { + sourceRealmURL: 'https://test-realm.example/someone-else/realm/', + }); + assert.strictEqual( + response.status, + 403, + 'rejects a non-owner of the source realm', + ); + }); + + test('allocates and persists a server-generated slug', async function (assert) { + let response = await post(defaultToken, { sourceRealmURL }); + assert.strictEqual(response.status, 200, 'allocates a slug'); + + let slug = response.body.data.attributes.slug; + assert.ok( + /^[a-z0-9]+$/.test(slug), + `slug is a random lowercase-alphanumeric string (${slug})`, + ); + + let rows = (await query(dbAdapter, [ + `SELECT slug FROM unlisted_realm_paths WHERE source_realm_url =`, + param(sourceRealmURL), + ])) as { slug: string }[]; + assert.strictEqual(rows.length, 1, 'one row is persisted'); + assert.strictEqual(rows[0].slug, slug, 'the persisted slug is returned'); + }); + + test('returns the same slug on repeat requests', async function (assert) { + let first = await post(defaultToken, { sourceRealmURL }); + let second = await post(defaultToken, { sourceRealmURL }); + assert.strictEqual( + second.body.data.attributes.slug, + first.body.data.attributes.slug, + 'allocation is idempotent without regenerate', + ); + }); + + test('concurrent first-time allocations converge on one slug', async function (assert) { + // Two overlapping first-time requests must not clobber each other: both + // get the slug that committed first, and only one row is persisted. + let [a, b] = await Promise.all([ + post(defaultToken, { sourceRealmURL }), + post(defaultToken, { sourceRealmURL }), + ]); + assert.strictEqual(a.status, 200, 'first request succeeds'); + assert.strictEqual(b.status, 200, 'second request succeeds'); + assert.strictEqual( + b.body.data.attributes.slug, + a.body.data.attributes.slug, + 'both requests return the same slug', + ); + + let rows = (await query(dbAdapter, [ + `SELECT slug FROM unlisted_realm_paths WHERE source_realm_url =`, + param(sourceRealmURL), + ])) as { slug: string }[]; + assert.strictEqual(rows.length, 1, 'only one row is persisted'); + assert.strictEqual( + rows[0].slug, + a.body.data.attributes.slug, + 'the persisted slug matches what both requests returned', + ); + }); + + test('regenerate mints a new slug', async function (assert) { + let first = await post(defaultToken, { sourceRealmURL }); + let regenerated = await post(defaultToken, { + sourceRealmURL, + regenerate: true, + }); + assert.notStrictEqual( + regenerated.body.data.attributes.slug, + first.body.data.attributes.slug, + 'regenerate produces a different slug', + ); + }); + }); +}); diff --git a/packages/runtime-common/published-realm-url.ts b/packages/runtime-common/published-realm-url.ts index 8598305817..5b254b9ed0 100644 --- a/packages/runtime-common/published-realm-url.ts +++ b/packages/runtime-common/published-realm-url.ts @@ -93,6 +93,59 @@ function normalizeProtocol(protocol: string): string { return protocol.replace(/:\/*$/, ''); } +// Character set for the machine-generated path segment of an "unlisted link" +// publish target (`.//`). Lowercase letters and +// digits only — safe as a URL path segment with no escaping — with the visually +// ambiguous characters removed (i, l, o, 0, 1) so a link read aloud or copied by +// hand is less error-prone. +const OBSCURE_SLUG_ALPHABET = 'abcdefghjkmnpqrstuvwxyz'; +const OBSCURE_SLUG_DIGITS = '23456789'; +const OBSCURE_SLUG_CHARSET = OBSCURE_SLUG_ALPHABET + OBSCURE_SLUG_DIGITS; + +// Length of a generated slug. 16 characters over a 31-symbol alphabet is ~79 +// bits of entropy — unguessable in the "even if the page is public, the URL is +// the secret" sense (a la a Google Doc link). +export const OBSCURE_SLUG_LENGTH = 16; + +function randomBytes(length: number): Uint8Array { + let cryptoObj = ( + globalThis as { crypto?: { getRandomValues?: (a: Uint8Array) => void } } + ).crypto; + if (!cryptoObj?.getRandomValues) { + throw new Error( + 'A secure random source (crypto.getRandomValues) is required to generate an unlisted link', + ); + } + let bytes = new Uint8Array(length); + cryptoObj.getRandomValues(bytes); + return bytes; +} + +// Picks `count` characters uniformly from `charset` using rejection sampling so +// there is no modulo bias toward the earlier characters of the alphabet. +function randomChars(charset: string, count: number): string { + let max = Math.floor(256 / charset.length) * charset.length; + let out = ''; + while (out.length < count) { + for (let byte of randomBytes(count - out.length)) { + if (byte < max) { + out += charset[byte % charset.length]; + if (out.length === count) { + break; + } + } + } + } + return out; +} + +// Generates an unguessable path-segment slug for an "unlisted link" publish +// target. The whole string is drawn from a URL-path-safe alphabet so it needs no +// escaping in the published-realm URL. +export function generateObscureSlug(): string { + return randomChars(OBSCURE_SLUG_CHARSET, OBSCURE_SLUG_LENGTH); +} + export function resolvePublishedRealmUrl( target: PublishTargetSpec, ctx: PublishedRealmUrlContext = {},