mirror of
https://github.com/HeyPuter/puter.git
synced 2026-08-23 22:47:19 +00:00
fix: PUT-1401 (#3504)
This commit is contained in:
@@ -242,7 +242,13 @@ export class AppController extends PuterController {
|
||||
source: 'appsRoute',
|
||||
args: req.query ?? {},
|
||||
});
|
||||
return { ...shaped, privateAccess };
|
||||
return {
|
||||
...shaped,
|
||||
privateAccess:
|
||||
shaped.privateAccess?.hasAccess === false
|
||||
? shaped.privateAccess
|
||||
: privateAccess,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -500,6 +500,73 @@ describe('AppController GET /apps/:name', () => {
|
||||
expect((arr[0] as Record<string, unknown>).uid).toBe(app.uid);
|
||||
expect(arr[1]).toBeNull();
|
||||
});
|
||||
|
||||
// The driver is the authoritative producer of launch metadata: it runs
|
||||
// the hosted-subdomain guard, which this route knows nothing about. The
|
||||
// route's own entitlement re-check must never turn that denial into an
|
||||
// affirmative verdict — the GUI launcher appends `puter.auth.token` to
|
||||
// whatever `index_url` it is handed.
|
||||
|
||||
it('preserves the hosted-backing denial for the app owner', async () => {
|
||||
const owner = await makeUser();
|
||||
const sub = uniqueName('gone');
|
||||
const row = await server.stores.subdomain.create({
|
||||
userId: owner.userId,
|
||||
subdomain: sub,
|
||||
});
|
||||
const app = await createApp(owner.actor, {
|
||||
index_url: `https://${sub}.site.puter.localhost/`,
|
||||
});
|
||||
await server.stores.subdomain.deleteByUuid(
|
||||
String((row as { uuid: string }).uuid),
|
||||
{ userId: owner.userId },
|
||||
);
|
||||
|
||||
const { res, captured } = makeRes();
|
||||
await withActor(owner.actor, () =>
|
||||
callRoute(
|
||||
'get',
|
||||
'/apps/:name',
|
||||
makeReq({ params: { name: app.name }, actor: owner.actor }),
|
||||
res,
|
||||
),
|
||||
);
|
||||
const body = captured.body as Record<string, unknown>;
|
||||
expect(body.privateAccess).toMatchObject({
|
||||
hasAccess: false,
|
||||
reason: 'hosted_backing_unavailable',
|
||||
});
|
||||
});
|
||||
|
||||
it('omits the stale index_url for a non-owner', async () => {
|
||||
const owner = await makeUser();
|
||||
const stranger = await makeUser();
|
||||
const sub = uniqueName('gone');
|
||||
const row = await server.stores.subdomain.create({
|
||||
userId: owner.userId,
|
||||
subdomain: sub,
|
||||
});
|
||||
const app = await createApp(owner.actor, {
|
||||
index_url: `https://${sub}.site.puter.localhost/`,
|
||||
});
|
||||
await server.stores.subdomain.deleteByUuid(
|
||||
String((row as { uuid: string }).uuid),
|
||||
{ userId: owner.userId },
|
||||
);
|
||||
|
||||
const { res, captured } = makeRes();
|
||||
await withActor(stranger.actor, () =>
|
||||
callRoute(
|
||||
'get',
|
||||
'/apps/:name',
|
||||
makeReq({ params: { name: app.name }, actor: stranger.actor }),
|
||||
res,
|
||||
),
|
||||
);
|
||||
const body = captured.body as Record<string, unknown>;
|
||||
expect(body.index_url).toBeUndefined();
|
||||
expect(body.privateAccess).toMatchObject({ hasAccess: false });
|
||||
});
|
||||
});
|
||||
|
||||
// ── POST /query/app ─────────────────────────────────────────────────
|
||||
|
||||
@@ -980,11 +980,13 @@ export class AppDriver extends PuterDriver {
|
||||
// launch token to an origin the app owner no longer controls. Only
|
||||
// set when not already denied so a private app's existing decision
|
||||
// is preserved.
|
||||
if (
|
||||
hostedBackingUnavailable &&
|
||||
result.privateAccess?.hasAccess !== false
|
||||
) {
|
||||
result.privateAccess = buildHostedBackingDenial();
|
||||
if (hostedBackingUnavailable) {
|
||||
if (result.privateAccess?.hasAccess !== false) {
|
||||
result.privateAccess = buildHostedBackingDenial();
|
||||
}
|
||||
if (actor?.user?.id !== app.owner_user_id) {
|
||||
delete result.index_url;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
|
||||
@@ -1520,6 +1520,48 @@ describe('AppDriver hosted-subdomain ownership check', () => {
|
||||
expect(access?.reason).toBe('hosted_backing_unavailable');
|
||||
});
|
||||
|
||||
it('withholds the stale index_url from everyone but the owner', async () => {
|
||||
const owner = await makeUser();
|
||||
const other = await makeUser();
|
||||
const sub = uniqueName('stale');
|
||||
const row = await server.stores.subdomain.create({
|
||||
userId: owner.userId,
|
||||
subdomain: sub,
|
||||
});
|
||||
|
||||
const created = await withActor(owner.actor, () =>
|
||||
driver.create({
|
||||
object: {
|
||||
name: uniqueName('app'),
|
||||
title: 'Backed App',
|
||||
index_url: hostedUrl(sub),
|
||||
},
|
||||
}),
|
||||
);
|
||||
await server.stores.subdomain.deleteByUuid(
|
||||
String((row as { uuid: string }).uuid),
|
||||
{ userId: owner.userId },
|
||||
);
|
||||
|
||||
// The owner still sees it — dev center renders the URL in the app's
|
||||
// edit form, and it's their row to repoint.
|
||||
const asOwner = await withActor(owner.actor, () =>
|
||||
driver.read({ uid: created.uid }),
|
||||
);
|
||||
expect(String(asOwner.index_url)).toContain(sub);
|
||||
|
||||
// Anyone else gets the denial without the URL it suppresses, so a
|
||||
// consumer that reads `index_url` without reading the verdict still
|
||||
// can't hand it to the launcher.
|
||||
const asOther = await withActor(other.actor, () =>
|
||||
driver.read({ uid: created.uid }),
|
||||
);
|
||||
expect(asOther.index_url).toBeUndefined();
|
||||
expect(
|
||||
(asOther.privateAccess as { hasAccess?: boolean }).hasAccess,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('denies launch when the hosted subdomain was reclaimed by another user', async () => {
|
||||
const owner = await makeUser();
|
||||
const attacker = await makeUser();
|
||||
|
||||
@@ -601,7 +601,7 @@ describe('SubdomainDriver.delete', () => {
|
||||
// an `index_url` match against the subdomain's host variants.
|
||||
|
||||
const createAppWithIndexUrl = async (
|
||||
ownerUserId: number,
|
||||
ownerUserId: number | null,
|
||||
indexUrl: string,
|
||||
opts: { isPrivate?: boolean } = {},
|
||||
): Promise<{ id: number; uid: string }> => {
|
||||
@@ -626,6 +626,104 @@ const createAppWithIndexUrl = async (
|
||||
return row;
|
||||
};
|
||||
|
||||
// ── Launch-origin takeover on re-registration ──────────────────────
|
||||
//
|
||||
// Deleting a hosted subdomain leaves the app row's `index_url` pointing
|
||||
// at the freed name. The GUI launcher appends `puter.auth.token` to that
|
||||
// URL, so whoever registers the name next would receive launch tokens
|
||||
// for the original app. `create` therefore refuses a name another user's
|
||||
// app still references — while leaving the app's own owner free to
|
||||
// re-create it.
|
||||
|
||||
describe('SubdomainDriver.create launch-origin reservation', () => {
|
||||
it("refuses a name another user's app still points at", async () => {
|
||||
const victim = await makeUser();
|
||||
const attacker = await makeUser();
|
||||
const sub = uniqueSubdomain('freed');
|
||||
|
||||
await createAppWithIndexUrl(
|
||||
victim.userId,
|
||||
`http://${sub}.site.puter.localhost/`,
|
||||
);
|
||||
|
||||
await expect(
|
||||
withActor(attacker.actor, () =>
|
||||
driver.create({
|
||||
object: {
|
||||
subdomain: sub,
|
||||
root_dir: `/${attacker.actor.user!.username}/Public`,
|
||||
},
|
||||
}),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 409 });
|
||||
expect(await server.stores.subdomain.getBySubdomain(sub)).toBeFalsy();
|
||||
});
|
||||
|
||||
it('refuses a name an unowned origin-bootstrapped app points at', async () => {
|
||||
const attacker = await makeUser();
|
||||
const sub = uniqueSubdomain('orphan');
|
||||
|
||||
await createAppWithIndexUrl(
|
||||
null,
|
||||
`http://${sub}.site.puter.localhost`,
|
||||
);
|
||||
|
||||
await expect(
|
||||
withActor(attacker.actor, () =>
|
||||
driver.create({
|
||||
object: {
|
||||
subdomain: sub,
|
||||
root_dir: `/${attacker.actor.user!.username}/Public`,
|
||||
},
|
||||
}),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 409 });
|
||||
});
|
||||
|
||||
it('lets the referencing app owner re-create the name', async () => {
|
||||
const owner = await makeUser();
|
||||
const sub = uniqueSubdomain('recreate');
|
||||
|
||||
await createAppWithIndexUrl(
|
||||
owner.userId,
|
||||
`http://${sub}.site.puter.localhost/`,
|
||||
);
|
||||
|
||||
const created = (await withActor(owner.actor, () =>
|
||||
driver.create({
|
||||
object: {
|
||||
subdomain: sub,
|
||||
root_dir: `/${owner.actor.user!.username}/Public`,
|
||||
},
|
||||
}),
|
||||
)) as Record<string, unknown>;
|
||||
expect(created.subdomain).toBe(sub);
|
||||
});
|
||||
|
||||
it('leaves names no app references claimable', async () => {
|
||||
const other = await makeUser();
|
||||
const claimant = await makeUser();
|
||||
const sub = uniqueSubdomain('unrelated');
|
||||
|
||||
// Same owner, unrelated host: the reservation keys off the name,
|
||||
// not off the existence of other people's apps.
|
||||
await createAppWithIndexUrl(
|
||||
other.userId,
|
||||
'https://elsewhere.example.test/',
|
||||
);
|
||||
|
||||
const created = (await withActor(claimant.actor, () =>
|
||||
driver.create({
|
||||
object: {
|
||||
subdomain: sub,
|
||||
root_dir: `/${claimant.actor.user!.username}/Public`,
|
||||
},
|
||||
}),
|
||||
)) as Record<string, unknown>;
|
||||
expect(created.subdomain).toBe(sub);
|
||||
});
|
||||
});
|
||||
|
||||
describe('SubdomainDriver associated_app derivation', () => {
|
||||
it('ignores `associated_app_uid` on create and derives null when no app matches', async () => {
|
||||
const { actor } = await makeUser();
|
||||
@@ -706,10 +804,14 @@ describe('SubdomainDriver associated_app derivation', () => {
|
||||
});
|
||||
|
||||
it("does not derive an `associated_app` for another user's app at the same host", async () => {
|
||||
// The core IDOR: attacker (a) sets up a subdomain whose host
|
||||
// matches the index_url of a victim's (b) private app. Even on
|
||||
// exact index_url match, the ownership filter must reject the
|
||||
// app from another user's account.
|
||||
// The core IDOR: attacker (a) holds a subdomain whose host matches
|
||||
// the index_url of a victim's (b) private app. Even on exact
|
||||
// index_url match, the ownership filter must reject the app from
|
||||
// another user's account.
|
||||
//
|
||||
// `create` now refuses that pairing outright (see the launch-origin
|
||||
// reservation above), so the row is planted through the store — this
|
||||
// is the legacy-data shape the derive filter still has to handle.
|
||||
const a = await makeUser();
|
||||
const b = await makeUser();
|
||||
const sub = uniqueSubdomain('idor');
|
||||
@@ -720,15 +822,15 @@ describe('SubdomainDriver associated_app derivation', () => {
|
||||
{ isPrivate: true },
|
||||
);
|
||||
|
||||
const created = (await withActor(a.actor, () =>
|
||||
driver.create({
|
||||
object: {
|
||||
subdomain: sub,
|
||||
root_dir: `/${a.actor.user!.username}/Public`,
|
||||
},
|
||||
}),
|
||||
const row = await server.stores.subdomain.create({
|
||||
userId: a.userId,
|
||||
subdomain: sub,
|
||||
});
|
||||
|
||||
const read = (await withActor(a.actor, () =>
|
||||
driver.read({ uid: (row as { uuid: string }).uuid }),
|
||||
)) as Record<string, unknown>;
|
||||
|
||||
expect(created.associated_app).toBeNull();
|
||||
expect(read.associated_app).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -31,6 +31,7 @@ import type { DriverRateLimitConfig } from '../meta.js';
|
||||
import type { FSEntry } from '../../stores/fs/FSEntry.js';
|
||||
import type { UserRow } from '../../stores/user/UserStore.js';
|
||||
import { expandTildePath } from '../../services/fs/resolveNode.js';
|
||||
import { buildHostedSubdomainIndexUrlCandidates } from '../../util/hostedAppBacking.js';
|
||||
import { WORKER_SUBDOMAIN_PREFIX } from '../../stores/subdomain/SubdomainStore.js';
|
||||
import {
|
||||
decodeCursor,
|
||||
@@ -147,6 +148,31 @@ export class SubdomainDriver extends PuterDriver {
|
||||
});
|
||||
}
|
||||
await this.services.fs.checkFSAccess(entry, actor);
|
||||
|
||||
// A name some other user's app still points at is not free either.
|
||||
// Deleting a hosted subdomain leaves the app row's `index_url` intact,
|
||||
// and the GUI launcher appends `puter.auth.token` to whatever URL it is
|
||||
// given — so registering the freed name would hand that app's launch
|
||||
// token to whoever claimed it. The app's own owner is exempt:
|
||||
// re-creating their site restores their app rather than hijacking it.
|
||||
// See `util/hostedAppBacking.ts` for the wider rule.
|
||||
//
|
||||
// Last check before the insert on purpose: `apps.index_url` is
|
||||
// unindexed, so this scan only runs for a request that would otherwise
|
||||
// have created the row, and it stays behind the same root_dir gate as
|
||||
// the existing uniqueness answer.
|
||||
const appHoldingName = await this.stores.app.findByIndexUrlCandidates(
|
||||
buildHostedSubdomainIndexUrlCandidates(subdomain, this.config),
|
||||
{ excludeOwnerUserId: actor.user.id },
|
||||
);
|
||||
if (appHoldingName) {
|
||||
throw new HttpError(
|
||||
409,
|
||||
'A site with this subdomain already exists',
|
||||
{ legacyCode: 'conflict' },
|
||||
);
|
||||
}
|
||||
|
||||
// `associated_app_id` is no longer accepted from clients. The
|
||||
// "associated app" for a subdomain is derived at read time from
|
||||
// `apps.owner_user_id = subdomain.user_id` + `index_url` match
|
||||
@@ -604,37 +630,6 @@ export class SubdomainDriver extends PuterDriver {
|
||||
const result = new Map<string, number>();
|
||||
if (rows.length === 0) return result;
|
||||
|
||||
const normalize = (v: unknown): string | null => {
|
||||
if (typeof v !== 'string') return null;
|
||||
const trimmed = v.trim().toLowerCase().replace(/^\./, '');
|
||||
return trimmed || null;
|
||||
};
|
||||
const stripPort = (v: string): string => v.split(':')[0] || v;
|
||||
|
||||
const hostingDomainsRaw = [
|
||||
normalize(this.config.static_hosting_domain),
|
||||
normalize(this.config.static_hosting_domain_alt),
|
||||
normalize(this.config.private_app_hosting_domain),
|
||||
normalize(this.config.private_app_hosting_domain_alt),
|
||||
].filter((d): d is string => !!d);
|
||||
const hostingDomains = [
|
||||
...new Set([
|
||||
...hostingDomainsRaw,
|
||||
...hostingDomainsRaw.map(stripPort),
|
||||
]),
|
||||
];
|
||||
if (hostingDomains.length === 0) return result;
|
||||
|
||||
const configuredProtocol =
|
||||
typeof this.config.protocol === 'string'
|
||||
? this.config.protocol.trim().replace(/:$/, '')
|
||||
: '';
|
||||
const protocols = [
|
||||
...new Set(
|
||||
[configuredProtocol, 'https', 'http'].filter((p) => !!p),
|
||||
),
|
||||
];
|
||||
|
||||
const userIdToRowMeta = new Map<
|
||||
number,
|
||||
Array<{ rowUuid: string; candidates: Set<string> }>
|
||||
@@ -656,16 +651,10 @@ export class SubdomainDriver extends PuterDriver {
|
||||
: '';
|
||||
if (!subdomain || !Number.isFinite(userId) || !rowUuid) continue;
|
||||
|
||||
const candidates = new Set<string>();
|
||||
for (const d of hostingDomains) {
|
||||
const host = `${subdomain}.${d}`;
|
||||
for (const p of protocols) {
|
||||
const base = `${p}://${host}`;
|
||||
candidates.add(base);
|
||||
candidates.add(`${base}/`);
|
||||
candidates.add(`${base}/index.html`);
|
||||
}
|
||||
}
|
||||
const candidates = new Set<string>(
|
||||
buildHostedSubdomainIndexUrlCandidates(subdomain, this.config),
|
||||
);
|
||||
if (candidates.size === 0) continue;
|
||||
for (const c of candidates) allCandidates.add(c);
|
||||
|
||||
if (!userIdToRowMeta.has(userId)) {
|
||||
|
||||
@@ -254,10 +254,18 @@ export class AppStore extends PuterStore {
|
||||
* Find the oldest app whose `index_url` matches one of `candidates`. Used
|
||||
* by the driver to detect duplicate puter-hosted index_url rows
|
||||
* (origin-bootstrap apps + same-owner duplicates) so they can be merged on
|
||||
* `create` / `update`. Returns the minimal row shape needed by the merge
|
||||
* path; falsy when nothing matches.
|
||||
* `create` / `update`, and by the subdomain driver to refuse a name another
|
||||
* user's app still points at. Returns the minimal row shape needed by the
|
||||
* merge path; falsy when nothing matches.
|
||||
*
|
||||
* `excludeOwnerUserId` skips rows owned by that user; unowned rows
|
||||
* (origin-bootstrap apps) still match, since their launch origin is no less
|
||||
* takeover-sensitive for having no owner.
|
||||
*/
|
||||
async findByIndexUrlCandidates(candidates, { excludeAppId } = {}) {
|
||||
async findByIndexUrlCandidates(
|
||||
candidates,
|
||||
{ excludeAppId, excludeOwnerUserId } = {},
|
||||
) {
|
||||
if (!Array.isArray(candidates) || candidates.length === 0) return null;
|
||||
const placeholders = candidates.map(() => '?').join(', ');
|
||||
const params = [...candidates];
|
||||
@@ -266,6 +274,10 @@ export class AppStore extends PuterStore {
|
||||
sql += ' AND `id` != ?';
|
||||
params.push(excludeAppId);
|
||||
}
|
||||
if (Number.isInteger(excludeOwnerUserId) && excludeOwnerUserId > 0) {
|
||||
sql += ' AND (`owner_user_id` IS NULL OR `owner_user_id` != ?)';
|
||||
params.push(excludeOwnerUserId);
|
||||
}
|
||||
sql += ' ORDER BY `timestamp` ASC, `id` ASC LIMIT 1';
|
||||
const rows = await this.clients.db.read(sql, params);
|
||||
return rows[0] ?? null;
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
buildHostedBackingDenial,
|
||||
buildHostedSubdomainIndexUrlCandidates,
|
||||
extractPuterHostedSubdomain,
|
||||
getPuterHostedDomains,
|
||||
hostedIndexUrlBackingIsUnavailable,
|
||||
@@ -67,6 +68,59 @@ describe('getPuterHostedDomains', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildHostedSubdomainIndexUrlCandidates', () => {
|
||||
it('covers every hosting domain, protocol and path variant', () => {
|
||||
const candidates = buildHostedSubdomainIndexUrlCandidates('myapp', {
|
||||
static_hosting_domain: 'puter.site',
|
||||
protocol: 'https:',
|
||||
});
|
||||
expect(candidates).toEqual(
|
||||
expect.arrayContaining([
|
||||
'https://myapp.puter.site',
|
||||
'https://myapp.puter.site/',
|
||||
'https://myapp.puter.site/index.html',
|
||||
'http://myapp.puter.site',
|
||||
'http://myapp.puter.site/',
|
||||
'http://myapp.puter.site/index.html',
|
||||
]),
|
||||
);
|
||||
// Nothing beyond the three path shapes the app write path accepts.
|
||||
expect(candidates).toHaveLength(6);
|
||||
});
|
||||
|
||||
it('keeps both the ported and bare host for dev configs', () => {
|
||||
const candidates = buildHostedSubdomainIndexUrlCandidates('dev', {
|
||||
static_hosting_domain: 'site.puter.localhost:4100',
|
||||
protocol: 'http',
|
||||
});
|
||||
expect(candidates).toContain('http://dev.site.puter.localhost:4100/');
|
||||
expect(candidates).toContain('http://dev.site.puter.localhost/');
|
||||
});
|
||||
|
||||
it('spans the alt and private hosting domains', () => {
|
||||
const candidates = buildHostedSubdomainIndexUrlCandidates('x', CONFIG);
|
||||
expect(candidates).toContain('https://x.puter.site/');
|
||||
expect(candidates).toContain('https://x.site.puter.localhost/');
|
||||
expect(candidates).toContain('https://x.private.puter.localhost/');
|
||||
});
|
||||
|
||||
it('normalizes the name and refuses unusable input', () => {
|
||||
expect(
|
||||
buildHostedSubdomainIndexUrlCandidates(' MyApp ', {
|
||||
static_hosting_domain: 'puter.site',
|
||||
}),
|
||||
).toContain('https://myapp.puter.site/');
|
||||
expect(buildHostedSubdomainIndexUrlCandidates('', CONFIG)).toEqual([]);
|
||||
expect(buildHostedSubdomainIndexUrlCandidates(null, CONFIG)).toEqual(
|
||||
[],
|
||||
);
|
||||
expect(buildHostedSubdomainIndexUrlCandidates(42, CONFIG)).toEqual([]);
|
||||
// No hosting domain configured — nothing can be hosted, so nothing
|
||||
// is reserved.
|
||||
expect(buildHostedSubdomainIndexUrlCandidates('x', {})).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractPuterHostedSubdomain', () => {
|
||||
it('extracts the label from a hosted url', () => {
|
||||
expect(
|
||||
|
||||
@@ -23,13 +23,19 @@ import type { PrivateLaunchDecision } from './privateLaunchAccess';
|
||||
/**
|
||||
* Launch-safety checks for puter-hosted `index_url`s.
|
||||
*
|
||||
* A hosted subdomain (`*.<hosting-domain>`) can be deleted by its owner and
|
||||
* then re-registered by anyone else, but the app row keeps the stale URL —
|
||||
* nothing rewrites it on subdomain deletion. Any producer of launchable app
|
||||
* metadata must therefore re-check the backing before handing an `index_url` to
|
||||
* the GUI launcher, which appends `puter.auth.token` to it.
|
||||
* A hosted subdomain (`*.<hosting-domain>`) can be deleted by its owner, but
|
||||
* the app row keeps the stale URL — nothing rewrites it on subdomain deletion.
|
||||
* Any producer of launchable app metadata must therefore re-check the backing
|
||||
* before handing an `index_url` to the GUI launcher, which appends
|
||||
* `puter.auth.token` to it.
|
||||
*
|
||||
* This module is the single home for that check. `AppDriver` was the first
|
||||
* The other half of the rule lives on the write side: `SubdomainDriver.create`
|
||||
* refuses a name that some other user's app still points at
|
||||
* (`buildHostedSubdomainIndexUrlCandidates`), so a freed name can't be
|
||||
* re-registered under someone else's launch origin. Only the app's own owner
|
||||
* may re-create it, which restores their app.
|
||||
*
|
||||
* This module is the single home for both checks. `AppDriver` was the first
|
||||
* caller; `SuggestedAppsService` and `/get-launch-apps` build their own
|
||||
* summaries and need the same guard, so keep the logic here rather than
|
||||
* re-deriving it per producer.
|
||||
@@ -42,6 +48,7 @@ interface HostedDomainConfig {
|
||||
static_hosting_domain_alt?: unknown;
|
||||
private_app_hosting_domain?: unknown;
|
||||
private_app_hosting_domain_alt?: unknown;
|
||||
protocol?: unknown;
|
||||
}
|
||||
|
||||
interface AppBackingRow {
|
||||
@@ -49,33 +56,91 @@ interface AppBackingRow {
|
||||
owner_user_id?: unknown;
|
||||
}
|
||||
|
||||
function normalizeConfiguredHostedDomain(domainValue: unknown): string | null {
|
||||
function normalizeHostedDomainValue(domainValue: unknown): string | null {
|
||||
if (typeof domainValue !== 'string') return null;
|
||||
const normalizedDomain = domainValue
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/^\./, '');
|
||||
return normalizedDomain || null;
|
||||
}
|
||||
|
||||
function normalizeConfiguredHostedDomain(domainValue: unknown): string | null {
|
||||
const normalizedDomain = normalizeHostedDomainValue(domainValue);
|
||||
if (!normalizedDomain) return null;
|
||||
return normalizedDomain.split(':')[0] || null;
|
||||
}
|
||||
|
||||
function configuredHostedDomainValues(
|
||||
config: HostedDomainConfig | undefined | null,
|
||||
): unknown[] {
|
||||
const cfg = config ?? {};
|
||||
return [
|
||||
cfg.static_hosting_domain,
|
||||
cfg.static_hosting_domain_alt,
|
||||
cfg.private_app_hosting_domain,
|
||||
cfg.private_app_hosting_domain_alt,
|
||||
];
|
||||
}
|
||||
|
||||
export function getPuterHostedDomains(
|
||||
config: HostedDomainConfig | undefined | null,
|
||||
): string[] {
|
||||
const domains = new Set<string>();
|
||||
const cfg = config ?? {};
|
||||
for (const configuredDomain of [
|
||||
cfg.static_hosting_domain,
|
||||
cfg.static_hosting_domain_alt,
|
||||
cfg.private_app_hosting_domain,
|
||||
cfg.private_app_hosting_domain_alt,
|
||||
]) {
|
||||
for (const configuredDomain of configuredHostedDomainValues(config)) {
|
||||
const normalized = normalizeConfiguredHostedDomain(configuredDomain);
|
||||
if (normalized) domains.add(normalized);
|
||||
}
|
||||
return [...domains];
|
||||
}
|
||||
|
||||
/**
|
||||
* Every `index_url` string that resolves to `subdomain` on one of our hosting
|
||||
* domains — protocol, port and trailing-path variants included.
|
||||
*
|
||||
* `apps.index_url` is matched by exact string, so a caller asking "does any app
|
||||
* still point at this name?" has to enumerate the same shapes the app write
|
||||
* path accepts.
|
||||
*/
|
||||
export function buildHostedSubdomainIndexUrlCandidates(
|
||||
subdomain: unknown,
|
||||
config: HostedDomainConfig | undefined | null,
|
||||
): string[] {
|
||||
const name =
|
||||
typeof subdomain === 'string' ? subdomain.trim().toLowerCase() : '';
|
||||
if (!name) return [];
|
||||
|
||||
const hosts = new Set<string>();
|
||||
for (const configuredDomain of configuredHostedDomainValues(config)) {
|
||||
// Keep the configured form and a port-stripped one: dev configs carry
|
||||
// a `:4100`-style port and stored index_urls exist in both shapes.
|
||||
const withPort = normalizeHostedDomainValue(configuredDomain);
|
||||
if (withPort) hosts.add(`${name}.${withPort}`);
|
||||
const bare = normalizeConfiguredHostedDomain(configuredDomain);
|
||||
if (bare) hosts.add(`${name}.${bare}`);
|
||||
}
|
||||
if (hosts.size === 0) return [];
|
||||
|
||||
const configuredProtocol =
|
||||
typeof config?.protocol === 'string'
|
||||
? config.protocol.trim().replace(/:$/, '')
|
||||
: '';
|
||||
const protocols = [
|
||||
...new Set([configuredProtocol, 'https', 'http'].filter(Boolean)),
|
||||
];
|
||||
|
||||
const candidates = new Set<string>();
|
||||
for (const host of hosts) {
|
||||
for (const protocol of protocols) {
|
||||
const base = `${protocol}://${host}`;
|
||||
candidates.add(base);
|
||||
candidates.add(`${base}/`);
|
||||
candidates.add(`${base}/index.html`);
|
||||
}
|
||||
}
|
||||
return [...candidates];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the subdomain label when `indexUrl` is hosted on one of our
|
||||
* configured hosting domains, else null (a developer's own external domain, or
|
||||
|
||||
Reference in New Issue
Block a user