Compare commits

...
Author SHA1 Message Date
Juan Castro 8963bb3d0d Merge put-2111-onlyoffice-claimed-subdomain into the admin-v2 test core 2026-10-19 15:50:26 -04:00
Juan Castro c345eee610 Merge put-2110-claimed-hosts-as-app-urls into the admin-v2 test core 2026-10-19 15:50:26 -04:00
Juan Castro dd4742eb6a Merge put-2125-rebase-user-subtree into the admin-v2 test core 2026-10-19 15:50:26 -04:00
Juan Castro 1a8aaf9844 Merge origin/main into juancastro/put-2111-onlyoffice-claimed-subdomain 2026-10-19 15:50:16 -04:00
Juan Castro afef62438b Merge origin/main into juancastro/put-2110-claimed-hosts-as-app-urls 2026-10-19 15:50:15 -04:00
Juan Castro 6d00840864 Merge origin/main into juancastro/put-2125-rebase-user-subtree 2026-10-19 15:50:13 -04:00
Juan Castro b0e9fc955f Merge PUT-2110 and PUT-2111 for the admin-v2 test deploy 2026-10-19 11:42:28 -04:00
Juan Castro f303527ec7 Merge remote-tracking branch 'origin/juancastro/put-2110-claimed-hosts-as-app-urls' into HEAD 2026-10-19 11:42:16 -04:00
Juan Castro 6bd11c977f feat: let a subdomain claim keep the main pipeline
`extension.claimSubdomain(name, { mainPipeline: true })` serves
`<name>.<domain>` the way core's own subdomains are served: through CORS,
body parsing, auth and every route, `subdomain: '*'` ones included. Routes
declared for the name are ordinary routes, with no option restrictions.
Gated claims (`{ authorize, checkRoute? }`) behave as before.

onlyoffice's subdomain now comes from its extension's claim, so core no
longer lists it among the subdomains it serves itself. Without that claim
`onlyoffice.<domain>` gets the unclaimed-subdomain 404 like any other name.
2026-10-19 11:37:14 -04:00
Juan Castro 37fb205a62 fix(apps): refuse claimed subdomains as app index_url hosts (PUT-2110)
index_url may not point at a system host: the origin, the api host, or a
subdomain an extension has claimed. App create and update now apply the
same refusal to `<claimed>.<domain>` (any case, any port) that they apply
to the origin and api hosts.

The host-to-claim match is a new exported `subdomainLabelOf` in
hostRedirects, also used by the claim dispatch middleware, so both
decide the same way.
2026-10-19 10:52:34 -04:00
Juan Castro 4dcd199455 fix(telemetry): resolve the tracer per span so a test's provider is the one used
withSpan held a ProxyTracer built at module load, which caches the first
provider it delegates to. Test files sharing a worker then sent spans to a
provider an earlier file had already shut down.
2026-10-18 14:06:41 -04:00
Juan Castro aea28680f1 Merge remote-tracking branch 'origin/main' into juancastro/put-2125-rebase-user-subtree 2026-10-18 13:07:46 -04:00
Juan Castro c283b1d861 feat(fs): finish a home rename that stopped partway
FSEntryStore.rebaseUserSubtree runs renameUserHome's owner-scoped
cascade on its own (same subtree matching, same rebased-cache
invalidation) and also drops the home row's cached copies at either
path. countUserSubtree counts the owner's rows under a prefix with that
same matching, from the primary. renameUserHome now uses the shared
cascade.
2026-10-16 13:24:28 -04:00
11 changed files with 525 additions and 52 deletions

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);
+13 -4
View File
@@ -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',
+100
View File
@@ -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();
+7
View File
@@ -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(
+2 -1
View File
@@ -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);
+87
View File
@@ -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
View File
@@ -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');
+101 -21
View File
@@ -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.
+7 -3
View File
@@ -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 });