diff --git a/src/backend/core/http/middleware/puterSite.test.ts b/src/backend/core/http/middleware/puterSite.test.ts index bf9ae1343..a0ecd37f3 100644 --- a/src/backend/core/http/middleware/puterSite.test.ts +++ b/src/backend/core/http/middleware/puterSite.test.ts @@ -26,8 +26,7 @@ import { PuterServer } from '../../../server'; import { HOSTED_ASSET_COOKIES } from '../../../services/auth/AuthService'; import { setupTestServer } from '../../../testUtil'; import type { IConfig } from '../../../types'; -import { makeActor, type Actor } from '../../actor'; -import { runWithContext } from '../../context'; +import type { Actor } from '../../actor'; import { generateDefaultFsentries } from '../../../util/userProvisioning'; import { createPuterSiteMiddleware } from './puterSite'; @@ -1851,223 +1850,6 @@ describe('createPuterSiteMiddleware — delegated site roots', () => { expect((await serveSite(sub)).statusCode).toBe(404); }); - it.each(['write', 'manage'])( - 'revalidates app-created sites with %s grants', - async (mode) => { - const owner = await makeUserWithHome(); - const app = await server.stores.app.create( - { - name: `publisher-${uuidv4()}`, - title: 'Publisher', - index_url: 'https://publisher.example.test/', - }, - { ownerUserId: owner.id }, - ); - const root = (await server.stores.fsEntry.getEntryByPath( - `/${owner.username}/Documents`, - ))!; - const permission = - mode === 'manage' - ? `manage:fs:${root.uuid}` - : `fs:${root.uuid}:write`; - await server.services.permission.grantUserAppPermission( - actorFor(owner), - app.uid, - permission, - ); - const sub = `app-${Math.random().toString(36).slice(2, 10)}`; - // Stored rows include publications made before the current authority check. - await server.stores.subdomain.create({ - userId: owner.id, - subdomain: sub, - rootDirId: root.id, - appOwner: app.id, - }); - await writeFile( - owner.id, - `${root.path}/index.html`, - Buffer.from('private initial'), - 'text/html', - ); - expect((await serveSite(sub)).statusCode).toBe( - mode === 'manage' ? 200 : 404, - ); - await server.services.permission.revokeUserAppPermission( - actorFor(owner), - app.uid, - permission, - ); - await server.services.fs.write(owner.id, { - fileMetadata: { - path: `${root.path}/index.html`, - overwrite: true, - size: 14, - contentType: 'text/html', - }, - fileContent: Buffer.from('private future'), - }); - const out = await serveSite(sub); - expect(out.statusCode).toBe(404); - expect(String(out.body)).not.toContain('private future'); - }, - ); - - it.each(['session', 'fullAccess', 'scoped'])( - 'root changes preserve publication authority for %s', - async (mode) => { - const owner = await makeUserWithHome(); - const userActor = makeActor({ user: owner }); - const actor = - mode === 'session' - ? userActor - : makeActor({ - user: owner, - accessToken: { - uid: uuidv4(), - issuer: userActor, - fullAccess: mode === 'fullAccess', - }, - }); - const app = await server.stores.app.create( - { - name: `takeover-${uuidv4()}`, - title: 'Publisher', - index_url: 'https://publisher.example.test/', - }, - { ownerUserId: owner.id }, - ); - const appActor = makeActor({ - user: owner, - app: { id: app.id, uid: app.uid }, - }); - const root = await server.services.fs.mkdir(owner.id, { - path: `/${owner.username}/AppData/${app.uid}`, - createMissingParents: true, - }); - const sub = `takeover-${Math.random().toString(36).slice(2, 10)}`; - const site = await server.stores.subdomain.create({ - userId: owner.id, - subdomain: sub, - rootDirId: root.id, - appOwner: app.id, - }); - await runWithContext({ actor }, () => - server.drivers.subdomains.update({ - uid: site.uuid, - object: { root_dir: root.path }, - }), - ); - expect( - (await server.stores.subdomain.getByUuid(site.uuid))!.app_owner, - ).toBe(app.id); - await runWithContext({ actor }, () => - server.drivers.subdomains.update({ - uid: site.uuid, - object: {}, - }), - ); - expect( - (await server.stores.subdomain.getByUuid(site.uuid))!.app_owner, - ).toBe(app.id); - const newRoot = `/${owner.username}/Documents`; - if (mode === 'scoped') { - const entry = - (await server.stores.fsEntry.getEntryByPath(newRoot))!; - await server.stores.permission.insertAccessTokenPerms( - actor.accessToken!.uid, - { userId: owner.id, appId: null }, - [{ permission: `manage:fs:${entry.uuid}` }], - ); - } - await writeFile( - owner.id, - `${newRoot}/index.html`, - Buffer.from('account publication'), - 'text/html', - ); - await runWithContext({ actor }, () => - server.drivers.subdomains.update({ - uid: site.uuid, - object: { root_dir: newRoot }, - }), - ); - expect((await serveSite(sub)).statusCode).toBe(200); - expect( - (await server.stores.subdomain.getByUuid(site.uuid))!.app_owner, - ).toBeNull(); - await expect( - runWithContext({ actor: appActor }, () => - server.drivers.subdomains.update({ - uid: site.uuid, - object: { root_dir: root.path }, - }), - ), - ).rejects.toMatchObject({ statusCode: 403 }); - }, - ); - - it.each([false, true])( - 'serves own AppData without grant queries (hosted app matches creator: %s)', - async (hostedApp) => { - const owner = await makeUserWithHome(); - const sub = `appdata-${Math.random().toString(36).slice(2, 10)}`; - const app = await server.stores.app.create( - { - name: `data-${uuidv4()}`, - title: 'Data', - index_url: hostedApp - ? `http://${sub}.site.puter.localhost/` - : 'https://data.example.test/', - }, - { ownerUserId: owner.id }, - ); - const root = await server.services.fs.mkdir(owner.id, { - path: `/${owner.username}/AppData/${app.uid}`, - createMissingParents: true, - }); - await writeFile( - owner.id, - `${root.path}/index.html`, - Buffer.from('app data'), - 'text/html', - ); - await server.stores.subdomain.create({ - userId: owner.id, - subdomain: sub, - rootDirId: root.id, - appOwner: app.id, - }); - expect((await serveSite(sub)).statusCode).toBe(200); - const read = vi.spyOn(server.clients.db, 'read'); - const get = vi.spyOn(server.clients.redis, 'get'); - const acl = vi.spyOn(server.services.acl, 'check'); - try { - const out = await serveSite(sub); - expect(out.statusCode).toBe(200); - expect(acl).not.toHaveBeenCalled(); - expect( - get.mock.calls.filter( - ([key]) => key === `apps:id:${app.id}`, - ), - ).toHaveLength(hostedApp ? 0 : 1); - expect( - read.mock.calls.filter(([query]) => { - const sql = String(query); - return ( - /(?:FROM|JOIN)\s+`?permissions`?\b/i.test(sql) || - (/FROM\s+`apps`/i.test(sql) && - !sql.includes('index_url')) - ); - }), - ).toHaveLength(0); - } finally { - read.mockRestore(); - get.mockRestore(); - acl.mockRestore(); - } - }, - ); - it("never consults the ACL for a site rooted in the publisher's own tree", async () => { const owner = await makeUserWithHome(); const homePath = `/${owner.username}`; @@ -2087,19 +1869,12 @@ describe('createPuterSiteMiddleware — delegated site roots', () => { }); const aclCheck = vi.spyOn(server.services.acl, 'check'); - const redisGet = vi.spyOn(server.clients.redis, 'get'); try { const out = await serveSite(sub); expect(out.statusCode).toBe(200); expect(aclCheck).not.toHaveBeenCalled(); - expect( - redisGet.mock.calls.filter(([key]) => - String(key).startsWith('apps:id:'), - ), - ).toHaveLength(0); } finally { aclCheck.mockRestore(); - redisGet.mockRestore(); } }); }); diff --git a/src/backend/core/http/middleware/puterSite.ts b/src/backend/core/http/middleware/puterSite.ts index 5f36e4d8e..4c3c1d33e 100644 --- a/src/backend/core/http/middleware/puterSite.ts +++ b/src/backend/core/http/middleware/puterSite.ts @@ -25,7 +25,6 @@ import type { puterServices } from '../../../services'; import { HOSTED_ASSET_COOKIES } from '../../../services/auth/AuthService'; import { MANAGE_PERM_PREFIX } from '../../../services/permission/consts'; import type { puterStores } from '../../../stores'; -import type { SubdomainRow } from '../../../stores/subdomain/SubdomainStore'; import type { IConfig, LayerInstances } from '../../../types'; import { awaitLater } from '../../../util/concurrency'; import { makeActor } from '../../actor'; @@ -142,6 +141,16 @@ const SUBDOMAIN_404 = `

404

Subdomain or site is not pointing to a directory.

`; +interface SubdomainRow { + id: number; + uuid: string; + subdomain: string; + user_id: number | null; + root_dir_id: number | null; + associated_app_id: number | null; + domain?: string | null; + protected?: number | null; +} interface AppRow { id: number; @@ -246,7 +255,7 @@ export const createPuterSiteMiddleware = ( const site = (await layers.stores.subdomain.getBySubdomain( subdomain, - )) as SubdomainRow | null; + )) as unknown as SubdomainRow | null; if (!site || site.user_id === null || site.user_id === undefined) { res.status(404).type('text/plain').send('Subdomain not found'); return; @@ -529,32 +538,13 @@ export const createPuterSiteMiddleware = ( return; } - // Delegated publications depend on their current share authority. - // Account-owned roots keep their direct path without extra lookups. - const publisherApp = - site.app_owner == null - ? null - : Number(associatedApp?.id) === Number(site.app_owner) - ? associatedApp - : await layers.stores.app.getById(Number(site.app_owner)); - const ownAppDataPath = publisherApp - ? `/${owner.username}/AppData/${publisherApp.uid}` - : null; - const ownAppData = - rootEntry.userId === site.user_id && - ownAppDataPath && - (rootEntry.path === ownAppDataPath || - rootEntry.path.startsWith(`${ownAppDataPath}/`)); - if (site.app_owner != null && !publisherApp) { - res.status(404) - .type('text/html; charset=UTF-8') - .send(SUBDOMAIN_404); - return; - } - if ( - rootEntry.userId !== site.user_id || - (publisherApp && !ownAppData) - ) { + // A site rooted in someone else's directory stands on the `manage` + // grant that authorized publishing it, and serves that whole subtree + // with the ACL bypassed — so the grant is re-read on every request. + // Trash is its own check: a grant is keyed on the node, not its + // location, so it survives the owner deleting the directory. Sites + // rooted in their publisher's own tree skip all of this. + if (rootEntry.userId !== site.user_id) { let stillPublishable = false; if (!isTrashedPath(rootEntry.path)) { try { @@ -565,9 +555,6 @@ export const createPuterSiteMiddleware = ( uuid: owner.uuid, username: owner.username, }, - app: publisherApp - ? { id: publisherApp.id, uid: publisherApp.uid } - : null, }), { path: rootEntry.path, diff --git a/src/backend/drivers/subdomain/SubdomainDriver.test.ts b/src/backend/drivers/subdomain/SubdomainDriver.test.ts index b1f1ed188..777bd2517 100644 --- a/src/backend/drivers/subdomain/SubdomainDriver.test.ts +++ b/src/backend/drivers/subdomain/SubdomainDriver.test.ts @@ -645,102 +645,6 @@ describe('SubdomainDriver root_dir publishing rights', () => { ).rejects.toMatchObject({ statusCode: 403 }); }); - it.each(['create', 'update'])( - 'refuses a same-user write-only app on %s', - async (operation) => { - const owner = await makeUser(); - const app = await server.stores.app.create( - { - name: uniqueSubdomain('publisher'), - title: 'Publisher', - index_url: 'https://publisher.example.test/', - }, - { ownerUserId: owner.userId }, - ); - const actor = makeActor({ - user: owner.actor.user, - app: { id: app.id, uid: app.uid }, - }); - const shared = `/${owner.actor.user!.username}/Documents`; - const entry = (await server.stores.fsEntry.getEntryByPath(shared))!; - await server.services.permission.grantUserAppPermission( - owner.actor, - app.uid, - `fs:${entry.uuid}:write`, - ); - let uid: unknown; - if (operation === 'update') { - const root = `/${owner.actor.user!.username}/AppData/${app.uid}`; - await server.services.fs.mkdir(owner.userId, { - path: root, - createMissingParents: true, - }); - const created = (await withActor(actor, () => - driver.create({ - object: { - subdomain: uniqueSubdomain('app-root'), - root_dir: root, - }, - }), - )) as Record; - uid = created.uid; - } - await expect( - withActor(actor, () => - operation === 'create' - ? driver.create({ - object: { - subdomain: uniqueSubdomain('denied-app'), - root_dir: shared, - }, - }) - : driver.update({ uid, object: { root_dir: shared } }), - ), - ).rejects.toMatchObject({ statusCode: 403 }); - }, - ); - - it('accepts a same-user app with share authority', async () => { - const owner = await makeUser(); - const app = await server.stores.app.create( - { - name: uniqueSubdomain('manager'), - title: 'Manager', - index_url: 'https://manager.example.test/', - }, - { ownerUserId: owner.userId }, - ); - const root = (await server.stores.fsEntry.getEntryByPath( - `/${owner.actor.user!.username}/Documents`, - ))!; - await server.services.permission.grantUserAppPermission( - owner.actor, - app.uid, - `manage:fs:${root.uuid}`, - ); - const actor = makeActor({ - user: owner.actor.user, - app: { id: app.id, uid: app.uid }, - }); - const sub = uniqueSubdomain('managed-app'); - const result = (await withActor(actor, () => - driver.create({ object: { subdomain: sub, root_dir: root.path } }), - )) as Record; - expect(result.subdomain).toBe(sub); - const nextRoot = (await server.stores.fsEntry.getEntryByPath( - `/${owner.actor.user!.username}/Public`, - ))!; - await server.services.permission.grantUserAppPermission( - owner.actor, app.uid, `manage:fs:${nextRoot.uuid}`, - ); - await withActor(actor, () => driver.update({ - uid: result.uid, object: { root_dir: nextRoot.path }, - })); - expect((await server.stores.subdomain.getBySubdomain(sub))!.app_owner) - .toBe(app.id); - - }); - // "Can edit & share" is the owner delegating the decision. it('accepts a directory shared with the actor at manage', async () => { const owner = await makeUser(); @@ -783,6 +687,8 @@ describe('SubdomainDriver root_dir publishing rights', () => { ).rejects.toMatchObject({ statusCode: 403 }); }); + // The owner's own path still only needs write, so an app hosting a + // directory its user gave it keeps working. it("still accepts the actor's own directory", async () => { const { actor } = await makeUser(); const sub = uniqueSubdomain('own'); diff --git a/src/backend/drivers/subdomain/SubdomainDriver.ts b/src/backend/drivers/subdomain/SubdomainDriver.ts index adeba5855..9dafced69 100644 --- a/src/backend/drivers/subdomain/SubdomainDriver.ts +++ b/src/backend/drivers/subdomain/SubdomainDriver.ts @@ -357,7 +357,6 @@ export class SubdomainDriver extends PuterDriver { // Subdomain name is immutable — strip if provided const patch: Record = {}; - let claimForAccount = false; if (object.root_dir !== undefined) { const rootDirPath = expandTildePath( String(object.root_dir), @@ -372,8 +371,6 @@ export class SubdomainDriver extends PuterDriver { } if (rootDirId !== (row.root_dir_id ?? null)) { await this.#checkPublishAccess(entry, actor); - // Scoped account credentials have proved manage on the new root. - claimForAccount = actor.effectiveApp === null; } patch.root_dir_id = rootDirId; } @@ -384,7 +381,7 @@ export class SubdomainDriver extends PuterDriver { const updated = await this.stores.subdomain.update( String(row.uuid), patch, - { userId: row.user_id as number, claimForAccount }, + { userId: row.user_id as number }, ); const [shaped] = await this.#hydrateRows( updated ? [updated as Record] : [], @@ -564,11 +561,22 @@ export class SubdomainDriver extends PuterDriver { throw new HttpError(403, 'Access denied', { legacyCode: 'forbidden' }); } - // Publishing exposes future descendants too, so every actor needs share authority. + /** + * Gate on pointing a subdomain at a directory. Hosting serves everything + * under the root dir with the ACL bypassed, so publishing makes that + * subtree world-readable — for good, and including whatever is written + * there later. That is the owner's call to make. + * + * `write` is not it. A shared folder's writer would be exposing the owner's + * tree, the row belongs to the writer's account, and nothing the owner can + * list would show it. `manage` — "Can edit & share" — is the grant that + * delegates the decision. + */ async #checkPublishAccess(entry: FSEntry, actor: Actor): Promise { // First, because this is the check that masks a directory the caller // cannot see at all as a 404. await this.services.fs.checkFSAccess(entry, actor); + if (entry.userId === actor.user?.id) return; try { await this.services.fs.checkFSAccess( entry, diff --git a/src/backend/stores/subdomain/SubdomainStore.ts b/src/backend/stores/subdomain/SubdomainStore.ts index 3995d4433..e75173fcd 100644 --- a/src/backend/stores/subdomain/SubdomainStore.ts +++ b/src/backend/stores/subdomain/SubdomainStore.ts @@ -63,8 +63,7 @@ export type CascadeRow = Pick< // - name / identity: `subdomain` — v1 marks this `immutable: true`; rename // would orphan DNS + ACL wiring tied to the old name. // - ownership: `user_id`, `app_owner` — set via `create` (or reclaimed in -// place by `reclaimByUuid`), never via patch. Account root reassignment -// clears app ownership through a trusted store option. +// place by `reclaimByUuid`), never via patch. // Flipping either hands the site to another user / app. // - access gate: `protected` — clearing this lets a future caller delete // or rename a protected site (e.g. `puter-app-icons`). The driver itself @@ -611,11 +610,8 @@ export class SubdomainStore extends PuterStore { patch: Record, { userId, - claimForAccount = false, }: { userId?: number | undefined; - /** The account explicitly selected a different publication root. */ - claimForAccount?: boolean; } = {}, ) { const allowed: Record = {}; @@ -623,19 +619,11 @@ export class SubdomainStore extends PuterStore { if (READ_ONLY_COLUMNS.has(k)) continue; allowed[k] = v; } - if (Object.keys(allowed).length === 0) - return this.getByUuid(uuid, { userId }); + const keys = Object.keys(allowed); + if (keys.length === 0) return this.getByUuid(uuid, { userId }); // Rename drops the old cache key. const before = await this.getByUuid(uuid, { userId }); - if ( - claimForAccount && - allowed.root_dir_id !== undefined && - allowed.root_dir_id !== before?.root_dir_id - ) { - allowed.app_owner = null; - } - const keys = Object.keys(allowed); const setClause = keys.map((k) => `\`${k}\` = ?`).join(', '); const values = keys.map((k) => allowed[k]); diff --git a/src/docs/src/Hosting/create.md b/src/docs/src/Hosting/create.md index 0f8b14563..f9730e515 100755 --- a/src/docs/src/Hosting/create.md +++ b/src/docs/src/Hosting/create.md @@ -23,7 +23,7 @@ A string containing the name of the subdomain you want to create. A string containing the path to the directory you want to serve. -Hosting serves the directory publicly, including files added later. An app can publish its own app data directory. Publishing another directory requires `manage` access, which allows editing and sharing. A shared directory stops being served if that access is withdrawn or its owner moves it to Trash. +The directory must be one you own. Hosting serves everything under it publicly, including files added later, so a directory someone shared with you can only be published if they gave you `manage` access — [`share()`](/FS/share/) calls that level "Can edit & share". That access is also what keeps the site up: if the owner withdraws it, or moves the directory to their trash, the site stops being served. #### `options` (Object) (optional) diff --git a/src/docs/src/Hosting/update.md b/src/docs/src/Hosting/update.md index c5159ee60..3054daab7 100755 --- a/src/docs/src/Hosting/update.md +++ b/src/docs/src/Hosting/update.md @@ -22,10 +22,6 @@ A string containing the name of the subdomain you want to update. A string containing the path to the directory you want to serve. -An app can publish its own app data directory. Publishing another directory requires `manage` access, which allows editing and sharing. Hosting serves all files under the directory publicly, including files added later. A shared directory stops being served if that access is withdrawn or its owner moves it to Trash. - -When an account credential selects a different directory for an app-created site, the account takes ownership of the publication and the creating app loses its management access. This includes an account-issued access token with management authority over the new directory. Selecting the current directory preserves the creating app's access. Credentials acting through an app preserve that app's ownership. - ## Return value A `Promise` that will resolve to a [`Subdomain`](/Objects/subdomain/) object when the subdomain has been updated. If a subdomain with the given name does not exist, the promise will be rejected with an error. If the path does not exist, the promise will be rejected with an error.