mirror of
https://github.com/HeyPuter/puter.git
synced 2026-11-13 20:12:57 -10:00
Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8963bb3d0d | ||
|
|
c345eee610 | ||
|
|
dd4742eb6a | ||
|
|
1a8aaf9844 | ||
|
|
afef62438b | ||
|
|
6d00840864 | ||
|
|
b0e9fc955f | ||
|
|
f303527ec7 | ||
|
|
6bd11c977f | ||
|
|
37fb205a62 | ||
|
|
4dcd199455 | ||
|
|
aea28680f1 | ||
|
|
c283b1d861 |
No files matched your search
@@ -29,6 +29,7 @@ import {
|
||||
createNativeAppStatic,
|
||||
createUserSubdomainNotFound,
|
||||
createWwwRedirect,
|
||||
subdomainLabelOf,
|
||||
type ResolvedClaim,
|
||||
type SubdomainClaim,
|
||||
} from './hostRedirects';
|
||||
@@ -175,7 +176,7 @@ describe('createUserSubdomainNotFound', () => {
|
||||
|
||||
it('passes through reserved subdomains (api, js, native apps, etc.)', () => {
|
||||
// `api`, `js`, `dav`, `docs`, `developer`, `editor`, `pdf`,
|
||||
// `puter-app-icons`, `onlyoffice`, etc. all bypass.
|
||||
// `puter-app-icons`, etc. all pass through.
|
||||
for (const sub of ['api', 'js', 'docs', 'editor', 'puter-app-icons']) {
|
||||
const { out, next } = run(
|
||||
createUserSubdomainNotFound(config),
|
||||
@@ -189,6 +190,17 @@ describe('createUserSubdomainNotFound', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('404s an extension-owned subdomain nobody has claimed', () => {
|
||||
const { next } = run(
|
||||
createUserSubdomainNotFound(config),
|
||||
makeReq({
|
||||
subdomains: ['com', 'puter', 'onlyoffice'],
|
||||
host: 'onlyoffice.puter.com',
|
||||
}),
|
||||
);
|
||||
expectNotFound(next);
|
||||
});
|
||||
|
||||
it('passes through when no subdomain is present (root)', () => {
|
||||
const { next } = run(
|
||||
createUserSubdomainNotFound(config),
|
||||
@@ -640,6 +652,21 @@ describe('createUserSubdomainNotFound with claimed subdomains', () => {
|
||||
expect(args).toEqual([]);
|
||||
});
|
||||
|
||||
it('hands a main-pipeline claim to the rest of the stack, like a reserved name', async () => {
|
||||
const claims = lookup({ office: { mainPipeline: true } });
|
||||
for (const [host, subdomains] of [
|
||||
['office.puter.com', ['office']],
|
||||
['office.puter.com:4100', ['office']],
|
||||
['office.x.puter.com', ['x', 'office']],
|
||||
] as const) {
|
||||
const args = await runClaimed(
|
||||
createUserSubdomainNotFound(config, claims),
|
||||
hostReq(host, [...subdomains]),
|
||||
);
|
||||
expect(args, host).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it('looks the claim up on every request', async () => {
|
||||
const claims: Record<string, ResolvedClaim> = {};
|
||||
const middleware = createUserSubdomainNotFound(config, lookup(claims));
|
||||
@@ -650,6 +677,32 @@ describe('createUserSubdomainNotFound with claimed subdomains', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('subdomainLabelOf', () => {
|
||||
it('returns the single label under the domain, ignoring case and port', () => {
|
||||
expect(subdomainLabelOf('shop.puter.com', 'puter.com')).toBe('shop');
|
||||
expect(subdomainLabelOf('SHOP.Puter.com:4100', 'puter.com')).toBe(
|
||||
'shop',
|
||||
);
|
||||
expect(subdomainLabelOf('shop.puter.com', 'Puter.com:443')).toBe(
|
||||
'shop',
|
||||
);
|
||||
});
|
||||
|
||||
it('returns undefined for anything but exactly one label under the domain', () => {
|
||||
for (const host of [
|
||||
'puter.com',
|
||||
'.puter.com',
|
||||
'a.shop.puter.com',
|
||||
'shop.puter.com.example.test',
|
||||
'shopputer.com',
|
||||
'shop.other.com',
|
||||
]) {
|
||||
expect(subdomainLabelOf(host, 'puter.com')).toBeUndefined();
|
||||
}
|
||||
expect(subdomainLabelOf('shop.puter.com', '')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('addClaimedSubdomain', () => {
|
||||
const claim: SubdomainClaim = { authorize: () => true };
|
||||
|
||||
@@ -677,7 +730,7 @@ describe('addClaimedSubdomain', () => {
|
||||
});
|
||||
|
||||
it('rejects subdomains core already handles', () => {
|
||||
for (const name of ['www', 'api', 'js', 'dav', 'docs', 'onlyoffice']) {
|
||||
for (const name of ['www', 'api', 'js', 'dav', 'docs']) {
|
||||
expect(() => addClaimedSubdomain(new Map(), name, claim)).toThrow(
|
||||
/reserved/,
|
||||
);
|
||||
@@ -706,6 +759,26 @@ describe('addClaimedSubdomain', () => {
|
||||
).toThrow(/invalid checkRoute/);
|
||||
});
|
||||
|
||||
it('records a main-pipeline claim, and refuses one that also carries a gate', () => {
|
||||
const claims = new Map<string, SubdomainClaim>();
|
||||
addClaimedSubdomain(claims, 'onlyoffice', { mainPipeline: true });
|
||||
expect(claims.get('onlyoffice')).toEqual({ mainPipeline: true });
|
||||
expect(() =>
|
||||
addClaimedSubdomain(claims, 'onlyoffice', { mainPipeline: true }),
|
||||
).toThrow(/already claimed/);
|
||||
for (const gate of [
|
||||
{ authorize: () => true },
|
||||
{ checkRoute: () => undefined },
|
||||
]) {
|
||||
expect(() =>
|
||||
addClaimedSubdomain(new Map(), 'portal', {
|
||||
mainPipeline: true,
|
||||
...gate,
|
||||
} as unknown as SubdomainClaim),
|
||||
).toThrow(/main pipeline with a gate/);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects a claim without authorize', () => {
|
||||
for (const bad of [undefined, {}, { authorize: true }]) {
|
||||
expect(() =>
|
||||
|
||||
@@ -41,8 +41,9 @@ const NATIVE_APP_SUBDOMAINS = [
|
||||
const NATIVE_APPS_WITH_DIST = new Set(['docs', 'developer']);
|
||||
|
||||
/**
|
||||
* Subdomains that v2 serves itself. Anything NOT in this set that lives on the
|
||||
* root domain is treated as a user-defined site and rejected with a 404.
|
||||
* Subdomains that v2 serves itself. A root-domain subdomain neither in this set
|
||||
* nor claimed by an extension is treated as a user-defined site and rejected
|
||||
* with a 404.
|
||||
*
|
||||
* Kept as a plain Set so `has()` is O(1); order doesn't matter.
|
||||
*/
|
||||
@@ -55,8 +56,6 @@ const RESERVED_SUBDOMAINS = new Set<string>([
|
||||
...NATIVE_APP_SUBDOMAINS,
|
||||
// App-icon serving subdomain.
|
||||
'puter-app-icons',
|
||||
// Extension-owned subdomains.
|
||||
'onlyoffice',
|
||||
]);
|
||||
|
||||
/**
|
||||
@@ -69,7 +68,8 @@ export type ClaimAuthorize = (
|
||||
res: Response,
|
||||
) => boolean | Promise<boolean>;
|
||||
|
||||
export interface SubdomainClaim {
|
||||
/** A claim answered on its own router, only past `authorize`. */
|
||||
export interface GatedSubdomainClaim {
|
||||
authorize: ClaimAuthorize;
|
||||
/**
|
||||
* Vets each route declared for the claim as the server builds it; a throw
|
||||
@@ -78,16 +78,39 @@ export interface SubdomainClaim {
|
||||
checkRoute?: (options: RouteOptions, label: string) => void;
|
||||
}
|
||||
|
||||
/** A claim as dispatch sees it: its gate, plus the routes served behind it. */
|
||||
export interface ResolvedClaim {
|
||||
authorize: ClaimAuthorize;
|
||||
handler?: RequestHandler;
|
||||
/** Served like core's own subdomains: CORS, auth and every route. */
|
||||
export interface MainPipelineSubdomainClaim {
|
||||
mainPipeline: true;
|
||||
}
|
||||
|
||||
export type SubdomainClaim = GatedSubdomainClaim | MainPipelineSubdomainClaim;
|
||||
|
||||
/** A claim as dispatch sees it: a gate and the routes behind it, or neither. */
|
||||
export type ResolvedClaim =
|
||||
| { authorize: ClaimAuthorize; handler?: RequestHandler }
|
||||
| MainPipelineSubdomainClaim;
|
||||
|
||||
export const isMainPipelineClaim = (
|
||||
claim: SubdomainClaim | ResolvedClaim | undefined,
|
||||
): claim is MainPipelineSubdomainClaim =>
|
||||
(claim as { mainPipeline?: unknown } | undefined)?.mainPipeline === true;
|
||||
|
||||
export type ClaimLookup = (name: string) => ResolvedClaim | undefined;
|
||||
|
||||
const SUBDOMAIN_LABEL = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
|
||||
|
||||
/** The label `x` when `host` is exactly `x.<domain>`, ignoring case and port. */
|
||||
export const subdomainLabelOf = (
|
||||
host: string,
|
||||
domain: string,
|
||||
): string | undefined => {
|
||||
const hostName = host.toLowerCase().split(':')[0];
|
||||
const base = domain.toLowerCase().split(':')[0];
|
||||
if (!base || !hostName.endsWith(`.${base}`)) return undefined;
|
||||
const label = hostName.slice(0, -(base.length + 1));
|
||||
return label && !label.includes('.') ? label : undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Record a claim on `<name>.<domain>`. Throws on a malformed or uppercase
|
||||
* label, a subdomain core already handles, or a name that is already claimed.
|
||||
@@ -106,6 +129,15 @@ export const addClaimedSubdomain = (
|
||||
if (claims.has(name)) {
|
||||
throw new Error(`Subdomain '${name}' is already claimed`);
|
||||
}
|
||||
if (isMainPipelineClaim(claim)) {
|
||||
if ('authorize' in claim || 'checkRoute' in claim) {
|
||||
throw new Error(
|
||||
`Subdomain '${name}' claimed for the main pipeline with a gate`,
|
||||
);
|
||||
}
|
||||
claims.set(name, { mainPipeline: true });
|
||||
return;
|
||||
}
|
||||
if (typeof claim?.authorize !== 'function') {
|
||||
throw new Error(`Subdomain '${name}' claimed without authorize`);
|
||||
}
|
||||
@@ -131,7 +163,7 @@ const subdomainNotFound = () =>
|
||||
* unclaimed-subdomain 404, and errors reach the error handler.
|
||||
*/
|
||||
const dispatchClaimed = async (
|
||||
claim: ResolvedClaim,
|
||||
claim: Exclude<ResolvedClaim, MainPipelineSubdomainClaim>,
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
@@ -190,7 +222,8 @@ export const createWwwRedirect = (config: IConfig): RequestHandler => {
|
||||
* Passes through when:
|
||||
*
|
||||
* - No active subdomain (root)
|
||||
* - Active subdomain is reserved (api, js, native apps, …)
|
||||
* - Active subdomain is reserved (api, js, native apps, …) or claimed for the
|
||||
* main pipeline
|
||||
* - Host is on one of the hosting domains (they may nest under `config.domain`)
|
||||
* - Host is a local worker host and the local worker server is on
|
||||
* - Host isn't under `config.domain` (custom domains, other hosts)
|
||||
@@ -223,6 +256,8 @@ export const createUserSubdomainNotFound = (
|
||||
req.subdomains?.[req.subdomains.length - 1] ?? ''
|
||||
).toLowerCase();
|
||||
if (active === '' || RESERVED_SUBDOMAINS.has(active)) return next();
|
||||
const claim = lookupClaim(active);
|
||||
if (isMainPipelineClaim(claim)) return next();
|
||||
|
||||
const hostName = (req.headers.host ?? '').toLowerCase().split(':')[0];
|
||||
if (
|
||||
@@ -239,8 +274,9 @@ export const createUserSubdomainNotFound = (
|
||||
// responder, body parsing, auth and every route, `subdomain: '*'` ones
|
||||
// included. One label deep only: `<name>.x.<domain>` is not the claim.
|
||||
const claimed =
|
||||
req.subdomains?.length === 1 && hostName === `${active}.${domain}`
|
||||
? lookupClaim(active)
|
||||
req.subdomains?.length === 1 &&
|
||||
subdomainLabelOf(hostName, domain) === active
|
||||
? claim
|
||||
: undefined;
|
||||
if (claimed) {
|
||||
void dispatchClaimed(claimed, req, res, next);
|
||||
|
||||
@@ -19,6 +19,8 @@
|
||||
|
||||
import { actorOwnsRow, requireContextActor } from '../../core/actor.js';
|
||||
import { HttpError } from '../../core/http/HttpError.js';
|
||||
import { subdomainLabelOf } from '../../core/http/middleware/hostRedirects.js';
|
||||
import { extensionStore } from '../../extensions.js';
|
||||
import {
|
||||
DEFAULT_FREE_SUBSCRIPTION,
|
||||
DEFAULT_TEMP_SUBSCRIPTION,
|
||||
@@ -719,9 +721,10 @@ export class AppDriver extends PuterDriver {
|
||||
* loading from the GUI host would execute third-party code same-origin with
|
||||
* the desktop — a full sandbox escape. The API host is reserved for the
|
||||
* same reason, and the builtin sentinel host is rewritten by the GUI to
|
||||
* `<gui origin>/builtin/…` (see BUILTIN_APPS_HOST above). Host comparison
|
||||
* (rather than full origin) deliberately also catches scheme/port variants
|
||||
* of these hosts.
|
||||
* `<gui origin>/builtin/…` (see BUILTIN_APPS_HOST above). A subdomain an
|
||||
* extension has claimed is a system host too. Host comparison (rather than
|
||||
* full origin) deliberately also catches scheme/port variants of these
|
||||
* hosts.
|
||||
*/
|
||||
#assertIndexUrlHostAllowed(indexUrl) {
|
||||
let hostname;
|
||||
@@ -753,7 +756,13 @@ export class AppDriver extends PuterDriver {
|
||||
}
|
||||
}
|
||||
|
||||
if (reserved.has(hostname)) {
|
||||
const label = config.domain
|
||||
? subdomainLabelOf(hostname, config.domain)
|
||||
: undefined;
|
||||
if (
|
||||
reserved.has(hostname) ||
|
||||
(label !== undefined && extensionStore.claimedSubdomains.has(label))
|
||||
) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
'`index_url` cannot point at a Puter system host',
|
||||
|
||||
@@ -21,6 +21,7 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import type { Actor } from '../../core/actor.js';
|
||||
import { runWithContext } from '../../core/context.js';
|
||||
import { extensionStore } from '../../extensions.js';
|
||||
import { PuterServer } from '../../server.js';
|
||||
import { setupTestServer } from '../../testUtil.js';
|
||||
import { decodeCursor } from '../../util/pagination.js';
|
||||
@@ -195,6 +196,105 @@ describe('AppDriver.create', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('with a claimed subdomain', () => {
|
||||
const claimed = 'claimed-app-host';
|
||||
|
||||
beforeAll(() => {
|
||||
extensionStore.claimedSubdomains.set(claimed, {
|
||||
authorize: () => true,
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
extensionStore.claimedSubdomains.delete(claimed);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['exactly', `https://${claimed}.puter.localhost/`],
|
||||
[
|
||||
'in uppercase',
|
||||
`https://${claimed.toUpperCase()}.PUTER.localhost/`,
|
||||
],
|
||||
['with a port', `http://${claimed}.puter.localhost:4100/app.html`],
|
||||
])(
|
||||
'rejects an index_url on it %s with 400',
|
||||
async (_label, index_url) => {
|
||||
const { actor } = await makeUser();
|
||||
await expect(
|
||||
withActor(actor, () =>
|
||||
driver.create({
|
||||
object: {
|
||||
name: uniqueName('claimed-host'),
|
||||
title: 't',
|
||||
index_url,
|
||||
},
|
||||
}),
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 400,
|
||||
message: /system host/,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
['exactly', `https://${claimed}.puter.localhost/`],
|
||||
[
|
||||
'in uppercase',
|
||||
`https://${claimed.toUpperCase()}.PUTER.localhost/`,
|
||||
],
|
||||
['with a port', `http://${claimed}.puter.localhost:4100/app.html`],
|
||||
])(
|
||||
'rejects updating an index_url onto it %s with 400',
|
||||
async (_label, index_url) => {
|
||||
const { actor } = await makeUser();
|
||||
const created = await withActor(actor, () =>
|
||||
driver.create({
|
||||
object: {
|
||||
name: uniqueName('claimed-host-upd'),
|
||||
title: 't',
|
||||
index_url: uniqueIndexUrl(),
|
||||
},
|
||||
}),
|
||||
);
|
||||
await expect(
|
||||
withActor(actor, () =>
|
||||
driver.update({
|
||||
uid: created.uid,
|
||||
object: { index_url },
|
||||
}),
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 400,
|
||||
message: /system host/,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
[
|
||||
'an unclaimed sibling subdomain',
|
||||
'https://not-claimed-app-host.puter.localhost/',
|
||||
],
|
||||
[
|
||||
'a host nested under the claim',
|
||||
`https://x.${claimed}.puter.localhost/`,
|
||||
],
|
||||
])('allows an index_url on %s', async (_label, index_url) => {
|
||||
const { actor } = await makeUser();
|
||||
const created = await withActor(actor, () =>
|
||||
driver.create({
|
||||
object: {
|
||||
name: uniqueName('claimed-sibling'),
|
||||
title: 't',
|
||||
index_url,
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(created.index_url).toBe(index_url);
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects a duplicate app name with 400', async () => {
|
||||
const a = await makeUser();
|
||||
const b = await makeUser();
|
||||
|
||||
@@ -161,6 +161,13 @@ describe('extension.claimSubdomain', () => {
|
||||
expect(extensionStore.claimedSubdomains.get('portal')).toEqual(claim);
|
||||
});
|
||||
|
||||
it('records a main-pipeline claim', () => {
|
||||
extension.claimSubdomain('onlyoffice', { mainPipeline: true });
|
||||
expect(extensionStore.claimedSubdomains.get('onlyoffice')).toEqual({
|
||||
mainPipeline: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('refuses a second claim, a reserved name, an invalid label and no authorize', () => {
|
||||
extension.claimSubdomain('portal', claim);
|
||||
expect(() => extension.claimSubdomain('portal', claim)).toThrow(
|
||||
|
||||
@@ -353,7 +353,8 @@ export const extension = {
|
||||
* Serve `<name>.<config.domain>` in place of the 404 an unclaimed subdomain
|
||||
* gets. `authorize` runs first, before CORS, body parsing and auth; when it
|
||||
* returns true, routes declared with `subdomain: name` answer. Anything
|
||||
* else gets that same 404.
|
||||
* else gets that same 404. `{ mainPipeline: true }` instead serves the host
|
||||
* like core's own subdomains, with ordinary routes.
|
||||
*/
|
||||
claimSubdomain: (name: string, claim: SubdomainClaim) => {
|
||||
addClaimedSubdomain(extensionStore.claimedSubdomains, name, claim);
|
||||
|
||||
@@ -781,6 +781,93 @@ describe('PuterServer claimed subdomains', () => {
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* A main-pipeline claim is served like a reserved subdomain, with ordinary
|
||||
* routes.
|
||||
*/
|
||||
describe('PuterServer main-pipeline claims', () => {
|
||||
let server: PuterServer;
|
||||
let port: number;
|
||||
const routeCount = { before: 0 };
|
||||
|
||||
beforeAll(async () => {
|
||||
extension.claimSubdomain('mainclaim', { mainPipeline: true });
|
||||
routeCount.before = extensionStore.routeHandlers.length;
|
||||
extension.get(
|
||||
'/hello',
|
||||
{ subdomain: 'mainclaim' },
|
||||
(_req: Request, res: Response) => {
|
||||
res.send('main hello');
|
||||
},
|
||||
);
|
||||
extension.get(
|
||||
'/signed-in',
|
||||
{
|
||||
subdomain: 'mainclaim',
|
||||
requireAuth: true,
|
||||
rateLimit: { limit: 5, window: 1000, key: 'user' },
|
||||
},
|
||||
(_req: Request, res: Response) => {
|
||||
res.send('signed in');
|
||||
},
|
||||
);
|
||||
extension.get('/everywhere', { subdomain: '*' }, (_req, res) => {
|
||||
res.send('everywhere');
|
||||
});
|
||||
port = await allocateEphemeralPort();
|
||||
server = await setupTestServer(
|
||||
{
|
||||
port,
|
||||
domain: 'puter.localhost',
|
||||
origin: `http://puter.localhost:${port}`,
|
||||
static_hosting_domain: 'site.puter.localhost',
|
||||
} as unknown as IConfig,
|
||||
{ listen: true },
|
||||
);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
extensionStore.claimedSubdomains.delete('mainclaim');
|
||||
extensionStore.routeHandlers.length = routeCount.before;
|
||||
await server?.shutdown();
|
||||
});
|
||||
|
||||
it('serves its routes and the main app on the claimed host', async () => {
|
||||
const host = 'mainclaim.puter.localhost';
|
||||
expect((await rawRequest(port, '/hello', { host })).body).toBe(
|
||||
'main hello',
|
||||
);
|
||||
const everywhere = await rawRequest(port, '/everywhere', {
|
||||
host,
|
||||
origin: 'https://elsewhere.example',
|
||||
});
|
||||
const reserved = await rawRequest(port, '/everywhere', {
|
||||
host: 'api.puter.localhost',
|
||||
origin: 'https://elsewhere.example',
|
||||
});
|
||||
expect(everywhere.body).toBe('everywhere');
|
||||
for (const res of [everywhere, reserved]) {
|
||||
expect(res.headers['access-control-allow-origin']).toBe(
|
||||
'https://elsewhere.example',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('runs auth for its routes', async () => {
|
||||
const res = await rawRequest(port, '/signed-in', {
|
||||
host: 'mainclaim.puter.localhost',
|
||||
});
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('keeps its routes off other hosts', async () => {
|
||||
for (const host of ['api.puter.localhost', 'other.puter.localhost']) {
|
||||
const res = await rawRequest(port, '/hello', { host });
|
||||
expect(res.status, host).toBe(404);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('PuterServer claimed subdomain route options', () => {
|
||||
const noop = ((_req: Request, res: Response) =>
|
||||
res.end()) as unknown as RequestHandler;
|
||||
|
||||
+12
-8
@@ -82,6 +82,7 @@ import {
|
||||
createWwwRedirect,
|
||||
createUserSubdomainNotFound,
|
||||
createNativeAppStatic,
|
||||
isMainPipelineClaim,
|
||||
} from './core/http/middleware/hostRedirects';
|
||||
import { createEgressMeteringMiddleware } from './core/http/middleware/egressMetering';
|
||||
import { createLocalWorkerProxyMiddleware } from './core/http/middleware/localWorkerProxy';
|
||||
@@ -451,7 +452,7 @@ export class PuterServer {
|
||||
this.#app.use(
|
||||
createUserSubdomainNotFound(this.#config, (name) => {
|
||||
const claim = extensionStore.claimedSubdomains.get(name);
|
||||
if (!claim) return undefined;
|
||||
if (!claim || isMainPipelineClaim(claim)) return claim;
|
||||
return {
|
||||
authorize: claim.authorize,
|
||||
handler: this.#claimRouters.get(name),
|
||||
@@ -1374,26 +1375,29 @@ export class PuterServer {
|
||||
}
|
||||
|
||||
/**
|
||||
* The claimed subdomain a route is declared for, if any. Claimed hosts
|
||||
* authenticate through the claim's `authorize`, so options that rely on the
|
||||
* main pipeline's actor or origin handling fail the boot instead.
|
||||
* The gated claim a route is declared for, if any. Gated hosts authenticate
|
||||
* through the claim's `authorize`, so options that rely on the main
|
||||
* pipeline's actor or origin handling fail the boot instead.
|
||||
*/
|
||||
static #claimedSubdomainOf(
|
||||
opts: RouteOptions,
|
||||
label: string,
|
||||
): string | undefined {
|
||||
const claims = extensionStore.claimedSubdomains;
|
||||
const gated = (name: string) => {
|
||||
const claim = extensionStore.claimedSubdomains.get(name);
|
||||
return isMainPipelineClaim(claim) ? undefined : claim;
|
||||
};
|
||||
const labels = [opts.subdomain ?? []].flat();
|
||||
for (const name of labels) {
|
||||
const lower = name.toLowerCase();
|
||||
if (lower !== name && claims.has(lower)) {
|
||||
if (lower !== name && gated(lower)) {
|
||||
throw new Error(
|
||||
`${label}: claimed subdomain '${lower}' must be written in lowercase`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (Array.isArray(opts.subdomain)) {
|
||||
const mixed = opts.subdomain.find((s) => claims.has(s));
|
||||
const mixed = opts.subdomain.find((s) => gated(s));
|
||||
if (mixed !== undefined) {
|
||||
throw new Error(
|
||||
`${label}: claimed subdomain '${mixed}' must be the route's only subdomain`,
|
||||
@@ -1402,7 +1406,7 @@ export class PuterServer {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof opts.subdomain !== 'string') return undefined;
|
||||
const claim = claims.get(opts.subdomain);
|
||||
const claim = gated(opts.subdomain);
|
||||
if (!claim) return undefined;
|
||||
for (const [key, value] of Object.entries(opts)) {
|
||||
if (value === undefined || value === false) continue;
|
||||
|
||||
@@ -2397,6 +2397,53 @@ describe('FSEntryStore home and prefix rewrites', () => {
|
||||
expect(descendant).not.toBeNull();
|
||||
});
|
||||
|
||||
it("finishes a home rename that stopped partway, moving only the owner's rows", async () => {
|
||||
const user = await makeUser();
|
||||
const other = await makeUser();
|
||||
const kept = await createFile(user, `${user.home}/Documents/kept.txt`);
|
||||
// Resolve once so the old paths are cached.
|
||||
expect(await store.getEntryByPath(kept.path)).not.toBeNull();
|
||||
// A row another account owns under this home, as legacy writes left them.
|
||||
const stray = await createFile(user, `${user.home}/Public/stray.txt`);
|
||||
await server.clients.db.write(
|
||||
'UPDATE fsentries SET user_id = ? WHERE id = ?',
|
||||
[other.userId, stray.id],
|
||||
);
|
||||
const root = await store.getRootEntryForUser(user.userId);
|
||||
const newPath = `/${user.username}-healed`;
|
||||
// The rename wrote the home row, then the cascade stopped.
|
||||
await server.clients.db.write(
|
||||
'UPDATE fsentries SET name = ?, path = ? WHERE id = ?',
|
||||
[newPath.slice(1), newPath, root!.id],
|
||||
);
|
||||
expect(
|
||||
await store.countUserSubtree(user.userId, user.home),
|
||||
).toBeGreaterThan(0);
|
||||
|
||||
const moved = await store.rebaseUserSubtree(
|
||||
user.userId,
|
||||
user.home,
|
||||
newPath,
|
||||
);
|
||||
|
||||
expect(moved).toBeGreaterThan(0);
|
||||
expect(
|
||||
await store.countUserSubtree(user.userId, user.home, newPath),
|
||||
).toBe(0);
|
||||
expect(await store.countUserSubtree(user.userId, newPath)).toBe(moved);
|
||||
expect(
|
||||
(await store.getEntryByPath(`${newPath}/Documents/kept.txt`))?.id,
|
||||
).toBe(kept.id);
|
||||
expect(await store.getEntryByPath(kept.path)).toBeNull();
|
||||
expect((await store.getEntryById(stray.id))?.path).toBe(stray.path);
|
||||
await expect(
|
||||
store.rebaseUserSubtree(user.userId, '/', newPath),
|
||||
).rejects.toThrow(HttpError);
|
||||
expect(
|
||||
await store.rebaseUserSubtree(user.userId, newPath, newPath),
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
it('is a no-op when the home row already matches, and null for an unknown user', async () => {
|
||||
const user = await makeUser();
|
||||
const first = await store.renameUserHome(user.userId, user.username);
|
||||
@@ -2845,6 +2892,31 @@ describe('FSEntryStore subtree patterns on MySQL', () => {
|
||||
).toHaveLength(500);
|
||||
});
|
||||
|
||||
it('scopes a finishing rebase and its count to the owner, with every pattern', async () => {
|
||||
const moved = tree.map((fixture) =>
|
||||
fixture.id === 1 ? { ...fixture, path: '/u/renamed' } : fixture,
|
||||
);
|
||||
const { db, store: mysqlStore } = storeOver(moved);
|
||||
const all = [
|
||||
'/u/tree/%',
|
||||
'/u/tree/🔥 a%',
|
||||
'/u/tree/😀 50!%!_off!!.png%',
|
||||
'/u/tree/🔥 a/🔥 b%',
|
||||
];
|
||||
|
||||
await mysqlStore.rebaseUserSubtree(7, '/u/tree', '/u/renamed');
|
||||
|
||||
const write = db.statements.find((st) => st.method === 'write');
|
||||
expect(write!.query).toContain('WHERE user_id = ?');
|
||||
expect(write!.params.slice(-5)).toEqual([7, ...all]);
|
||||
|
||||
db.statements.length = 0;
|
||||
await mysqlStore.countUserSubtree(7, '/u/tree', '/u/renamed');
|
||||
expect(db.statements).toEqual([
|
||||
expect.objectContaining({ method: 'pread', params: [7, ...all] }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('adds nothing on other engines', async () => {
|
||||
const { db, store: sqliteStore } = storeOver(tree, 'sqlite');
|
||||
|
||||
|
||||
@@ -3707,27 +3707,7 @@ export class FSEntryStore extends PuterStore {
|
||||
);
|
||||
|
||||
if (oldPath && oldPath !== '/' && oldPath !== newPath) {
|
||||
const likePattern = `${this.#escapeLikePattern(oldPath)}/%`;
|
||||
// SUBSTR counts characters, not UTF-16 code units.
|
||||
const oldLen = [...oldPath].length;
|
||||
const rewrittenPath = this.clients.db.case({
|
||||
postgres: '? || SUBSTR(path, ?)',
|
||||
otherwise: 'CONCAT(?, SUBSTR(path, ?))',
|
||||
});
|
||||
const groups = await this.#subtreePatterns(oldPath, likePattern, {
|
||||
rootPaths: [oldPath, newPath],
|
||||
primary: true,
|
||||
});
|
||||
for (const patterns of groups) {
|
||||
await this.clients.db.write(
|
||||
`UPDATE fsentries
|
||||
SET path = ${rewrittenPath},
|
||||
modified = ?
|
||||
WHERE user_id = ? AND ${this.#pathLikeAny(patterns)}`,
|
||||
[newPath, oldLen + 1, now, userId, ...patterns],
|
||||
);
|
||||
}
|
||||
await this.#invalidateRebasedSubtree(oldPath, newPath, groups);
|
||||
await this.#rebaseOwnedSubtree(userId, oldPath, newPath, now);
|
||||
}
|
||||
|
||||
// Invalidate root cache under both old and new keys.
|
||||
@@ -3746,6 +3726,106 @@ export class FSEntryStore extends PuterStore {
|
||||
return refreshed;
|
||||
}
|
||||
|
||||
// Finishes a home rename that stopped partway: moves the owner's rows under `oldPath` and drops their cached copies and the home's.
|
||||
async rebaseUserSubtree(
|
||||
userId: number,
|
||||
oldPath: string,
|
||||
newPath: string,
|
||||
): Promise<number> {
|
||||
const from = this.#normalizePath(oldPath);
|
||||
const to = this.#normalizePath(newPath);
|
||||
if (from === '/' || to === '/') {
|
||||
throw new HttpError(
|
||||
400,
|
||||
'Cannot rewrite path prefix to/from root',
|
||||
{ legacyCode: 'bad_request' },
|
||||
);
|
||||
}
|
||||
if (from === to) return 0;
|
||||
const moved = await this.#rebaseOwnedSubtree(
|
||||
userId,
|
||||
from,
|
||||
to,
|
||||
Math.floor(Date.now() / 1000),
|
||||
);
|
||||
const homes = (await this.clients.db.pread(
|
||||
`SELECT ${this.#selectFsentriesColumns()} FROM fsentries
|
||||
WHERE user_id = ? AND parent_uid IS NULL AND path IN (?, ?)`,
|
||||
[userId, from, to],
|
||||
)) as unknown as FSEntryRow[];
|
||||
await this.#invalidateEntriesCache(
|
||||
homes.map((row) => this.#mapFSEntryRow(row)),
|
||||
);
|
||||
// A home cached under the old path may no longer have a row there.
|
||||
await this.publishCacheKeys({
|
||||
keys: [entryCacheKey('path', from)],
|
||||
broadcast: this.#takeBroadcastBudget(1) > 0,
|
||||
});
|
||||
return moved;
|
||||
}
|
||||
|
||||
// The owner's rows under `path`, matched as a rewrite matches them, from the primary; `homePath` is where the home row sits if it moved.
|
||||
async countUserSubtree(
|
||||
userId: number,
|
||||
path: string,
|
||||
homePath?: string,
|
||||
): Promise<number> {
|
||||
const prefix = this.#normalizePath(path);
|
||||
if (prefix === '/') return 0;
|
||||
const groups = await this.#subtreePatterns(
|
||||
prefix,
|
||||
`${this.#escapeLikePattern(prefix)}/%`,
|
||||
{
|
||||
rootPaths: homePath
|
||||
? [prefix, this.#normalizePath(homePath)]
|
||||
: [prefix],
|
||||
primary: true,
|
||||
},
|
||||
);
|
||||
let total = 0;
|
||||
for (const patterns of groups) {
|
||||
const rows = (await this.clients.db.pread(
|
||||
`SELECT COUNT(*) AS n FROM fsentries WHERE user_id = ? AND ${this.#pathLikeAny(patterns)}`,
|
||||
[userId, ...patterns],
|
||||
)) as unknown as { n: number | string }[];
|
||||
total += Number(rows[0]?.n ?? 0);
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
// The owner-scoped prefix rewrite behind renameUserHome and rebaseUserSubtree.
|
||||
async #rebaseOwnedSubtree(
|
||||
userId: number,
|
||||
oldPath: string,
|
||||
newPath: string,
|
||||
now: number,
|
||||
): Promise<number> {
|
||||
const likePattern = `${this.#escapeLikePattern(oldPath)}/%`;
|
||||
// SUBSTR counts characters, not UTF-16 code units.
|
||||
const oldLen = [...oldPath].length;
|
||||
const rewrittenPath = this.clients.db.case({
|
||||
postgres: '? || SUBSTR(path, ?)',
|
||||
otherwise: 'CONCAT(?, SUBSTR(path, ?))',
|
||||
});
|
||||
const groups = await this.#subtreePatterns(oldPath, likePattern, {
|
||||
rootPaths: [oldPath, newPath],
|
||||
primary: true,
|
||||
});
|
||||
let affected = 0;
|
||||
for (const patterns of groups) {
|
||||
const result = await this.clients.db.write(
|
||||
`UPDATE fsentries
|
||||
SET path = ${rewrittenPath},
|
||||
modified = ?
|
||||
WHERE user_id = ? AND ${this.#pathLikeAny(patterns)}`,
|
||||
[newPath, oldLen + 1, now, userId, ...patterns],
|
||||
);
|
||||
affected += this.#affectedRows(result);
|
||||
}
|
||||
await this.#invalidateRebasedSubtree(oldPath, newPath, groups);
|
||||
return affected;
|
||||
}
|
||||
|
||||
// Rewrites path column for every descendant of `oldPrefix` to use `newPrefix`,
|
||||
// and drops their cached copies. Used by move/rename when a directory is
|
||||
// relocated. Pass `newUserId` when the subtree also changes hands.
|
||||
|
||||
@@ -19,7 +19,8 @@
|
||||
|
||||
import { type Attributes, SpanStatusCode, trace } from '@opentelemetry/api';
|
||||
|
||||
const tracer = trace.getTracer('puter-backend');
|
||||
// Looked up per call: a cached ProxyTracer keeps the first provider it saw.
|
||||
const tracer = () => trace.getTracer('puter-backend');
|
||||
|
||||
type AttrsOrFactory = Attributes | (() => Attributes);
|
||||
|
||||
@@ -32,7 +33,7 @@ export function withSpan<T>(
|
||||
attrs: AttrsOrFactory,
|
||||
fn: () => T,
|
||||
): T {
|
||||
return tracer.startActiveSpan(name, (span) => {
|
||||
return tracer().startActiveSpan(name, (span) => {
|
||||
try {
|
||||
// Mirror the name into an attribute: X-Ray can only filter by
|
||||
// annotations, and annotations come from attributes.
|
||||
@@ -65,7 +66,10 @@ export function withSpan<T>(
|
||||
});
|
||||
}
|
||||
|
||||
function recordError(span: ReturnType<typeof tracer.startSpan>, err: unknown) {
|
||||
function recordError(
|
||||
span: ReturnType<ReturnType<typeof tracer>['startSpan']>,
|
||||
err: unknown,
|
||||
) {
|
||||
const e = err instanceof Error ? err : new Error(String(err));
|
||||
span.recordException(e);
|
||||
span.setStatus({ code: SpanStatusCode.ERROR, message: e.message });
|
||||
|
||||
Reference in new issue
Block a user