Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
232 changes: 232 additions & 0 deletions packages/host/app/components/operator-mode/publish-realm-modal.gts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,12 @@ export default class PublishRealmModal extends Component<Signature> {
@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
// (`<username>.<spaceDomain>/<unlistedPathSegment>/`). 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:
Expand All @@ -119,6 +125,7 @@ export default class PublishRealmModal extends Component<Signature> {
super(owner, args);
this.ensureInitialSelectionsTask.perform();
this.fetchBoxelClaimedDomain.perform();
this.loadUnlistedPathTask.perform();
this.checkPrivateDependenciesTask.perform();
this.checkDanglingRoutingRulesTask.perform();
}
Expand Down Expand Up @@ -245,6 +252,71 @@ export default class PublishRealmModal extends Component<Signature> {
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://<username>.<spaceDomain>/<random>/`. 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;
Expand Down Expand Up @@ -582,6 +654,56 @@ export default class PublishRealmModal extends Component<Signature> {
}
}

@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) {
Expand Down Expand Up @@ -1021,6 +1143,116 @@ export default class PublishRealmModal extends Component<Signature> {
{{/if}}
</div>

<div class='domain-option'>
<input
type='checkbox'
id='unlisted-link-checkbox'
checked={{this.isUnlistedRealmSelected}}
{{on 'change' this.toggleUnlistedDomain}}
class='domain-checkbox'
data-test-unlisted-link-checkbox
disabled={{this.isUnlistedCheckboxDisabled}}
/>
<label class='option-title' for='unlisted-link-checkbox'>Unlisted
Link</label>

<div class='domain-details'>
<WithLoadedRealm @realmURL={{this.currentRealmURL}} as |realm|>
<RealmIcon @realmInfo={{realm.info}} class='realm-icon' />
</WithLoadedRealm>
{{#if this.unlistedRealmUrl}}
<div class='domain-url-container'>
<span class='domain-url' data-test-unlisted-link-url>
<span
class='url-part'
>{{this.unlistedRealmParts.baseUrl}}</span><span
class='url-part-bold'
>{{this.unlistedRealmParts.pathSegment}}/</span>
</span>
{{#if this.isUnlistedRealmPublished}}
<div class='domain-info'>
<span class='last-published-at'>Published
{{this.unlistedLastPublishedTime}}</span>
<BoxelButton
@kind='text-only'
@size='extra-small'
@disabled={{this.isUnpublishingRealm
this.unlistedRealmUrl
}}
class='unpublish-button'
{{on
'click'
(fn @handleUnpublish this.unlistedRealmUrl)
}}
data-test-unpublish-unlisted-link-button
>
{{#if (this.isUnpublishingRealm this.unlistedRealmUrl)}}
<LoadingIndicator />
Unpublishing…
{{else}}
<Undo2
width='11'
height='11'
class='unpublish-icon'
/>
Unpublish
{{/if}}
</BoxelButton>
</div>
{{/if}}
</div>
{{else}}
<span class='domain-url' data-test-unlisted-link-loading>
Generating link…
</span>
{{/if}}
</div>
{{#if this.unlistedRealmUrl}}
{{#if this.isUnlistedRealmPublished}}
<BoxelButton
@as='anchor'
@kind='secondary-light'
@size='small'
@href={{this.unlistedRealmUrl}}
@disabled={{this.isUnpublishingAnyRealms}}
class='action'
target='_blank'
rel='noopener noreferrer'
data-test-open-unlisted-link-button
>
<ExternalLink width='16' height='16' class='button-icon' />
Open Site
</BoxelButton>
{{else}}
<BoxelButton
@kind='text-only'
@size='small'
class='action'
@disabled={{this.isRegeneratingUnlistedLink}}
{{on 'click' (perform this.regenerateUnlistedLinkTask)}}
data-test-regenerate-unlisted-link-button
>
{{#if this.isRegeneratingUnlistedLink}}
<LoadingIndicator />
Generating…
{{else}}
New link
{{/if}}
</BoxelButton>
{{/if}}
{{/if}}
{{#if this.publishErrorForUnlistedLink}}
<div
class='domain-publish-error'
data-test-domain-publish-error={{this.unlistedRealmUrl}}
>
<span
class='error-text'
>{{this.publishErrorForUnlistedLink}}</span>
</div>
{{/if}}
</div>

<div class='domain-option'>
<input
type='checkbox'
Expand Down
39 changes: 39 additions & 0 deletions packages/host/app/services/realm-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1016,6 +1016,45 @@ export default class RealmServerService extends Service {
};
}

// Asks the server for this realm's unlisted-link path segment, allocating a
// fresh server-generated one when none exists (or when `regenerate` is set).
// The slug is always determined by the server so it can't be hand-picked.
async allocateUnlistedPath(
sourceRealmURL: string,
options: { regenerate?: boolean } = {},
): Promise<{ sourceRealmURL: string; slug: string }> {
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<void> {
await this.login();

Expand Down
Loading
Loading