diff --git a/doc/contributing-apis.md b/doc/contributing-apis.md index 1bf280e8a..86759dff8 100644 --- a/doc/contributing-apis.md +++ b/doc/contributing-apis.md @@ -47,7 +47,7 @@ Both are supported ways to define an API. **Prefer a controller when you need fi Provider sign-in requires the account's authenticator or a recovery code when 2FA is enabled, including signup callbacks that resolve an existing account. The five-minute challenge uses the same single-use `/login/otp` and `/login/recovery-code` verification as password sign-in. The GUI carries it in a URL fragment and removes it before initialization. A session and successful popup-return proof are issued only after verification; cancelling retains the popup's signed retry context without marking it authenticated. -Disabling 2FA through `/user-protected/disable-2fa` requires both password or OIDC revalidation and the current six-digit authenticator `code`. Missing, malformed, or incorrect codes leave the secret and recovery codes intact. A team's requirement for 2FA still prevents disabling it. +Disabling 2FA through `/user-protected/disable-2fa` requires both password or OIDC revalidation and either the current six-digit authenticator `code` or an unused recovery code in `code`. Recovery codes are consumed atomically. Missing or incorrect codes leave the secret and remaining recovery codes intact. A team's requirement for 2FA still prevents disabling it. Email changes through `/user-protected/change-email` require the password or OIDC revalidation cookie, plus a six-digit `code` from the authenticator when 2FA is enabled. Missing or malformed codes return `400 bad_request`; incorrect codes return `401 code_mismatch`. Both include `factor: 'otp'`. Confirmation links are bound to that verified request. A 2FA-enabled account must request a new link if its existing link was issued without second-factor verification. diff --git a/extensions/thumbnails.test.ts b/extensions/thumbnails.test.ts index a47a598a4..abf2546da 100644 --- a/extensions/thumbnails.test.ts +++ b/extensions/thumbnails.test.ts @@ -314,6 +314,41 @@ describe('thumbnails extension — handleThumbnailRead', () => { await server?.shutdown(); }); + it('keeps read URLs stable within an hour and rotates them at the next hour', async () => { + const key = mintedKey(); + const signedAt = Date.UTC(2026, 9, 26, 10); + const read = async () => { + const entry: Record = { + thumbnail: `s3://${BUCKET}/${key}`, + }; + await handleThumbnailRead(entry, { + s3, + s3Presign, + bucketName: BUCKET, + bucketEndpoint: BUCKET_ENDPOINT, + fsEntry: server.stores.fsEntry, + }); + return entry.thumbnail as string; + }; + vi.useFakeTimers({ toFake: ['Date'] }); + try { + vi.setSystemTime(signedAt + 1000); + const first = await read(); + vi.setSystemTime(signedAt + 3599_000); + expect(await read()).toBe(first); + expect(new URL(first).searchParams.get('X-Amz-Date')).toBe( + '20261026T100000Z', + ); + expect(new URL(first).searchParams.get('X-Amz-Expires')).toBe( + '604800', + ); + vi.setSystemTime(signedAt + 3600_000); + expect(await read()).not.toBe(first); + } finally { + vi.useRealTimers(); + } + }); + // Thumbnails stored before keys were bound to entries keep displaying. it.each([ ['an entry-bound key', () => mintedKey()], diff --git a/extensions/thumbnails.ts b/extensions/thumbnails.ts index d2c1012c1..6c40adfa8 100644 --- a/extensions/thumbnails.ts +++ b/extensions/thumbnails.ts @@ -307,6 +307,9 @@ export const handleThumbnailRead = async ( const thumb = entry.thumbnail; if (typeof thumb !== 'string' || !thumb) return; const presignClient = deps.s3Presign; + const signingDate = new Date( + Math.floor(Date.now() / 3_600_000) * 3_600_000, + ); if (isStoragePointer(thumb, deps.bucketEndpoint)) { const key = resolveThumbnailKey(thumb); @@ -319,7 +322,7 @@ export const handleThumbnailRead = async ( entry.thumbnail = await getSignedUrl( presignClient, new GetObjectCommand({ Bucket: deps.bucketName, Key: key }), - { expiresIn: 604800 }, + { expiresIn: 604800, signingDate }, ); } else if (thumb.startsWith('data')) { // Move an inline thumbnail into storage. The row only takes the new @@ -373,7 +376,7 @@ export const handleThumbnailRead = async ( entry.thumbnail = await getSignedUrl( presignClient, new GetObjectCommand({ Bucket: deps.bucketName, Key: key }), - { expiresIn: 604800 }, + { expiresIn: 604800, signingDate }, ); } }; diff --git a/src/backend/clients/database/DatabaseClient.test.ts b/src/backend/clients/database/DatabaseClient.test.ts index 9776d8a42..a90e8f8e4 100644 --- a/src/backend/clients/database/DatabaseClient.test.ts +++ b/src/backend/clients/database/DatabaseClient.test.ts @@ -108,6 +108,25 @@ describe('AbstractDatabaseClient — unimplemented surface', () => { }); describe('AbstractDatabaseClient — replica-aware reads', () => { + it('avoids a primary read when the replica has rows in sequential mode', async () => { + const client = new FixtureDatabaseClient([{ id: 1 }], [{ id: 2 }]); + expect( + await client.tryHardRead('SELECT 1', [], { race: false }), + ).toEqual([{ id: 1 }]); + expect(client.preads).toEqual([]); + }); + + it.each([[], new Error('replica unavailable')])( + 'falls back once in sequential mode', + async (replica) => { + const client = new FixtureDatabaseClient(replica, [{ id: 2 }]); + expect( + await client.tryHardRead('SELECT 1', [], { race: false }), + ).toEqual([{ id: 2 }]); + expect(client.preads).toEqual(['SELECT 1']); + }, + ); + it('returns replica rows without waiting on the primary', async () => { const client = new FixtureDatabaseClient([{ id: 1 }], [{ id: 2 }]); await expect(client.tryHardRead('SELECT 1')).resolves.toEqual([ diff --git a/src/backend/clients/database/DatabaseClient.ts b/src/backend/clients/database/DatabaseClient.ts index d74178258..2c93ce5ef 100644 --- a/src/backend/clients/database/DatabaseClient.ts +++ b/src/backend/clients/database/DatabaseClient.ts @@ -138,25 +138,27 @@ export class AbstractDatabaseClient extends PuterClient { /** * Like `read()`, but an empty or failed replica read falls back to the - * primary, so replication lag can't hide a row that was just written. The - * primary read starts alongside the replica one rather than after it. - * Without a replica this is a single `read()`. + * primary, so replication lag can't hide a row that was just written. By + * default the primary read starts alongside the replica one; `race: false` + * reads the primary only after the replica returns empty or fails. Without + * a replica this is a single `read()`. */ @Span('db.tryHardRead', (query: string) => ({ 'db.statement': query })) async tryHardRead( query: string, params: unknown[] = [], + { race = true }: { race?: boolean } = {}, ): Promise[]> { if (!this.hasReadReplica()) return this.read(query, params); - const primary = settle(this.pread(query, params)); + const primary = race ? settle(this.pread(query, params)) : null; try { const rows = await this.replicaRead(query, params); if (rows.length > 0) return rows; } catch { // replica failed — fall through to primary } - const result = await primary; + const result = await (primary ?? settle(this.pread(query, params))); if ('error' in result) throw result.error; return result.value; } diff --git a/src/backend/clients/database/MySQLDatabaseClient.ts b/src/backend/clients/database/MySQLDatabaseClient.ts index ff95fe3ca..3f9074e57 100644 --- a/src/backend/clients/database/MySQLDatabaseClient.ts +++ b/src/backend/clients/database/MySQLDatabaseClient.ts @@ -217,7 +217,8 @@ export class MySQLDatabaseClient extends AbstractDatabaseClient { return this.configuration === Configuration.REPLICA; } - // No failover: `tryHardRead` already has a primary read in flight. + // No failover: `tryHardRead` falls back to the primary on an empty result + // or error (raced alongside by default, after the replica with `race: false`). protected override async replicaRead( query: string, params: unknown[], diff --git a/src/backend/clients/database/SqliteDatabaseClient.test.ts b/src/backend/clients/database/SqliteDatabaseClient.test.ts index a9fe08413..fb8695e1b 100644 --- a/src/backend/clients/database/SqliteDatabaseClient.test.ts +++ b/src/backend/clients/database/SqliteDatabaseClient.test.ts @@ -27,7 +27,7 @@ import { DatabaseClientFactory } from './index.js'; import { SqliteDatabaseClient } from './SqliteDatabaseClient.js'; /** Highest schema version the migration table can reach. */ -const CURRENT_SCHEMA_VERSION = 92; +const CURRENT_SCHEMA_VERSION = 93; /** * These suites migrate real files on disk. Idle they finish in well under a diff --git a/src/backend/clients/database/SqliteDatabaseClient.ts b/src/backend/clients/database/SqliteDatabaseClient.ts index b55eca84e..13dcf971f 100644 --- a/src/backend/clients/database/SqliteDatabaseClient.ts +++ b/src/backend/clients/database/SqliteDatabaseClient.ts @@ -131,6 +131,7 @@ const AVAILABLE_MIGRATIONS: [number, string[]][] = [ [89, ['0094_deleted-apps.sql']], [90, ['0095_kv-share-handle-levels.sql']], [91, ['0096_kv-share-handle-quota-indexes.sql']], + [92, ['0097_user-to-user-holder-permission-index.sql']], ]; export class SqliteDatabaseClient extends AbstractDatabaseClient { diff --git a/src/backend/clients/database/migrations/mysql/mysql_mig_51.sql b/src/backend/clients/database/migrations/mysql/mysql_mig_51.sql new file mode 100644 index 000000000..d553c0c03 --- /dev/null +++ b/src/backend/clients/database/migrations/mysql/mysql_mig_51.sql @@ -0,0 +1,38 @@ +-- Copyright (C) 2024-present Puter Technologies Inc. +-- +-- This file is part of Puter. +-- +-- Puter is free software: you can redistribute it and/or modify +-- it under the terms of the GNU Affero General Public License as published +-- by the Free Software Foundation, either version 3 of the License, or +-- (at your option) any later version. +-- +-- This program is distributed in the hope that it will be useful, +-- but WITHOUT ANY WARRANTY; without even the implied warranty of +-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +-- GNU Affero General Public License for more details. +-- +-- You should have received a copy of the GNU Affero General Public License +-- along with this program. If not, see . + +-- Lets a holder's grant check read only the permissions it names. Guarded, so +-- a replay does nothing. + +DROP PROCEDURE IF EXISTS _puter_add_u2u_holder_permission_index; +DELIMITER // +CREATE PROCEDURE _puter_add_u2u_holder_permission_index() +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'user_to_user_permissions' + AND INDEX_NAME = 'idx_user_to_user_permissions_holder_permission' + ) THEN + ALTER TABLE `user_to_user_permissions` ADD INDEX `idx_user_to_user_permissions_holder_permission` (`holder_user_id`, `permission`); + END IF; +END// +DELIMITER ; + +CALL _puter_add_u2u_holder_permission_index(); + +DROP PROCEDURE IF EXISTS _puter_add_u2u_holder_permission_index; diff --git a/src/backend/clients/database/migrations/postgres/postgres_mig_40.sql b/src/backend/clients/database/migrations/postgres/postgres_mig_40.sql new file mode 100644 index 000000000..d44256dd0 --- /dev/null +++ b/src/backend/clients/database/migrations/postgres/postgres_mig_40.sql @@ -0,0 +1,21 @@ +-- Copyright (C) 2024-present Puter Technologies Inc. +-- +-- This file is part of Puter. +-- +-- Puter is free software: you can redistribute it and/or modify +-- it under the terms of the GNU Affero General Public License as published +-- by the Free Software Foundation, either version 3 of the License, or +-- (at your option) any later version. +-- +-- This program is distributed in the hope that it will be useful, +-- but WITHOUT ANY WARRANTY; without even the implied warranty of +-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +-- GNU Affero General Public License for more details. +-- +-- You should have received a copy of the GNU Affero General Public License +-- along with this program. If not, see . + +-- Lets a holder's grant check read only the permissions it names. + +CREATE INDEX IF NOT EXISTS idx_user_to_user_permissions_holder_permission + ON user_to_user_permissions (holder_user_id, permission); diff --git a/src/backend/clients/database/migrations/sqlite/0097_user-to-user-holder-permission-index.sql b/src/backend/clients/database/migrations/sqlite/0097_user-to-user-holder-permission-index.sql new file mode 100644 index 000000000..0cc9ca90d --- /dev/null +++ b/src/backend/clients/database/migrations/sqlite/0097_user-to-user-holder-permission-index.sql @@ -0,0 +1,21 @@ +-- Copyright (C) 2024-present Puter Technologies Inc. +-- +-- This file is part of Puter. +-- +-- Puter is free software: you can redistribute it and/or modify +-- it under the terms of the GNU Affero General Public License as published +-- by the Free Software Foundation, either version 3 of the License, or +-- (at your option) any later version. +-- +-- This program is distributed in the hope that it will be useful, +-- but WITHOUT ANY WARRANTY; without even the implied warranty of +-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +-- GNU Affero General Public License for more details. +-- +-- You should have received a copy of the GNU Affero General Public License +-- along with this program. If not, see . + +-- Lets a holder's grant check read only the permissions it names. + +CREATE INDEX IF NOT EXISTS `idx_user_to_user_permissions_holder_permission` + ON `user_to_user_permissions` (`holder_user_id`, `permission`); diff --git a/src/backend/controllers/auth/AuthController.elevation.http.test.ts b/src/backend/controllers/auth/AuthController.elevation.http.test.ts new file mode 100644 index 000000000..5722d7d57 --- /dev/null +++ b/src/backend/controllers/auth/AuthController.elevation.http.test.ts @@ -0,0 +1,85 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { setupPuterTestEnv, type PuterTestEnv } from '../../testUtil.js'; + +describe('auth elevation cookies over HTTP', () => { + let env: PuterTestEnv; + + beforeAll(async () => { + env = await setupPuterTestEnv(); + }, 120_000); + + afterAll(async () => { + await env?.shutdown(); + }); + + it.each(['bearer', 'cookie'])( + 'API host returns a usable token without setting cookies (%s auth)', + async (credential) => { + const { token, password, username } = env.users.user; + const res = await fetch(new URL('/auth/elevate', env.apiOrigin), { + method: 'POST', + headers: { + 'content-type': 'application/json', + ...(credential === 'cookie' + ? { cookie: `puter_auth_token=${token}` } + : { authorization: `Bearer ${token}` }), + }, + body: JSON.stringify({ password }), + }); + expect(res.status).toBe(200); + expect(res.headers.get('set-cookie')).toBeNull(); + const body = (await res.json()) as { + elevated: boolean; + token: string; + }; + expect(body.elevated).toBe(true); + expect(typeof body.token).toBe('string'); + const user = await env.server.stores.user.getByUsername(username); + const { verifyStepUpSession } = + await import('../../core/http/middleware/stepUpSession.js'); + expect( + verifyStepUpSession( + { + headers: { 'x-puter-elevation': body.token }, + actor: { user: { uuid: user!.uuid } }, + } as never, + { tokenService: env.server.services.token }, + ), + ).toBe(true); + }, + ); + + it('GUI host sets the elevation cookie and omits the raw token for cookie auth', async () => { + const { token, password } = env.users.user; + const res = await fetch(new URL('/auth/elevate', env.origin), { + method: 'POST', + headers: { + 'content-type': 'application/json', + cookie: `puter_auth_token=${token}`, + }, + body: JSON.stringify({ password }), + }); + expect(res.status).toBe(200); + expect(res.headers.get('set-cookie')).toContain('puter_elevated='); + expect(await res.json()).toEqual({ elevated: true }); + }); +}); diff --git a/src/backend/controllers/auth/AuthController.ts b/src/backend/controllers/auth/AuthController.ts index 758c785b0..1ab6f6486 100644 --- a/src/backend/controllers/auth/AuthController.ts +++ b/src/backend/controllers/auth/AuthController.ts @@ -4275,7 +4275,27 @@ export class AuthController extends PuterController { ); } - this.#requireAccountTwoFactor(user, req.body?.code); + const code = req.body?.code; + if ( + user.otp_enabled && + typeof code === 'string' && + /^[A-Z0-9]{8}$/.test(code) + ) { + await this.#assertSecondFactorAttemptsRemain(user.uuid); + const consumed = await this.stores.user.consumeOtpRecoveryCode( + user.id, + hashRecoveryCode(code), + ); + if (!consumed) { + await this.#recordSecondFactorFailure(user.uuid); + throw new HttpError(401, 'Incorrect code.', { + legacyCode: 'code_mismatch', + fields: { factor: 'otp' }, + }); + } + } else { + this.#requireAccountTwoFactor(user, code); + } await this.stores.user.update(user.id, { otp_enabled: false, @@ -4485,11 +4505,14 @@ export class AuthController extends PuterController { } const token = signStepUpToken(this.services.token, user as never); - res.cookie( - STEP_UP_COOKIE_NAME, - token, - stepUpCookieOptions(this.config), - ); + const isApiHost = req.subdomains?.at(-1) === 'api'; + if (!isApiHost) { + res.cookie( + STEP_UP_COOKIE_NAME, + token, + stepUpCookieOptions(this.config), + ); + } // A browser reads its elevation back from the httpOnly cookie and never // needs the raw value; handing it to page JS would put the second factor @@ -4498,7 +4521,9 @@ export class AuthController extends PuterController { // password/TOTP — this only avoids needless exposure, it isn't a gate. const cookieName = this.config.cookie_name ?? 'puter_token'; const usedSessionCookie = - !!req.token && req.token === req.cookies?.[cookieName]; + !isApiHost && + !!req.token && + req.token === req.cookies?.[cookieName]; res.json( usedSessionCookie ? { elevated: true } : { elevated: true, token }, ); diff --git a/src/backend/controllers/auth/recoveryDisable2fa.http.test.ts b/src/backend/controllers/auth/recoveryDisable2fa.http.test.ts new file mode 100644 index 000000000..9c862caa1 --- /dev/null +++ b/src/backend/controllers/auth/recoveryDisable2fa.http.test.ts @@ -0,0 +1,89 @@ +import { v4 as uuidv4 } from 'uuid'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { hashRecoveryCode } from '../../services/auth/OTPUtil.js'; +import { + createTestUser, + setupPuterTestEnv, + type PuterTestEnv, +} from '../../testUtil.js'; + +describe('2FA after signing in with a recovery code', () => { + let env: PuterTestEnv; + const password = 'lost-authenticator-password'; + + beforeAll(async () => { + env = await setupPuterTestEnv(); + }, 120_000); + afterAll(async () => { + await env?.shutdown(); + }); + + const post = ( + path: string, + body: Record, + cookie?: string, + ) => + fetch(new URL(path, env.origin), { + method: 'POST', + headers: { + 'content-type': 'application/json', + ...(cookie ? { cookie } : {}), + }, + body: JSON.stringify(body), + }); + + it('allows an unused recovery code to disable 2FA after password revalidation', async () => { + const username = `lost2fa_${uuidv4().slice(0, 8)}`; + await createTestUser(env.server, { username, password }); + const user = (await env.server.stores.user.getByUsername(username))!; + await env.server.stores.user.update(user.id, { + email: `${username}@example.com`, + email_confirmed: true, + otp_enabled: true, + otp_secret: 'TESTSECRETBASE32', + otp_recovery_codes: ['RECOVER1', 'RECOVER2', 'RECOVER3'] + .map(hashRecoveryCode) + .join(','), + }); + + // Sign in with a recovery code: works, and yields a web session. + const login = await post('/login', { username, password }); + expect(login.status).toBe(202); + const { otp_jwt_token } = await login.json(); + const recovered = await post('/login/recovery-code', { + token: otp_jwt_token, + code: 'RECOVER1', + }); + expect((await recovered.json()).next_step).toBe('complete'); + const cookie = recovered.headers + .getSetCookie() + .find((value) => value.startsWith('puter_auth_token='))! + .split(';')[0]!; + + const reused = await post( + '/user-protected/disable-2fa', + { password, code: 'RECOVER1' }, + cookie, + ); + expect(reused.status).toBe(401); + expect( + (await env.server.stores.user.getById(user.id, { force: true }))! + .otp_enabled, + ).toBeTruthy(); + + const disable = await post( + '/user-protected/disable-2fa', + { password, code: 'RECOVER2' }, + cookie, + ); + expect(disable.status).toBe(200); + expect(await disable.json()).toMatchObject({ success: true }); + + const after = await env.server.stores.user.getById(user.id, { + force: true, + }); + expect(after!.otp_enabled).toBeFalsy(); + expect(after!.otp_secret).toBeNull(); + expect(after!.otp_recovery_codes).toBeNull(); + }); +}); diff --git a/src/backend/controllers/fs/FSController.recursiveOffset.http.test.ts b/src/backend/controllers/fs/FSController.recursiveOffset.http.test.ts new file mode 100644 index 000000000..2021203d0 --- /dev/null +++ b/src/backend/controllers/fs/FSController.recursiveOffset.http.test.ts @@ -0,0 +1,80 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { setupPuterTestEnv, type PuterTestEnv } from '../../testUtil.js'; + +describe('recursive readdir with offset', () => { + let env: PuterTestEnv; + + beforeAll(async () => { + env = await setupPuterTestEnv(); + }, 120_000); + + afterAll(async () => { + await env?.shutdown(); + }); + + const post = async (path: string, token: string, body: unknown) => + fetch(new URL(path, env.apiOrigin), { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify(body), + }); + + it('skips entries before applying the recursive page limit', async () => { + const { username, token } = env.users.user; + const base = `/${username}/Documents/rec-offset`; + for (const path of [base, `${base}/a`, `${base}/b`, `${base}/c`]) { + expect((await post('/fs/mkdir', token, { path })).status).toBe(200); + } + + const list = async (body: Record) => { + const response = await post('/fs/readdir', token, { + path: base, + recursive: true, + ...body, + }); + expect(response.status).toBe(200); + const page = (await response.json()) as { + items: Array<{ name: string }>; + }; + return page.items.map((item) => item.name); + }; + + expect(await list({ limit: 3 })).toEqual(['a', 'b', 'c']); + // Non-recursive honours offset. + const flat = await post('/fs/readdir', token, { + path: base, + limit: 1, + offset: 2, + }); + expect( + ((await flat.json()) as Array<{ name: string }>).map((e) => e.name), + ).toEqual(['c']); + expect(await list({ limit: 1, offset: 2 })).toEqual(['c']); + expect(await list({ offset: 2 })).toEqual(['c']); + expect(await list({ offset: 20 })).toEqual([]); + expect(await list({ limit: 1, offset: 1, sortOrder: 'desc' })).toEqual([ + 'b', + ]); + const first = await post('/fs/readdir', token, { + path: base, + recursive: true, + limit: 1, + offset: 1, + }); + const page = await first.json(); + expect(page.items.map((item: { name: string }) => item.name)).toEqual([ + 'b', + ]); + expect(page.cursor).toBeTypeOf('string'); + expect(await list({ limit: 1, cursor: page.cursor })).toEqual(['c']); + // A cursor wins over offset, and an invalid offset is ignored, as + // recursive listings did before they honoured offset. + expect( + await list({ limit: 1, cursor: page.cursor, offset: 1 }), + ).toEqual(['c']); + expect(await list({ limit: 1, offset: -1 })).toEqual(['a']); + }); +}); diff --git a/src/backend/controllers/fs/FSController.ts b/src/backend/controllers/fs/FSController.ts index d39462010..e886684f4 100644 --- a/src/backend/controllers/fs/FSController.ts +++ b/src/backend/controllers/fs/FSController.ts @@ -1335,6 +1335,7 @@ export class FSController extends PuterController { parent.path, { limit, + offset, cursor: typeof body.cursor === 'string' ? body.cursor diff --git a/src/backend/controllers/fs/LegacyFSController.appAcl.http.test.ts b/src/backend/controllers/fs/LegacyFSController.appAcl.http.test.ts new file mode 100644 index 000000000..4d39b68c9 --- /dev/null +++ b/src/backend/controllers/fs/LegacyFSController.appAcl.http.test.ts @@ -0,0 +1,114 @@ +import { v4 as uuidv4 } from 'uuid'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { makeActor } from '../../core/actor.js'; +import { setupPuterTestEnv, type PuterTestEnv } from '../../testUtil.js'; + +describe('/auth/check-app-acl', () => { + let env: PuterTestEnv; + beforeAll(async () => { + env = await setupPuterTestEnv(); + }, 120_000); + afterAll(async () => { + await env?.shutdown(); + }); + + const post = (path: string, body: unknown, token: string) => + fetch(new URL(path, env.apiOrigin), { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify(body), + }); + + const makeDirAndApp = async () => { + const { username, token } = env.users.user; + const server = env.server; + const user = (await server.stores.user.getByUsername(username))!; + const path = `/${username}/Documents/cacl-${uuidv4()}`; + expect((await post('/fs/mkdir', { path }, token)).status).toBe(200); + const entry = (await server.stores.fsEntry.getEntryByPath(path))!; + + const app = await ( + server.stores.app.create as unknown as ( + f: Record, + o: { ownerUserId: number }, + ) => Promise<{ uid: string; id: number }> + )( + { + name: `cacl-${uuidv4()}`, + title: 'Check app acl', + index_url: `https://cacl-${uuidv4()}.test/`, + }, + { ownerUserId: user.id }, + ); + return { user, path, entry, app }; + }; + + it('allows a granted file without poisoning the app permission cache', async () => { + const { token } = env.users.user; + const server = env.server; + const { user, path, entry, app } = await makeDirAndApp(); + + const grant = await post( + '/auth/grant-user-app', + { app_uid: app.uid, permission: `fs:${entry.uuid}:read` }, + token, + ); + expect(grant.status).toBe(200); + + const res = await post( + '/auth/check-app-acl', + { subject: { path }, app: app.uid, mode: 'read' }, + token, + ); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ allowed: true }); + + const asApp = makeActor({ user, app: { uid: app.uid, id: app.id } }); + const descriptor = () => server.services.acl.fsDescriptor(path); + expect( + await server.services.acl.check(asApp, descriptor(), 'read'), + ).toBe(true); + }); + + it('denies an app with no grant', async () => { + const { path, app } = await makeDirAndApp(); + const res = await post( + '/auth/check-app-acl', + { subject: { path }, app: app.uid, mode: 'read' }, + env.users.user.token, + ); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ allowed: false }); + }); + + it('refuses app and access tokens', async () => { + const { token, apiToken } = env.users.user; + const { path, entry, app } = await makeDirAndApp(); + const grant = await post( + '/auth/grant-user-app', + { app_uid: app.uid, permission: `fs:${entry.uuid}:read` }, + token, + ); + expect(grant.status).toBe(200); + + const minted = await post( + '/auth/get-user-app-token', + { app_uid: app.uid }, + token, + ); + expect(minted.status).toBe(200); + const appToken = ((await minted.json()) as { token: string }).token; + + for (const credential of [appToken, apiToken]) { + const res = await post( + '/auth/check-app-acl', + { subject: { path }, app: app.uid, mode: 'read' }, + credential, + ); + expect(res.status).toBe(403); + } + }); +}); diff --git a/src/backend/controllers/fs/LegacyFSController.copyAndListing.http.test.ts b/src/backend/controllers/fs/LegacyFSController.copyAndListing.http.test.ts new file mode 100644 index 000000000..b317a4217 --- /dev/null +++ b/src/backend/controllers/fs/LegacyFSController.copyAndListing.http.test.ts @@ -0,0 +1,204 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { randomUUID } from 'node:crypto'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { setupPuterTestEnv, type PuterTestEnv } from '../../testUtil.js'; + +let env: PuterTestEnv; + +beforeAll(async () => { + env = await setupPuterTestEnv(); +}, 120_000); + +afterAll(async () => { + await env?.shutdown(); +}); + +const post = (path: string, body: unknown) => + fetch(new URL(path, env.apiOrigin), { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${env.users.user.token}`, + }, + body: JSON.stringify(body), + }); + +const write = (path: string, content: string) => + post('/fs/write', { + fileMetadata: { path, size: content.length, contentType: 'text/plain' }, + fileContent: content, + }); + +const read = async (path: string) => { + const url = new URL('/read', env.apiOrigin); + url.searchParams.set('file', path); + const response = await fetch(url, { + headers: { Authorization: `Bearer ${env.users.user.token}` }, + }); + return response.text(); +}; + +const makeDir = async (name: string) => { + const path = `/${env.users.user.username}/Documents/${name}-${randomUUID().slice(0, 8)}`; + expect((await post('/fs/mkdir', { path })).status).toBe(200); + return path; +}; + +describe('legacy /copy onto an existing file', () => { + it('replaces the file with overwrite', async () => { + const dir = await makeDir('copy-onto'); + expect((await write(`${dir}/a.txt`, 'aaa')).status).toBe(200); + expect((await write(`${dir}/b.txt`, 'bbbbbb')).status).toBe(200); + + const copy = await post('/copy', { + source: `${dir}/a.txt`, + destination: `${dir}/b.txt`, + overwrite: true, + }); + expect(copy.status).toBe(200); + const [result] = (await copy.json()) as Array<{ + copied: { name: string; path: string }; + overwritten?: { name: string }; + }>; + expect(result!.copied.name).toBe('b.txt'); + expect(result!.overwritten?.name).toBe('b.txt'); + expect(await read(`${dir}/b.txt`)).toBe('aaa'); + expect(await read(`${dir}/a.txt`)).toBe('aaa'); + }); + + it('refuses without overwrite and leaves the file alone', async () => { + const dir = await makeDir('copy-onto-refused'); + await write(`${dir}/a.txt`, 'aaa'); + await write(`${dir}/b.txt`, 'bbbbbb'); + + const copy = await post('/copy', { + source: `${dir}/a.txt`, + destination: `${dir}/b.txt`, + }); + expect(copy.status).toBe(409); + expect(((await copy.json()) as { code: string }).code).toBe( + 'item_with_same_name_exists', + ); + expect(await read(`${dir}/b.txt`)).toBe('bbbbbb'); + }); + + it('still copies into a directory destination under the same name', async () => { + const dir = await makeDir('copy-into'); + await write(`${dir}/a.txt`, 'aaa'); + expect((await post('/fs/mkdir', { path: `${dir}/sub` })).status).toBe( + 200, + ); + + const copy = await post('/copy', { + source: `${dir}/a.txt`, + destination: `${dir}/sub`, + }); + expect(copy.status).toBe(200); + expect(await read(`${dir}/sub/a.txt`)).toBe('aaa'); + }); + + it('refuses to overwrite a file with itself', async () => { + const dir = await makeDir('copy-self'); + await write(`${dir}/a.txt`, 'aaa'); + + for (const destination of [`${dir}/a.txt`, dir]) { + const copy = await post('/copy', { + source: `${dir}/a.txt`, + destination, + overwrite: true, + }); + expect(copy.status).toBe(400); + } + expect(await read(`${dir}/a.txt`)).toBe('aaa'); + }); +}); + +describe('signed folder listing', () => { + const signedListing = async (path: string) => { + const signed = await post('/sign', { + items: [{ path, action: 'read' }], + }); + expect(signed.status).toBe(200); + const readUrl = new URL( + (await signed.json()).signatures[0].read_url as string, + ); + return fetch( + new URL(`${readUrl.pathname}${readUrl.search}`, env.apiOrigin), + ); + }; + + it('lists every child of a folder larger than one query', async () => { + const dir = await makeDir('big'); + const parent = (await env.server.stores.fsEntry.getEntryByPath(dir))!; + const total = 10_050; + const now = Math.floor(Date.now() / 1000); + for (let start = 0; start < total; start += 500) { + const rows: unknown[][] = []; + for (let i = start; i < Math.min(total, start + 500); i++) { + const name = `f${String(i).padStart(5, '0')}.txt`; + rows.push([ + randomUUID(), + parent.userId, + parent.id, + parent.uuid, + name, + `${dir}/${name}`, + 0, + now, + now, + now, + 0, + ]); + } + await env.server.clients.db.write( + `INSERT INTO fsentries (uuid, user_id, parent_id, parent_uid, name, path, is_dir, modified, created, accessed, size) VALUES ${rows.map(() => '(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)').join(', ')}`, + rows.flat(), + ); + } + + const response = await signedListing(dir); + expect(response.status).toBe(200); + expect(response.headers.get('content-type')).toMatch( + /^application\/json/, + ); + const children = (await response.json()) as Array<{ + fsentry_name: string; + write_url?: string; + }>; + expect(children).toHaveLength(total); + expect(children[0]!.fsentry_name).toBe('f00000.txt'); + expect(children.at(-1)!.fsentry_name).toBe('f10049.txt'); + expect(new Set(children.map((c) => c.fsentry_name)).size).toBe(total); + expect(children.some((c) => c.write_url)).toBe(false); + }, 120_000); + + it('answers a small folder in one body', async () => { + const dir = await makeDir('small'); + await write(`${dir}/one.txt`, '1'); + const response = await signedListing(dir); + expect(response.status).toBe(200); + expect(response.headers.get('content-length')).not.toBeNull(); + const children = (await response.json()) as Array<{ + fsentry_name: string; + }>; + expect(children.map((c) => c.fsentry_name)).toEqual(['one.txt']); + }); +}); diff --git a/src/backend/controllers/fs/LegacyFSController.http.test.ts b/src/backend/controllers/fs/LegacyFSController.http.test.ts index 02ba5e45a..01f6d3c8f 100644 --- a/src/backend/controllers/fs/LegacyFSController.http.test.ts +++ b/src/backend/controllers/fs/LegacyFSController.http.test.ts @@ -153,6 +153,34 @@ describe('legacy file downloads over HTTP', () => { }, ); + it.each(routes)( + 'GET %s answers a ranged read of an empty file with an empty 200', + async (route) => { + const file = await writeFile( + `empty-${route.replace(/\W/g, '')}.bin`, + Buffer.alloc(0), + ); + const ranged = await requestFor(route, file, { + headers: { Range: 'bytes=0-511' }, + }); + expect(ranged.status).toBe(200); + expect(await ranged.text()).toBe(''); + }, + ); + + it('GET /read answers byte_count on an empty file with an empty 200', async () => { + const file = await writeFile('empty-byte-count.bin', Buffer.alloc(0)); + for (const query of ['byte_count=512', 'offset=0']) { + const url = new URL(`/read?${query}`, env.apiOrigin); + url.searchParams.set('file', file.path); + const response = await fetch(url, { + headers: { Authorization: `Bearer ${env.users.user.token}` }, + }); + expect(response.status).toBe(200); + expect(await response.text()).toBe(''); + } + }); + // Storage hands over one chunk and then goes quiet, as a large object // does while the client is still reading. const stallReadsOf = (objectKey: string) => { diff --git a/src/backend/controllers/fs/LegacyFSController.launchIcons.test.ts b/src/backend/controllers/fs/LegacyFSController.launchIcons.test.ts new file mode 100644 index 000000000..4d02afacf --- /dev/null +++ b/src/backend/controllers/fs/LegacyFSController.launchIcons.test.ts @@ -0,0 +1,160 @@ +import type { Request, RequestHandler, Response } from 'express'; +import { v4 as uuidv4 } from 'uuid'; +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import type { Actor } from '../../core/actor.js'; +import { runWithContext } from '../../core/context.js'; +import { PuterRouter } from '../../core/http/PuterRouter.js'; +import { PuterServer } from '../../server.js'; +import { setupTestServer } from '../../testUtil.js'; +import type { LegacyFSController } from './LegacyFSController.js'; + +const PNG_2X2 = + 'iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAYAAABytg0kAAAACXBIWXMAAAPoAAAD6AG1e1JrAAAAEUlEQVR4nGP4z8DwH4QZYAwAR8oH+WdZbrcAAAAASUVORK5CYII='; + +let server: PuterServer; +let handler: RequestHandler; +type Driver = { + create: (a: Record) => Promise>; +}; + +beforeAll(async () => { + server = await setupTestServer({ + api_base_url: 'http://api.test.local', + static_hosting_domain: 'site.test', + } as unknown as Parameters[0]); + await server.services.appIcon.ensureIconsDirectory(); + const controller = server.controllers + .legacyFs as unknown as LegacyFSController; + const router = new PuterRouter(); + controller.registerRoutes(router); + handler = router.routes.find( + (r) => + r.path === '/get-launch-apps' && + String(r.method).toLowerCase() === 'get', + )!.handler as RequestHandler; +}, 60_000); + +afterAll(async () => { + await server?.shutdown(); +}); + +const makeActor = async (): Promise<{ actor: Actor; id: number }> => { + const username = `v13-${Math.random().toString(36).slice(2, 10)}`; + const u = await server.stores.user.create({ + username, + uuid: uuidv4(), + password: null, + email: `${username}@test.local`, + free_storage: 100 * 1024 * 1024, + requires_email_confirmation: false, + }); + const r = (await server.stores.user.getById(u.id))!; + return { + id: r.id, + actor: { + user: { + id: r.id, + uuid: r.uuid, + username: r.username, + email: r.email ?? null, + email_confirmed: true, + }, + } as unknown as Actor, + }; +}; + +const createViaDriver = async (actor: Actor, icon: string) => + runWithContext({ actor }, () => + (server.drivers.apps as unknown as Driver).create({ + object: { + name: `v13-${Math.random().toString(36).slice(2, 10)}`, + title: 'V', + index_url: `https://v13-${Math.random().toString(36).slice(2, 10)}.test/`, + icon, + }, + }), + ); + +const launchApps = async (actor: Actor, appUid: string, userId: number) => { + await server.clients.db.write( + 'INSERT INTO `app_opens` (`app_uid`, `user_id`, `ts`) VALUES (?, ?, ?)', + [appUid, userId, Math.floor(Date.now() / 1000)], + ); + let body: { recent: Array> } | undefined; + const res = { + json: (v: unknown) => { + body = v as typeof body; + return res; + }, + status: () => res, + setHeader: () => res, + } as unknown as Response; + await handler( + { + query: { icon_size: '64' }, + headers: {}, + body: {}, + actor, + } as unknown as Request, + res, + () => {}, + ); + return body!.recent.find((e) => e.uuid === appUid)!; +}; + +describe('/get-launch-apps recent icon, verified', () => { + it('a valid data-URL icon written through the driver is migrated to a short URL', async () => { + const { actor, id } = await makeActor(); + const app = await createViaDriver( + actor, + `data:image/png;base64,${PNG_2X2}`, + ); + await vi.waitFor( + async () => { + const row = await server.stores.app.getByUid(app.uid as string); + expect(String(row?.icon).slice(0, 80)).toMatch( + /^https?:\/\/.*\/app-icon\/app-/, + ); + }, + { timeout: 10_000 }, + ); + const entry = await launchApps(actor, app.uid as string, id); + expect(String(entry.icon).length).toBeLessThan(200); + expect(entry.iconCdnUrl).toMatch(/-64\.png$/); + }, 30_000); + + it('returns an icon endpoint when migration cannot decode the stored icon', async () => { + const { actor, id } = await makeActor(); + // PNG signature passes the write-path sniff; sharp rejects the rest. + const corrupt = Buffer.concat([ + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), + Buffer.alloc(1024 * 1024, 0x41), + ]).toString('base64'); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + let app: Record; + try { + app = await createViaDriver( + actor, + `data:image/png;base64,${corrupt}`, + ); + // Give the background pipeline time to try and fail. + await vi.waitFor( + () => + expect( + warn.mock.calls.some( + ([m]) => m === '[app-icon] icon processing failed', + ), + ).toBe(true), + { timeout: 10_000 }, + ); + } finally { + warn.mockRestore(); + } + const row = await server.stores.app.getByUid(app!.uid as string); + expect(String(row?.icon).startsWith('data:image/png')).toBe(true); + const entry = await launchApps(actor, app!.uid as string, id); + expect(entry.icon).toBe( + `http://api.test.local/app-icon/${app!.uid}/64`, + ); + }, 30_000); +}); diff --git a/src/backend/controllers/fs/LegacyFSController.test.ts b/src/backend/controllers/fs/LegacyFSController.test.ts index a358398e1..aa03661e6 100644 --- a/src/backend/controllers/fs/LegacyFSController.test.ts +++ b/src/backend/controllers/fs/LegacyFSController.test.ts @@ -5293,4 +5293,39 @@ describe('LegacyFSController GET /get-launch-apps', () => { expect(returned).toEqual(recentUids); }); + + it('skips the recent opens for recent=0 and keeps recommended', async () => { + const { actor, userId } = await makeUser(); + const app = await makeHostedApp(userId, 'unregistered-skip'); + await recordOpen(userId, app.uid); + + const call = async (query: Record) => { + const { res, captured } = makeRes(); + await withActor(actor, async () => { + await getLaunchAppsHandler()( + makeReq({ actor, query }), + res, + () => { + throw new Error('handler called next() unexpectedly'); + }, + ); + }); + return captured.body as { + recommended: unknown[]; + recent: unknown[]; + }; + }; + const full = await call({}); + expect(full.recent.length).toBeGreaterThan(0); + + const opens = vi.spyOn(server.stores.app, 'getRecentAppOpens'); + try { + const skipped = await call({ recent: '0' }); + expect(skipped.recent).toEqual([]); + expect(skipped.recommended).toEqual(full.recommended); + expect(opens).not.toHaveBeenCalled(); + } finally { + opens.mockRestore(); + } + }); }); diff --git a/src/backend/controllers/fs/LegacyFSController.ts b/src/backend/controllers/fs/LegacyFSController.ts index beeb68a7a..32201fe5e 100644 --- a/src/backend/controllers/fs/LegacyFSController.ts +++ b/src/backend/controllers/fs/LegacyFSController.ts @@ -26,6 +26,7 @@ import { assertResolvedActor, isAccessTokenActor, isAppActor, + isPlainUserActor, makeActor, } from '../../core/actor.js'; import { Context } from '../../core/context.js'; @@ -95,6 +96,7 @@ import { assertAccess, assertCanCreate, assertCanMoveInto, + contextBoundStream, emitGuiItemEvent, expandClientPath, findOverwriteTarget, @@ -102,6 +104,7 @@ import { getString, type GuiItemEventName, loadLegacyAssociatedApps, + rangeForEntry, replacedByMove, resolveReadRange, resolveV1Selector, @@ -381,7 +384,9 @@ export class LegacyFSController extends PuterController { let recent: unknown[] = []; const userId = req.actor?.user?.id; - if (userId) { + // `recent=0`: a caller that only shows `recommended`. + const wantsRecent = req.query.recent !== '0'; + if (userId && wantsRecent) { const recentUids = await this.stores.app.getRecentAppOpens( userId, { limit: 10 }, @@ -418,9 +423,9 @@ export class LegacyFSController extends PuterController { return { ...toAppSummary(app, { config: this.config, + apiBaseUrl: this.config.api_base_url, iconSize, }), - icon: app.icon ?? null, index_url: backingGone ? null : app.index_url, ...(backingGone ? { privateAccess: buildHostedBackingDenial() } @@ -801,10 +806,23 @@ export class LegacyFSController extends PuterController { this.stores.fsEntry, body.source, ); - const destinationParent = await resolveV1Selector( + const destination = await resolveV1Selector( this.stores.fsEntry, body.destination, ); + // A file destination names the copy itself, replaced only with + // `overwrite`. + let newName = getString(body, 'new_name'); + let destinationParent = destination; + if (!destination.isDir && !newName && destination.parentUid) { + const parent = await this.stores.fsEntry.getEntryByUuid( + destination.parentUid, + ); + if (parent) { + destinationParent = parent; + newName = destination.name; + } + } await assertAccess( this.services.acl, @@ -829,14 +847,14 @@ export class LegacyFSController extends PuterController { ? await findOverwriteTarget( this.stores.fsEntry, destinationParent, - getString(body, 'new_name') ?? source.name, + newName ?? source.name, ) : null; const copy = await this.services.fs.copy(userId, { source, destinationParent, - newName: getString(body, 'new_name'), + newName, overwrite: overwriteRequested, dedupeName: getBoolean(body, 'dedupe_name', 'change_name') ?? false, }); @@ -1289,7 +1307,10 @@ export class LegacyFSController extends PuterController { }); } - const range = resolveReadRange(req.headers.range, query); + const range = rangeForEntry( + entry, + resolveReadRange(req.headers.range, query), + ); const download = await this.services.fs.readContent(entry, { range, }); @@ -1790,19 +1811,50 @@ export class LegacyFSController extends PuterController { // owner, so the layout it reveals is theirs to reveal, and the child // signatures expire; the paths are the only part that outlives them. if (entry.isDir) { - const children = await this.services.fs.listDirectory(entry.uuid); - const signedChildren = children.map((child) => { + const signChild = (child: FSEntry) => { const { write_url: _, ...rest } = signEntry(child, signingCfg, { ttlSeconds: NON_OWNER_SIGNATURE_TTL_SECONDS, }); return { ...rest, path: maskEntryPath(child) }; - }); - res.json(signedChildren); + }; + const pages = this.services.fs.listDirectoryPages(entry.uuid); + const first = await pages.next(); + const firstPage = first.done ? [] : first.value; + const second = await pages.next(); + if (second.done) { + res.json(firstPage.map(signChild)); + return; + } + + // Past one page the listing streams, so a large folder isn't held + // in memory whole. A failure partway cuts the response off. + const secondPage = second.value; + const serialize = (child: FSEntry) => + JSON.stringify(signChild(child)); + async function* allPages() { + yield firstPage; + yield secondPage; + yield* pages; + } + async function* body() { + let separator = '['; + for await (const page of allPages()) { + if (page.length === 0) continue; + yield separator + page.map(serialize).join(','); + separator = ','; + } + yield separator === '[' ? '[]' : ']'; + } + res.type('application/json'); + await pipeToResponse(contextBoundStream(body()), res); return; } // File: stream bytes with Range support. - const range = resolveReadRange(req.headers.range, query); + const range = rangeForEntry( + entry, + resolveReadRange(req.headers.range, query), + ); const download = await this.services.fs.readContent(entry, { range, }); @@ -1992,6 +2044,11 @@ export class LegacyFSController extends PuterController { */ checkAppAcl = async (req: Request, res: Response): Promise => { const actor = this.#requireActor(req); + // Sessions only: for an app, this would be a window onto its neighbours' grants. + if (!isPlainUserActor(actor)) + throw new HttpError(403, 'actor must be a user', { + legacyCode: 'forbidden', + }); const body = asRecord(req.body); const subjectRef = body.subject; @@ -2022,7 +2079,7 @@ export class LegacyFSController extends PuterController { legacyCode: 'subject_does_not_exist', }); - let app: { uid: string } | null = null; + let app: { uid: string; id: number } | null = null; if (typeof appRef === 'string') { app = (await this.stores.app.getByUid(appRef)) ?? @@ -2036,7 +2093,7 @@ export class LegacyFSController extends PuterController { // Build an actor-under-user shape for the check. const actorForApp = makeActor({ user: actor.user, - app: { uid: (app as { uid: string }).uid }, + app: { uid: app.uid, id: app.id }, }); const allowed = await acl.check(actorForApp, descriptor, mode); res.json({ allowed }); @@ -2084,10 +2141,12 @@ export class LegacyFSController extends PuterController { // by default, so opt in by naming the downloader as who pays. req.egressActor = actor; - const range = + const range = rangeForEntry( + entry, typeof req.headers.range === 'string' ? req.headers.range - : undefined; + : undefined, + ); const download = await this.services.fs.readContent(entry, { range, }); diff --git a/src/backend/controllers/fs/legacyFsHelpers.test.ts b/src/backend/controllers/fs/legacyFsHelpers.test.ts index 4ff613b10..8e8522d37 100644 --- a/src/backend/controllers/fs/legacyFsHelpers.test.ts +++ b/src/backend/controllers/fs/legacyFsHelpers.test.ts @@ -17,15 +17,19 @@ * along with this program. If not, see . */ +import { Writable } from 'node:stream'; import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import { Context, runWithContext } from '../../core/context.js'; import type { PuterServer } from '../../server.js'; import { createTestUser, setupTestServer } from '../../testUtil.js'; import { asRecord, + contextBoundStream, fsEntryMimeType, getBoolean, getString, loadLegacyAssociatedApps, + rangeForEntry, resolveReadRange, signEntry, signEntryThumbnail, @@ -705,3 +709,50 @@ describe('resolveReadRange', () => { ).toThrow('Invalid byte_count'); }); }); + +describe('rangeForEntry', () => { + it('drops the range for an empty file only', () => { + const entry = (size: number) => ({ size }) as FSEntry; + expect(rangeForEntry(entry(0), 'bytes=0-511')).toBeUndefined(); + expect(rangeForEntry(entry(10), 'bytes=0-4')).toBe('bytes=0-4'); + }); +}); + +describe('contextBoundStream', () => { + it("reads every chunk in the caller's context", async () => { + // A sink slower than the source that completes writes from outside + // any request, so later chunks are asked for from its drain. + const pending: Array<() => void> = []; + const flush = runWithContext({}, () => + setInterval(() => pending.splice(0).forEach((done) => done()), 5), + ); + const sink = new Writable({ + highWaterMark: 1, + write(_chunk, _encoding, done) { + pending.push(done); + }, + }); + const seen: unknown[] = []; + try { + await runWithContext({ actor: 'caller' as never }, async () => { + async function* chunks() { + for (let i = 0; i < 40; i++) { + await new Promise((resolve) => setTimeout(resolve, 1)); + seen.push(Context.get('actor')); + yield 'x'; + } + } + await new Promise((resolve, reject) => { + contextBoundStream(chunks()) + .pipe(sink) + .on('finish', resolve) + .on('error', reject); + }); + }); + } finally { + clearInterval(flush); + } + expect(seen).toHaveLength(40); + expect(seen.every((actor) => actor === 'caller')).toBe(true); + }); +}); diff --git a/src/backend/controllers/fs/legacyFsHelpers.ts b/src/backend/controllers/fs/legacyFsHelpers.ts index 4e93810ea..c9353458c 100644 --- a/src/backend/controllers/fs/legacyFsHelpers.ts +++ b/src/backend/controllers/fs/legacyFsHelpers.ts @@ -17,7 +17,9 @@ * along with this program. If not, see . */ +import { AsyncResource } from 'node:async_hooks'; import { posix as pathPosix } from 'node:path'; +import { Readable } from 'node:stream'; import { contentType as contentTypeFromMime } from 'mime-types'; import type { FSEntry } from '../../stores/fs/FSEntry.js'; import type { FSEntryStore } from '../../stores/fs/FSEntryStore.js'; @@ -150,6 +152,29 @@ export function resolveReadRange( return undefined; } +/** + * A response body read from `chunks` for `pipeToResponse`. Each chunk is pulled + * in the caller's async context, which share masking reads, not in whichever + * socket event asked for more. + */ +export function contextBoundStream( + chunks: AsyncGenerator, +): Readable { + const next = AsyncResource.bind(() => chunks.next()); + const stop = AsyncResource.bind(() => chunks.return(undefined)); + return Readable.from({ + [Symbol.asyncIterator]: () => ({ next, return: stop }), + }); +} + +/** No range is satisfiable on an empty file, so it reads whole: empty, 200. */ +export function rangeForEntry( + entry: FSEntry, + range: string | undefined, +): string | undefined { + return entry.size === 0 ? undefined : range; +} + // Accepts either `{ path }` or `{ uid }` or `{ id }` or `{ parent, name }` // from a legacy body field. Returns a resolved entry (throwing 404 if not // found or 400 if no usable ref is present). diff --git a/src/backend/controllers/webdav/WebDAVController.largeFolder.http.test.ts b/src/backend/controllers/webdav/WebDAVController.largeFolder.http.test.ts new file mode 100644 index 000000000..e240685c0 --- /dev/null +++ b/src/backend/controllers/webdav/WebDAVController.largeFolder.http.test.ts @@ -0,0 +1,146 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { randomUUID } from 'node:crypto'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { runWithContext } from '../../core/context.js'; +import { + setupPuterTestEnv, + TEST_OTHER_USER_CREDENTIALS, + TEST_USER_CREDENTIALS, + type PuterTestEnv, +} from '../../testUtil.js'; + +/** PROPFIND has no paging, so a folder must list whole however large. */ +describe('PROPFIND on a folder larger than one query', () => { + let env: PuterTestEnv; + const total = 10_050; + let dir: string; + let dirUuid: string; + + beforeAll(async () => { + env = await setupPuterTestEnv(); + const { username } = env.users.user; + dir = `/${username}/Documents/big-${randomUUID().slice(0, 8)}`; + const owner = (await env.server.stores.user.getByUsername(username))!; + const parent = (await env.server.stores.fsEntry.getEntryByPath( + `/${username}/Documents`, + ))!; + dirUuid = randomUUID(); + const now = Math.floor(Date.now() / 1000); + const insert = (rows: unknown[][]) => + env.server.clients.db.write( + `INSERT INTO fsentries (uuid, user_id, parent_id, parent_uid, name, path, is_dir, modified, created, accessed, size) VALUES ${rows.map(() => '(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)').join(', ')}`, + rows.flat(), + ); + await insert([ + [ + dirUuid, + owner.id, + parent.id, + parent.uuid, + dir.split('/').at(-1), + dir, + 1, + now, + now, + now, + 0, + ], + ]); + const folder = (await env.server.stores.fsEntry.getEntryByPath(dir))!; + for (let start = 0; start < total; start += 500) { + const rows: unknown[][] = []; + for (let i = start; i < Math.min(total, start + 500); i++) { + const name = `f${String(i).padStart(5, '0')}.txt`; + rows.push([ + randomUUID(), + owner.id, + folder.id, + folder.uuid, + name, + `${dir}/${name}`, + 0, + now, + now, + now, + 0, + ]); + } + await insert(rows); + } + + const actor = { user: owner }; + await runWithContext({ actor: actor as never }, () => + env.server.services.share.share(actor as never, { + uid: dirUuid, + recipient: { username: env.users.other.username }, + mode: 'read', + }), + ); + }, 180_000); + + afterAll(async () => { + await env?.shutdown(); + }); + + const propfind = async ( + path: string, + credentials: { username: string; password: string }, + ) => { + const port = new URL(env.apiOrigin).port; + const auth = Buffer.from( + `${credentials.username}:${credentials.password}`, + ).toString('base64'); + const response = await fetch( + `http://dav.puter.localhost:${port}${encodeURI(path)}`, + { + method: 'PROPFIND', + headers: { Authorization: `Basic ${auth}`, Depth: '1' }, + }, + ); + return { status: response.status, body: await response.text() }; + }; + + const hrefsOf = (xml: string) => + [...xml.matchAll(/([^<]*)<\/D:href>/g)].map((m) => m[1]!); + + it('lists every child', async () => { + const { status, body } = await propfind(dir, TEST_USER_CREDENTIALS); + expect(status).toBe(207); + expect(body.endsWith('')).toBe(true); + const hrefs = hrefsOf(body); + expect(hrefs).toHaveLength(1 + total); + expect(new Set(hrefs).size).toBe(1 + total); + expect(body).toContain(`${dir}/f10049.txt`); + }, 60_000); + + it("masks every page of a share recipient's listing", async () => { + const masked = `/${env.users.user.username}/${dirUuid}/${dir.split('/').at(-1)}`; + const { status, body } = await propfind( + masked, + TEST_OTHER_USER_CREDENTIALS, + ); + expect(status).toBe(207); + const hrefs = hrefsOf(body); + expect(hrefs).toHaveLength(1 + total); + expect(hrefs.every((href) => href.startsWith(masked))).toBe(true); + expect(body).not.toContain('/Documents/'); + }, 60_000); +}); diff --git a/src/backend/controllers/webdav/WebDAVController.ts b/src/backend/controllers/webdav/WebDAVController.ts index 761c66740..719dda5d6 100644 --- a/src/backend/controllers/webdav/WebDAVController.ts +++ b/src/backend/controllers/webdav/WebDAVController.ts @@ -24,6 +24,7 @@ import { posix as pathPosix } from 'node:path'; import { pipeline } from 'node:stream/promises'; import type { Actor } from '../../core/actor.js'; import { HttpError } from '../../core/http/HttpError.js'; +import { pipeToResponse } from '../../core/http/pipeToResponse.js'; import { assertNotSuspended, assertTeam2fa, @@ -58,6 +59,7 @@ import { import { Context } from '../../core/context.js'; import type { FSEntry } from '../../stores/fs/FSEntry.js'; import { + contextBoundStream, emitGuiItemEvent, type GuiItemEventName, } from '../fs/legacyFsHelpers.js'; @@ -600,19 +602,41 @@ export class WebDAVController extends PuterController { propfindEntry(maskEntryPath(entry), entry, entry.isDir), ]; - if (depth !== '0' && entry.isDir) { - const children = await this.services.fs.listDirectory( - entry.uuid, - {}, - ); - for (const child of children) { - responses.push( - propfindEntry(maskEntryPath(child), child, child.isDir), - ); - } + if (depth === '0' || !entry.isDir) { + this.#sendMultistatus(res, responses); + return; } - this.#sendMultistatus(res, responses); + const toXml = (children: FSEntry[]) => + children + .map((child) => + propfindEntry(maskEntryPath(child), child, child.isDir), + ) + .join('\n'); + const pages = this.services.fs.listDirectoryPages(entry.uuid); + const first = await pages.next(); + if (!first.done && first.value.length > 0) + responses.push(toXml(first.value)); + const second = await pages.next(); + if (second.done) { + this.#sendMultistatus(res, responses); + return; + } + + // Past one page the listing streams, so a large folder isn't held in + // memory whole. A failure partway cuts the response off. + const secondPage = second.value; + async function* body() { + yield `${MULTISTATUS_OPEN}${responses.join('\n')}`; + yield `\n${toXml(secondPage)}`; + for await (const page of pages) + if (page.length > 0) yield `\n${toXml(page)}`; + yield MULTISTATUS_CLOSE; + } + res.status(207).set({ + 'Content-Type': 'application/xml; charset=utf-8', + }); + await pipeToResponse(contextBoundStream(body()), res); } /** @@ -1289,8 +1313,12 @@ function escapeXml(text: string): string { .replace(/'/g, '''); } +const MULTISTATUS_OPEN = + '\n\n'; +const MULTISTATUS_CLOSE = '\n'; + function wrapMultistatus(inner: string): string { - return `\n\n${inner}\n`; + return `${MULTISTATUS_OPEN}${inner}${MULTISTATUS_CLOSE}`; } /** diff --git a/src/backend/core/http/middleware/privateAppGate.ts b/src/backend/core/http/middleware/privateAppGate.ts index ff05da351..560fa1e08 100644 --- a/src/backend/core/http/middleware/privateAppGate.ts +++ b/src/backend/core/http/middleware/privateAppGate.ts @@ -476,9 +476,8 @@ function actorMatchesExpectedApp( } /** - * Mirror of `resolvePrivateIdentity` for public hosted apps. Reads the sticky - * `puter.public.hosted.actor.token` cookie first, then the same - * session/bootstrap fallbacks. + * Fresh authenticated identities replace the sticky public billing cookie when + * the visitor switches accounts. */ export async function resolvePublicHostedIdentity(opts: { req: Request; @@ -500,37 +499,6 @@ export async function resolvePublicHostedIdentity(opts: { const cookies = (req as Request & { cookies?: Record }) .cookies; - const { name: publicCookieNameV2, legacyName: publicCookieNameLegacy } = - HOSTED_ASSET_COOKIES.public; - const publicCookieToken = - (typeof cookies?.[publicCookieNameV2] === 'string' - ? cookies[publicCookieNameV2] - : null) ?? - (typeof cookies?.[publicCookieNameLegacy] === 'string' - ? cookies[publicCookieNameLegacy] - : null); - if (publicCookieToken) { - try { - const claims = await authService.verifyHostedAssetToken( - 'public', - publicCookieToken, - { - appUid: expectedAppUid, - subdomain: expectedSubdomain, - host: expectedHost, - }, - ); - return { - source: 'public-cookie', - userUid: claims.userUid, - sessionUuid: claims.sessionUuid, - hasValidPublicCookie: true, - }; - } catch { - /* fall through — v1-signed cookies land here and get re-minted */ - } - } - const existingActor = req.actor; if ( existingActor?.user?.uuid && @@ -586,6 +554,37 @@ export async function resolvePublicHostedIdentity(opts: { } } + const { name: publicCookieNameV2, legacyName: publicCookieNameLegacy } = + HOSTED_ASSET_COOKIES.public; + const publicCookieToken = + (typeof cookies?.[publicCookieNameV2] === 'string' + ? cookies[publicCookieNameV2] + : null) ?? + (typeof cookies?.[publicCookieNameLegacy] === 'string' + ? cookies[publicCookieNameLegacy] + : null); + if (publicCookieToken) { + try { + const claims = await authService.verifyHostedAssetToken( + 'public', + publicCookieToken, + { + appUid: expectedAppUid, + subdomain: expectedSubdomain, + host: expectedHost, + }, + ); + return { + source: 'public-cookie', + userUid: claims.userUid, + sessionUuid: claims.sessionUuid, + hasValidPublicCookie: true, + }; + } catch { + /* fall through — v1-signed cookies land here and get re-minted */ + } + } + return { source: 'none' }; } diff --git a/src/backend/core/http/middleware/puterSite.test.ts b/src/backend/core/http/middleware/puterSite.test.ts index cd58da60c..18dbf101a 100644 --- a/src/backend/core/http/middleware/puterSite.test.ts +++ b/src/backend/core/http/middleware/puterSite.test.ts @@ -2591,3 +2591,239 @@ describe('createPuterSiteMiddleware — private-app gate decisions', () => { }, ); }); + +describe('public hosted asset billing identity', () => { + it.each(['actor', 'session', 'query'] as const)( + 'replaces an older billing cookie with the current %s identity', + async (source) => { + const owner = await makeUserWithHome(); + const previous = await makeUser(); + const current = await makeUser(); + const home = (await server.stores.fsEntry.getEntryByPath( + `/${owner.username}`, + ))!; + const sub = `switch-${uuidv4().slice(0, 8)}`; + const host = `${sub}.site.puter.localhost`; + await server.stores.subdomain.create({ + userId: owner.id, + subdomain: sub, + rootDirId: home.id, + }); + await writeFile( + owner.id, + `/${owner.username}/index.html`, + Buffer.from('hello'), + ); + const app = await server.stores.app.create( + { name: sub, title: sub, index_url: `http://${host}/` }, + { ownerUserId: owner.id }, + ); + const previousToken = await server.services.auth.getUserAppToken( + actorFor(previous), + app.uid, + ); + const previousActor = + (await server.services.auth.authenticateFromToken( + previousToken, + ))!; + const currentToken = await server.services.auth.getUserAppToken( + actorFor(current), + app.uid, + ); + const oldCookie = await server.services.auth.createHostedAssetToken( + 'public', + { + appUid: app.uid, + userUid: previous.uuid, + sessionUuid: previousActor.session?.uid, + subdomain: sub, + host, + }, + ); + const cookieName = HOSTED_ASSET_COOKIES.public.name; + const req = makeReq({ + hostname: host, + path: '/index.html', + cookies: { + [cookieName]: oldCookie, + ...(source === 'session' ? { session: currentToken } : {}), + }, + query: + source === 'query' + ? { 'puter.auth.token': currentToken } + : {}, + }); + if (source === 'actor') + req.actor = + (await server.services.auth.authenticateFromToken( + currentToken, + ))!; + const { res, out } = makeRes(); + await buildMiddleware({ cookie_name: 'session' })( + req, + res, + vi.fn(), + ); + await new Promise((resolve) => setImmediate(resolve)); + expect(out.statusCode).toBe(200); + expect(req.egressActor?.user?.uuid).toBe(current.uuid); + const refreshed = out.cookies[cookieName]; + expect(refreshed).toBeTruthy(); + expect( + ( + await server.services.auth.verifyHostedAssetToken( + 'public', + refreshed, + { appUid: app.uid, host }, + ) + ).userUid, + ).toBe(current.uuid); + const nextReq = makeReq({ + hostname: host, + path: '/index.html', + cookies: { [cookieName]: refreshed }, + }); + await runMiddleware(buildMiddleware(), nextReq); + expect(nextReq.egressActor?.user?.uuid).toBe(current.uuid); + }, + ); + + it('bills recognized visitors, reuses the parent session, and falls back to the owner', async () => { + const owner = await makeUserWithHome(); + const visitor = await makeUser(); + const home = (await server.stores.fsEntry.getEntryByPath( + `/${owner.username}`, + ))!; + const sub = `egress-${uuidv4().slice(0, 8)}`; + const host = `${sub}.site.puter.localhost`; + await server.stores.subdomain.create({ + userId: owner.id, + subdomain: sub, + rootDirId: home.id, + }); + await writeFile( + owner.id, + `/${owner.username}/index.html`, + Buffer.from('hello'), + ); + const app = await server.stores.app.create( + { name: sub, title: sub, index_url: `http://${host}/` }, + { ownerUserId: owner.id }, + ); + const token = await server.services.auth.getUserAppToken( + actorFor(visitor), + app.uid, + ); + const authenticated = + await server.services.auth.authenticateFromToken(token); + const serve = async (query = {}, cookies = {}, headers = {}) => { + const req = makeReq({ hostname: host, path: '/index.html', query }); + Object.assign(req, { cookies, headers }); + const { res, out } = makeRes(); + await buildMiddleware()(req, res, vi.fn()); + await new Promise((resolve) => setImmediate(resolve)); + expect(out.statusCode).toBe(200); + return { req, out }; + }; + const create = vi.spyOn(server.stores.session, 'create'); + try { + const first = await serve({ 'puter.auth.token': token }); + expect(first.req.egressActor?.user?.uuid).toBe(visitor.uuid); + const cookieName = HOSTED_ASSET_COOKIES.public.name; + const cookie = first.out.cookies[cookieName]; + expect(cookie).toBeTruthy(); + const claims = await server.services.auth.verifyHostedAssetToken( + 'public', + cookie, + { appUid: app.uid, host }, + ); + expect(claims.sessionUuid).toBe(authenticated?.session?.uid); + const next = await serve({}, { [cookieName]: cookie }); + expect(next.req.egressActor?.user?.uuid).toBe(visitor.uuid); + expect(next.out.cookies).toEqual({}); + const blockedCookies = await serve( + {}, + {}, + { referer: `http://${host}/?puter.auth.token=${token}` }, + ); + expect(blockedCookies.req.egressActor?.user?.uuid).toBe( + visitor.uuid, + ); + expect(create).not.toHaveBeenCalled(); + const anonymous = await serve(); + expect(anonymous.req.egressActor?.user?.uuid).toBe(owner.uuid); + const invalid = await serve({}, { [cookieName]: 'invalid' }); + expect(invalid.req.egressActor?.user?.uuid).toBe(owner.uuid); + await server.services.auth.revokeAllSessionsForUserId(visitor.id); + const revoked = await serve({}, { [cookieName]: cookie }); + expect(revoked.req.egressActor?.user?.uuid).toBe(owner.uuid); + } finally { + create.mockRestore(); + } + }); +}); + +describe('site rooted in the owner tree, root moved to Trash', () => { + it('stops serving a trashed folder and resumes when restored', async () => { + const owner = await makeUserWithHome(); + const dir = await server.services.fs.mkdir(owner.id, { + path: `/${owner.username}/Documents/site`, + createMissingParents: true, + }); + const body = Buffer.from('trashed'); + await server.services.fs.write(owner.id, { + fileMetadata: { + path: `/${owner.username}/Documents/site/index.html`, + size: body.byteLength, + }, + fileContent: body, + }); + const sub = `tr-${Math.random().toString(36).slice(2, 8)}`; + await server.stores.subdomain.create({ + userId: owner.id, + subdomain: sub, + rootDirId: dir.id, + }); + const serveIndex = async () => { + const mw = createPuterSiteMiddleware(hostingConfig, { + clients: server.clients, + stores: server.stores, + services: server.services, + }); + const { res, out } = makeRes(); + await mw( + makeReq({ + hostname: `${sub}.site.puter.localhost`, + path: '/', + }), + res, + vi.fn(), + ); + await new Promise((resolve) => setImmediate(resolve)); + return out; + }; + expect((await serveIndex()).statusCode).toBe(200); + + const trash = (await server.stores.fsEntry.getEntryByPath( + `/${owner.username}/Trash`, + ))!; + const entry = (await server.stores.fsEntry.getEntryById(dir.id))!; + await server.services.fs.move(owner.id, { + source: entry, + destinationParent: trash, + }); + const moved = (await server.stores.fsEntry.getEntryById(dir.id))!; + expect(moved.path.startsWith(`/${owner.username}/Trash/`)).toBe(true); + + const out = await serveIndex(); + expect(out.statusCode).toBe(404); + const documents = (await server.stores.fsEntry.getEntryByPath( + `/${owner.username}/Documents`, + ))!; + await server.services.fs.move(owner.id, { + source: moved, + destinationParent: documents, + }); + expect((await serveIndex()).statusCode).toBe(200); + }); +}); diff --git a/src/backend/core/http/middleware/puterSite.ts b/src/backend/core/http/middleware/puterSite.ts index 77697c07e..b6d9d14f9 100644 --- a/src/backend/core/http/middleware/puterSite.ts +++ b/src/backend/core/http/middleware/puterSite.ts @@ -27,6 +27,7 @@ import { MANAGE_PERM_PREFIX } from '../../../services/permission/consts'; import type { puterStores } from '../../../stores'; import type { IConfig, LayerInstances } from '../../../types'; import { awaitLater } from '../../../util/concurrency'; +import { isTrashedPath } from '../../../util/hostedAppBacking'; import { makeActor } from '../../actor'; import { buildAppCenterFallback, @@ -109,10 +110,6 @@ const isServableEntryPath = ( return !isWorkersSourcePath(relPath) && !isSiteConfigPath(relPath); }; -/** Inside some owner's top-level Trash, which is where Delete puts things. */ -const isTrashedPath = (path: string): boolean => - /^\/[^/]+\/Trash(\/|$)/u.test(path); - /** * Suggested value for `config.hosting_csp`. Not applied unless configured. * @@ -333,6 +330,7 @@ export const createPuterSiteMiddleware = ( const associatedApp = (await appLookup) as AppRow | null; + let hostedVisitorUid: string | undefined; const isPrivateApp = Boolean(associatedApp?.is_private); const privateApp = isPrivateApp ? associatedApp : null; @@ -400,6 +398,8 @@ export const createPuterSiteMiddleware = ( return; } + hostedVisitorUid = identity.userUid; + // Entitlement check runs on every request — matching v1. The // sticky cookie is an identity shortcut, not an access cache; // the marketplace extension already caches access decisions @@ -509,11 +509,8 @@ export const createPuterSiteMiddleware = ( res.status(404).type('text/plain').send('Subdomain not found'); return; } else { - // Public hosted site. Mint the public hosted-actor cookie if - // we can identify the visitor — lets the hosted page make - // cross-origin requests as the actor without needing a - // host-scoped main session cookie. No-op for anonymous - // visitors. + // Remember the authenticated visitor for asset billing across + // requests that do not carry the original app token. try { const identity = await resolvePublicHostedIdentity({ req, @@ -526,6 +523,7 @@ export const createPuterSiteMiddleware = ( expectedSubdomain: subdomain, expectedHost: host, }); + hostedVisitorUid = identity.userUid; if ( identity.userUid && !(identity as { hasValidPublicCookie?: boolean }) @@ -574,7 +572,7 @@ export const createPuterSiteMiddleware = ( .send(SUBDOMAIN_404); return; } - if (!rootEntry.isDir) { + if (!rootEntry.isDir || isTrashedPath(rootEntry.path)) { res.status(404) .type('text/html; charset=UTF-8') .send(SUBDOMAIN_404); @@ -584,37 +582,27 @@ export const createPuterSiteMiddleware = ( // 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 { - stillPublishable = await layers.services.acl.check( - makeActor({ - user: { - id: owner.id, - uuid: owner.uuid, - username: owner.username, - }, - }), - { - path: rootEntry.path, - resolveAncestors: () => - layers.services.fs.getAncestorChain( - rootEntry.path, - ), + try { + stillPublishable = await layers.services.acl.check( + makeActor({ + user: { + id: owner.id, + uuid: owner.uuid, + username: owner.username, }, - MANAGE_PERM_PREFIX, - ); - } catch (e) { - // Fail closed — an unreadable grant is not a held one. - console.warn( - '[puter-site] publish grant recheck failed', - e, - ); - } + }), + { + path: rootEntry.path, + resolveAncestors: () => + layers.services.fs.getAncestorChain(rootEntry.path), + }, + MANAGE_PERM_PREFIX, + ); + } catch (e) { + // Fail closed — an unreadable grant is not a held one. + console.warn('[puter-site] publish grant recheck failed', e); } if (!stillPublishable) { res.status(404) @@ -837,7 +825,22 @@ export const createPuterSiteMiddleware = ( // hosting is unauthenticated by design, so most visitors are nobody in // particular. Set unconditionally because hosting subdomains are not // metered by default: this is what opts the response in. - req.egressActor = req.actor ?? { + let visitorActor = req.actor; + if (hostedVisitorUid && visitorActor?.user?.uuid !== hostedVisitorUid) { + try { + const visitor = + await layers.stores.user.getByUuid(hostedVisitorUid); + if (visitor && !visitor.suspended) { + visitorActor = makeActor({ + user: visitor, + app: associatedApp ?? undefined, + }); + } + } catch { + // Unknown requesters are billed to the site owner. + } + } + req.egressActor = visitorActor ?? { user: { uuid: owner.uuid, id: owner.id, diff --git a/src/backend/drivers/ai-chat/providers/openai/OpenAiChatCompletionsProvider.test.ts b/src/backend/drivers/ai-chat/providers/openai/OpenAiChatCompletionsProvider.test.ts index bc604beb5..56a7c02bc 100644 --- a/src/backend/drivers/ai-chat/providers/openai/OpenAiChatCompletionsProvider.test.ts +++ b/src/backend/drivers/ai-chat/providers/openai/OpenAiChatCompletionsProvider.test.ts @@ -177,6 +177,7 @@ describe('OpenAiChatProvider model catalog', () => { expect(ids).toContain('gpt-5.2-2025-12-11'); expect(ids).toContain('gpt-6-astra'); expect(ids).toContain('gpt-6.1-sol'); + expect(ids).toContain('gpt-6.1-terra'); expect(ids).toContain('gpt-6-sol'); expect(ids).toContain('gpt-6-luna'); }); @@ -348,30 +349,33 @@ describe('OpenAiChatProvider.complete request shape', () => { expect(args.safety_identifier).toBe('puter-u42'); }); - it.each(['gpt-6-astra', 'gpt-6.1-sol', 'gpt-6-sol', 'gpt-6-luna'])( - 'resolves the namespaced %s alias', - async (model) => { - const { provider } = makeProvider(); - createMock.mockResolvedValueOnce(baseCompletion); + it.each([ + 'gpt-6-astra', + 'gpt-6.1-sol', + 'gpt-6.1-terra', + 'gpt-6-sol', + 'gpt-6-luna', + ])('resolves the namespaced %s alias', async (model) => { + const { provider } = makeProvider(); + createMock.mockResolvedValueOnce(baseCompletion); - await withTestActor(() => - provider.complete({ - model: `openai/${model}`, - messages: [{ role: 'user', content: 'hello' }], - reasoning_effort: 'low', - }), - ); + await withTestActor(() => + provider.complete({ + model: `openai/${model}`, + messages: [{ role: 'user', content: 'hello' }], + reasoning_effort: 'low', + }), + ); - expect(createMock.mock.calls[0]![0].model).toBe(model); - expect(createMock.mock.calls[0]![0].reasoning_effort).toBe('low'); - expect(recordSpy).toHaveBeenCalledWith( - expect.any(Object), - expect.anything(), - `openai:${model}`, - expect.any(Object), - ); - }, - ); + expect(createMock.mock.calls[0]![0].model).toBe(model); + expect(createMock.mock.calls[0]![0].reasoning_effort).toBe('low'); + expect(recordSpy).toHaveBeenCalledWith( + expect.any(Object), + expect.anything(), + `openai:${model}`, + expect.any(Object), + ); + }); it('omits parallel_tool_calls/tool_choice when no tool is sent (Chat Completions rejects them alone)', async () => { const { provider } = makeProvider(); diff --git a/src/backend/drivers/ai-chat/providers/openai/OpenAiChatResponsesProvider.test.ts b/src/backend/drivers/ai-chat/providers/openai/OpenAiChatResponsesProvider.test.ts index dd473b104..509e6ec4d 100644 --- a/src/backend/drivers/ai-chat/providers/openai/OpenAiChatResponsesProvider.test.ts +++ b/src/backend/drivers/ai-chat/providers/openai/OpenAiChatResponsesProvider.test.ts @@ -224,6 +224,7 @@ describe('OpenAiResponsesChatProvider model catalog', () => { }); it.each([ + ['gpt-6.1-terra', '2026-06-01', '2026-10-26', 100, 5, 500], ['gpt-6.1-sol', '2026-04-30', '2026-09-29', 200, 10, 1000], ['gpt-6-sol', '2026-04-20', '2026-09-22', 200, 20, 1000], ['gpt-6-luna', '2026-05-18', '2026-09-22', 10, 1, 50], @@ -244,6 +245,7 @@ describe('OpenAiResponsesChatProvider model catalog', () => { tokens: 1_000_000, prompt_tokens: input, cached_tokens: cached, + cache_write_tokens: input * 1.25, completion_tokens: output, }, context: 1_050_000, @@ -332,6 +334,7 @@ describe('OpenAiResponsesChatProvider.complete request shape', () => { 'gpt-6-luna', 'gpt-6-astra', 'gpt-6.1-sol', + 'gpt-6.1-terra', 'gpt-5.6', 'openai/gpt-5.6', 'openai/gpt-5.6-luna', @@ -367,7 +370,7 @@ describe('OpenAiResponsesChatProvider.complete request shape', () => { }, ); - it.each(['gpt-5.6-luna', 'gpt-6-luna'])( + it.each(['gpt-5.6-luna', 'gpt-6-luna', 'gpt-6.1-terra'])( 'preserves sampling with reasoning disabled for %s', async (model) => { const { provider } = makeProvider(); @@ -620,7 +623,13 @@ describe('OpenAiResponsesChatProvider.complete request shape', () => { expect(gpt41Args).not.toHaveProperty('verbosity'); }); - it.each(['gpt-6-astra', 'gpt-6.1-sol', 'gpt-6-sol', 'gpt-6-luna'])( + it.each([ + 'gpt-6-astra', + 'gpt-6.1-sol', + 'gpt-6.1-terra', + 'gpt-6-sol', + 'gpt-6-luna', + ])( 'maps flat controls to Responses options for %s aliases', async (model) => { const { provider } = makeProvider(); @@ -860,47 +869,51 @@ describe('OpenAiResponsesChatProvider.complete non-stream output', () => { }); }); - it('bills the whole request at long-context rates past 272K input tokens', async () => { - const sol = OPEN_AI_MODELS.find((m) => m.id === 'gpt-6-sol')!; - const { provider } = makeProvider(); - responsesCreateMock.mockResolvedValueOnce({ - output: [{ role: 'assistant' }], - output_text: 'hi', - usage: { - input_tokens: 300_000, - output_tokens: 10_000, - input_tokens_details: { - cached_tokens: 50_000, - cache_write_tokens: 20_000, + it.each([ + ['gpt-6-sol', 119], + ['gpt-6.1-terra', 59], + ] as const)( + 'bills %s at long-context rates past 272K input tokens', + async (modelId, total) => { + const model = OPEN_AI_MODELS.find((m) => m.id === modelId)!; + const { provider } = makeProvider(); + responsesCreateMock.mockResolvedValueOnce({ + output: [{ role: 'assistant' }], + output_text: 'hi', + usage: { + input_tokens: 300_000, + output_tokens: 10_000, + input_tokens_details: { + cached_tokens: 50_000, + cache_write_tokens: 20_000, + }, }, - }, - }); + }); - await withTestActor(() => - provider.complete({ - model: 'gpt-6-sol', - messages: [{ role: 'user', content: 'hi' }], - }), - ); + await withTestActor(() => + provider.complete({ + model: modelId, + messages: [{ role: 'user', content: 'hi' }], + }), + ); - const [, , , overrides] = recordSpy.mock.calls[0]!; - // $0.92 + $0.02 + $0.10 + $0.15 = $1.19, against $0.62 at the - // standard rates. - expect(overrides).toEqual({ - prompt_tokens: 230_000 * Number(sol.costs.prompt_tokens) * 2, - cached_tokens: 50_000 * Number(sol.costs.cached_tokens) * 2, - cache_write_tokens: - 20_000 * Number(sol.costs.cache_write_tokens) * 2, - completion_tokens: - 10_000 * Number(sol.costs.completion_tokens) * 1.5, - }); - const totalCents = - Object.values(overrides as Record).reduce( - (a, b) => a + b, - 0, - ) / 1_000_000; - expect(totalCents).toBeCloseTo(119); - }); + const [, , , overrides] = recordSpy.mock.calls[0]!; + expect(overrides).toEqual({ + prompt_tokens: 230_000 * Number(model.costs.prompt_tokens) * 2, + cached_tokens: 50_000 * Number(model.costs.cached_tokens) * 2, + cache_write_tokens: + 20_000 * Number(model.costs.cache_write_tokens) * 2, + completion_tokens: + 10_000 * Number(model.costs.completion_tokens) * 1.5, + }); + const totalCents = + Object.values(overrides as Record).reduce( + (a, b) => a + b, + 0, + ) / 1_000_000; + expect(totalCents).toBeCloseTo(total); + }, + ); it('bills cached tokens at the input rate when the model prices no cache read', async () => { // gpt-5.4-pro is responses-API-only and its catalogue entry has no diff --git a/src/backend/drivers/ai-chat/providers/openai/models.ts b/src/backend/drivers/ai-chat/providers/openai/models.ts index 67d0006e0..241e26418 100644 --- a/src/backend/drivers/ai-chat/providers/openai/models.ts +++ b/src/backend/drivers/ai-chat/providers/openai/models.ts @@ -31,6 +31,31 @@ const GPT_LONG_CONTEXT_PRICING = { // Hardcoded from https://models.dev/api.json export const OPEN_AI_MODELS: IChatModel[] = [ + { + puterId: 'openai:openai/gpt-6.1-terra', + id: 'gpt-6.1-terra', + responsesSampling: 'reasoningDisabled', + modalities: { input: ['text', 'image'], output: ['text'] }, + open_weights: false, + tool_call: true, + knowledge: '2026-06-01', + release_date: '2026-10-26', + aliases: ['openai/gpt-6.1-terra'], + costs_currency: 'usd-cents', + input_cost_key: 'prompt_tokens', + output_cost_key: 'completion_tokens', + costs: { + tokens: 1_000_000, + prompt_tokens: 100, + cached_tokens: 5, + cache_write_tokens: 100 * 1.25, + completion_tokens: 500, + }, + long_context_pricing: GPT_LONG_CONTEXT_PRICING, + context: 1_050_000, + max_tokens: 128_000, + responses_api: true, + }, { puterId: 'openai:openai/gpt-6.1-sol', id: 'gpt-6.1-sol', diff --git a/src/backend/drivers/ai-chat/utils/openaiParams.test.ts b/src/backend/drivers/ai-chat/utils/openaiParams.test.ts index 48f42f2b7..bd2801ca8 100644 --- a/src/backend/drivers/ai-chat/utils/openaiParams.test.ts +++ b/src/backend/drivers/ai-chat/utils/openaiParams.test.ts @@ -289,6 +289,9 @@ describe('clampReasoningEffort', () => { ['gpt-5.5', 'max', 'xhigh'], ['gpt-5', 'high', 'high'], ['gpt-6-astra-1', 'none', 'low'], + ...(['none', 'low', 'medium', 'high', 'xhigh', 'max'] as const).map( + (effort) => ['gpt-6.1-terra', effort, effort] as const, + ), ] as const)( 'clamps %s requesting %s to %s', (modelId, requested, expected) => { diff --git a/src/backend/drivers/apps/AppDriver.js b/src/backend/drivers/apps/AppDriver.js index 6a54c711d..8c242366c 100644 --- a/src/backend/drivers/apps/AppDriver.js +++ b/src/backend/drivers/apps/AppDriver.js @@ -271,8 +271,11 @@ export class AppDriver extends PuterDriver { legacyCode: 'app_name_already_in_use', }); } - if (filetypes) + if (filetypes) { await this.appStore.setFiletypeAssociations(app.id, filetypes); + // The row was built before they were stored; views re-read them. + delete app.filetype_associations; + } this.#emitAppChanged({ app, action: 'created' }); @@ -421,10 +424,10 @@ export class AppDriver extends PuterDriver { existing: app, }); - // Puter-hosted index_url handling on update — same flow as create - // but only when the index_url is actually changing. Self-app is + // Puter-hosted index_url handling on update — same flow as create. + // #validateInput already dropped an unchanged index_url. Self-app is // excluded from the conflict search via `excludeAppId`. - if (fields.index_url && fields.index_url !== app.index_url) { + if (fields.index_url) { await this.#ensurePuterSiteSubdomainIsOwned( fields.index_url, actor.user, @@ -486,6 +489,8 @@ export class AppDriver extends PuterDriver { } if (filetypes !== undefined) { await this.appStore.setFiletypeAssociations(app.id, filetypes); + // A filetype-only update returns the cached row with the old ones. + if (updated) delete updated.filetype_associations; } this.#emitAppChanged({ app: updated, old_app: app, action: 'updated' }); @@ -615,13 +620,19 @@ export class AppDriver extends PuterDriver { protocols: WEB_AND_EXTENSION_PROTOCOLS, }); if (out.index_url) out.index_url = new URL(out.index_url).href; - // Only enforce on new/changed values so rows that already - // carry a reserved host (migration-seeded builtins) can still - // have their other fields updated. + // A value equal to the stored one after normalization is + // unchanged: drop it so legacy spellings stay as stored and + // skip the host checks (migration-seeded builtins carry + // reserved hosts). Rewriting it would make a dormant + // mixed-case/default-port row match again without checks. if ( + existing && out.index_url !== undefined && - out.index_url !== existing?.index_url + out.index_url === + (URL.parse(existing.index_url)?.href ?? existing.index_url) ) { + delete out.index_url; + } else if (out.index_url !== undefined) { this.#assertIndexUrlHostAllowed(out.index_url); } } diff --git a/src/backend/drivers/apps/AppDriver.legacyUrl.test.ts b/src/backend/drivers/apps/AppDriver.legacyUrl.test.ts new file mode 100644 index 000000000..8988b97f9 --- /dev/null +++ b/src/backend/drivers/apps/AppDriver.legacyUrl.test.ts @@ -0,0 +1,233 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { v4 as uuidv4 } from 'uuid'; +import { makeActor } from '../../core/actor.js'; +import { runWithContext } from '../../core/context.js'; +import { PuterServer } from '../../server.js'; +import { setupTestServer } from '../../testUtil.js'; +import type { IConfig } from '../../types.js'; + +let server: PuterServer; + +beforeAll(async () => { + server = await setupTestServer({ + domain: 'puter.localhost', + static_hosting_domain: 'site.puter.localhost', + protocol: 'http', + } as unknown as IConfig); +}); + +afterAll(async () => { + await server?.shutdown(); +}); + +type AppsDriver = { + update: (args: { + id: { name: string }; + object: Record; + }) => Promise>; +}; + +describe('saving app settings with an unchanged legacy index_url', () => { + it('allows saving an unchanged URL after its site is gone', async () => { + const username = `li-${Math.random().toString(36).slice(2, 10)}`; + const user = await server.stores.user.create({ + username, + uuid: uuidv4(), + password: null, + email: `${username}@test.local`, + free_storage: 100 * 1024 * 1024, + requires_email_confirmation: false, + } as Parameters[0]); + const actor = makeActor({ + user: { ...user, email_confirmed: true } as never, + }); + const sub = `li-${Math.random().toString(36).slice(2, 8)}`; + const legacyUrl = `http://${sub}.site.puter.localhost`; + const name = `li-app-${Math.random().toString(36).slice(2, 8)}`; + // Row as Dev Center wrote it before normalization: no trailing slash. + await server.clients.db.write( + 'INSERT INTO `apps` (`uid`, `name`, `title`, `index_url`, `owner_user_id`) VALUES (?, ?, ?, ?, ?)', + [`app-${uuidv4()}`, name, 'Old title', legacyUrl, user.id], + ); + const apps = server.drivers.apps as unknown as AppsDriver; + + // The site it pointed at has since been deleted. + expect(await server.stores.subdomain.getBySubdomain(sub)).toBeFalsy(); + + // Title-only save works. + await runWithContext({ actor }, () => + apps.update({ id: { name }, object: { title: 'Title only' } }), + ); + + // Dev Center's save sends the stored index_url back as it was. + await expect( + runWithContext({ actor }, () => + apps.update({ + id: { name }, + object: { title: 'New title', index_url: legacyUrl }, + }), + ), + ).resolves.toMatchObject({ title: 'New title' }); + await expect( + runWithContext({ actor }, () => + apps.update({ + id: { name }, + object: { + index_url: `http://different-${sub}.site.puter.localhost/`, + }, + }), + ), + ).rejects.toMatchObject({ + statusCode: 400, + legacyCode: 'subdomain_not_owned', + }); + }); + + it('normalizes a stored reserved host before comparing unchanged values', async () => { + const user = await server.stores.user.create({ + username: `legacy-${uuidv4().slice(0, 8)}`, + uuid: uuidv4(), + password: null, + }); + const actor = makeActor({ user }); + const name = `legacy-${uuidv4()}`; + const indexUrl = 'https://builtins.namespaces.puter.com'; + await server.clients.db.write( + 'INSERT INTO `apps` (`uid`, `name`, `title`, `index_url`, `owner_user_id`) VALUES (?, ?, ?, ?, ?)', + [`app-${uuidv4()}`, name, 'Builtin', indexUrl, user.id], + ); + const apps = server.drivers.apps as unknown as AppsDriver; + await expect( + runWithContext({ actor }, () => + apps.update({ + id: { name }, + object: { index_url: indexUrl, title: 'Saved' }, + }), + ), + ).resolves.toMatchObject({ title: 'Saved' }); + await expect( + runWithContext({ actor }, () => + apps.update({ + id: { name }, + object: { index_url: `${indexUrl}/different` }, + }), + ), + ).rejects.toMatchObject({ statusCode: 400 }); + }); + + it('leaves a legacy mixed-case row as stored when re-sent normalized', async () => { + const mkUser = async () => { + const username = `lc-${Math.random().toString(36).slice(2, 10)}`; + const user = await server.stores.user.create({ + username, + uuid: uuidv4(), + password: null, + email: `${username}@test.local`, + free_storage: 100 * 1024 * 1024, + requires_email_confirmation: false, + } as Parameters[0]); + return { + user, + actor: makeActor({ + user: { ...user, email_confirmed: true } as never, + }), + }; + }; + const owner = await mkUser(); + const siteOwner = await mkUser(); + const sub = `lc${Math.random().toString(36).slice(2, 8)}`; + const otherSub = `lc${Math.random().toString(36).slice(2, 8)}`; + for (const s of [sub, otherSub]) { + const home = await server.services.fs.mkdir(siteOwner.user.id, { + path: `/${siteOwner.user.username}/${s}`, + createMissingParents: true, + }); + await server.stores.subdomain.create({ + userId: siteOwner.user.id, + subdomain: s, + rootDirId: home.id, + }); + } + // Pre-normalization row pointing at a site now owned by someone else. + const legacyUrl = `http://${sub.toUpperCase()}.site.puter.localhost`; + const name = `lc-app-${uuidv4().slice(0, 8)}`; + await server.clients.db.write( + 'INSERT INTO `apps` (`uid`, `name`, `title`, `index_url`, `owner_user_id`) VALUES (?, ?, ?, ?, ?)', + [`app-${uuidv4()}`, name, 'Old', legacyUrl, owner.user.id], + ); + const apps = server.drivers.apps as unknown as AppsDriver; + const readStored = async () => { + const [row] = await server.clients.db.read( + 'SELECT index_url, title FROM apps WHERE name = ?', + [name], + ); + return row; + }; + + await runWithContext({ actor: owner.actor }, () => + apps.update({ id: { name }, object: { title: 'Title only' } }), + ); + expect(await readStored()).toMatchObject({ + index_url: legacyUrl, + title: 'Title only', + }); + + await runWithContext({ actor: owner.actor }, () => + apps.update({ + id: { name }, + object: { + title: 'Resent', + index_url: `http://${sub}.site.puter.localhost/`, + }, + }), + ); + expect(await readStored()).toMatchObject({ + index_url: legacyUrl, + title: 'Resent', + }); + + await expect( + runWithContext({ actor: owner.actor }, () => + apps.update({ + id: { name }, + object: { + index_url: `http://${otherSub}.site.puter.localhost/`, + }, + }), + ), + ).rejects.toMatchObject({ + statusCode: 400, + legacyCode: 'subdomain_not_owned', + }); + expect((await readStored()).index_url).toBe(legacyUrl); + }); + + it('leaves a legacy default-port reserved host as stored', async () => { + const user = await server.stores.user.create({ + username: `legacy-${uuidv4().slice(0, 8)}`, + uuid: uuidv4(), + password: null, + }); + const actor = makeActor({ user }); + const name = `legacy-${uuidv4()}`; + const indexUrl = 'https://PUTER.COM:443'; + await server.clients.db.write( + 'INSERT INTO `apps` (`uid`, `name`, `title`, `index_url`, `owner_user_id`) VALUES (?, ?, ?, ?, ?)', + [`app-${uuidv4()}`, name, 'Builtin', indexUrl, user.id], + ); + const apps = server.drivers.apps as unknown as AppsDriver; + await expect( + runWithContext({ actor }, () => + apps.update({ + id: { name }, + object: { index_url: 'https://puter.com/', title: 'Saved' }, + }), + ), + ).resolves.toMatchObject({ title: 'Saved' }); + const [row] = await server.clients.db.read( + 'SELECT index_url FROM apps WHERE name = ?', + [name], + ); + expect(row.index_url).toBe(indexUrl); + }); +}); diff --git a/src/backend/drivers/apps/AppDriver.test.ts b/src/backend/drivers/apps/AppDriver.test.ts index 5c5ddf9a2..f7edc9e97 100644 --- a/src/backend/drivers/apps/AppDriver.test.ts +++ b/src/backend/drivers/apps/AppDriver.test.ts @@ -1055,6 +1055,34 @@ describe('AppDriver.create additional branches', () => { ); }); + it('returns the new filetype_associations from a filetype-only update of a cached app', async () => { + const { actor } = await makeUser(); + const created = await withActor(actor, () => + driver.create({ + object: { + name: uniqueName('ftu'), + title: 't', + index_url: uniqueIndexUrl(), + filetype_associations: ['txt'], + }, + }), + ); + // Let the post-write window lapse so the row is cached again. + for (const key of await server.clients.redis.keys('apps:*:deleted')) { + await server.clients.redis.del(key); + } + await server.stores.app.getByUid(created.uid as string); + await new Promise((r) => setTimeout(r, 20)); + + const updated = await withActor(actor, () => + driver.update({ + uid: created.uid as string, + object: { filetype_associations: ['csv'] }, + }), + ); + expect(updated.filetype_associations).toEqual(['csv']); + }); + it('rejects too-long title with 400', async () => { const { actor } = await makeUser(); await expect( @@ -2294,7 +2322,10 @@ describe('AppDriver.select query-count regression', () => { expect(large.itemCount).toBe(30); // Sanity check the classifier actually saw the shapes it's counting. expect(small.counts.indexUrlIn).toBeGreaterThan(0); - expect(small.counts.subdomains).toBeGreaterThan(0); + // Live and dangling backings both come from the warm subdomain cache + // (negative entries included), so no subdomains read on either path. + expect(small.counts.subdomains).toBe(0); + expect(small.pread).toBe(0); expect(large.counts).toEqual(small.counts); expect(large.pread).toBe(small.pread); diff --git a/src/backend/drivers/subdomain/SubdomainDriver.grant.test.ts b/src/backend/drivers/subdomain/SubdomainDriver.grant.test.ts new file mode 100644 index 000000000..3e3f730e8 --- /dev/null +++ b/src/backend/drivers/subdomain/SubdomainDriver.grant.test.ts @@ -0,0 +1,207 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { v4 as uuidv4 } from 'uuid'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { makeActor } from '../../core/actor.js'; +import type { Actor } from '../../core/actor.js'; +import { runWithContext } from '../../core/context.js'; +import { PuterServer } from '../../server.js'; +import { setupTestServer } from '../../testUtil.js'; +import type { SubdomainDriver } from './SubdomainDriver.js'; + +// `puter.perms.request('subdomains', { access })` grants an app +// `subdomains-of-user::read|write`, widening its scope from the rows it +// created to all of its user's rows, and never past them. + +let server: PuterServer; +let driver: SubdomainDriver; + +beforeAll(async () => { + server = await setupTestServer(); + driver = server.drivers.subdomains as unknown as SubdomainDriver; +}); + +afterAll(async () => { + await server?.shutdown(); +}); + +const rand = () => Math.random().toString(36).slice(2, 10); + +const makeUser = async () => { + const username = `sdg-${rand()}`; + const created = await server.stores.user.create({ + username, + uuid: uuidv4(), + password: null, + email: `${username}@test.local`, + free_storage: 100 * 1024 * 1024, + requires_email_confirmation: false, + }); + const user = (await server.stores.user.getById(created.id))!; + return makeActor({ user: { ...user, email_confirmed: true } as never }); +}; + +const makeApp = async (owner: Actor) => + (await server.stores.app.create( + { + name: `sdg-app-${uuidv4()}`, + title: 'Site manager', + index_url: `https://sdg-${uuidv4()}.example.test/`, + }, + { ownerUserId: owner.user!.id }, + )) as { uid: string; id: number }; + +const asApp = (user: Actor, app: { uid: string; id: number }) => + makeActor({ ...user, app: { uid: app.uid, id: app.id } }); + +const seedRow = ( + user: Actor, + appOwner: number | null, + name = `sdg-${rand()}`, +) => + server.stores.subdomain.create({ + userId: user.user!.id, + subdomain: name, + rootDirId: null, + associatedAppId: null, + appOwner, + }); + +const grant = (user: Actor, app: { uid: string }, access: 'read' | 'write') => + server.services.permission.grantUserAppPermission( + user, + app.uid, + `subdomains-of-user:${user.user!.uuid}:${access}`, + ); + +const as = (actor: Actor, fn: () => Promise) => + runWithContext({ actor }, fn); + +const listed = async (actor: Actor) => + ( + (await as(actor, () => driver.select({}))) as Array<{ + subdomain: string; + }> + ).map((row) => row.subdomain); + +describe('SubdomainDriver with a subdomains grant', () => { + it('leaves an app without the grant scoped to the rows it created', async () => { + const user = await makeUser(); + const app = await makeApp(user); + const own = await seedRow(user, app.id); + const loose = await seedRow(user, null); + const appActor = asApp(user, app); + + expect(await listed(appActor)).toEqual([own.subdomain]); + const page = (await as(appActor, () => + driver.select({ includeTotal: true }), + )) as { items: unknown[]; total: number }; + expect(page.total).toBe(1); + for (const call of [ + () => driver.read({ uid: loose.uuid }), + () => driver.update({ uid: loose.uuid, object: {} }), + () => driver.delete({ uid: loose.uuid }), + ]) { + await expect(as(appActor, call)).rejects.toMatchObject({ + statusCode: 403, + }); + } + }); + + it('read lets the app list and read all of its user rows, but not change them', async () => { + const user = await makeUser(); + const app = await makeApp(user); + const otherApp = await makeApp(user); + const own = await seedRow(user, app.id); + const loose = await seedRow(user, null); + const byOtherApp = await seedRow(user, otherApp.id); + await grant(user, app, 'read'); + const appActor = asApp(user, app); + + expect((await listed(appActor)).sort()).toEqual( + [own.subdomain, loose.subdomain, byOtherApp.subdomain].sort(), + ); + const page = (await as(appActor, () => + driver.select({ includeTotal: true }), + )) as { total: number }; + expect(page.total).toBe(3); + const read = (await as(appActor, () => + driver.read({ id: { subdomain: loose.subdomain } }), + )) as { uid: string }; + expect(read.uid).toBe(loose.uuid); + + for (const call of [ + () => driver.update({ uid: loose.uuid, object: {} }), + () => driver.delete({ uid: byOtherApp.uuid }), + ]) { + await expect(as(appActor, call)).rejects.toMatchObject({ + statusCode: 403, + }); + } + }); + + it('write also lets the app change and delete them', async () => { + const user = await makeUser(); + const app = await makeApp(user); + const otherApp = await makeApp(user); + const loose = await seedRow(user, null); + const byOtherApp = await seedRow(user, otherApp.id); + await grant(user, app, 'write'); + const appActor = asApp(user, app); + + expect((await listed(appActor)).sort()).toEqual( + [loose.subdomain, byOtherApp.subdomain].sort(), + ); + const updated = (await as(appActor, () => + driver.update({ uid: loose.uuid, object: {} }), + )) as { uid: string }; + expect(updated.uid).toBe(loose.uuid); + await as(appActor, () => driver.delete({ uid: byOtherApp.uuid })); + expect( + await server.stores.subdomain.getByUuid(String(byOtherApp.uuid), { + primary: true, + }), + ).toBeNull(); + }); + + it('never reaches another user rows, or rows of a user who granted a different app', async () => { + const user = await makeUser(); + const stranger = await makeUser(); + const app = await makeApp(user); + const otherApp = await makeApp(user); + const strangers = await seedRow(stranger, app.id); + const mine = await seedRow(user, null); + await grant(user, otherApp, 'write'); + // The stranger's own grant names the stranger, not `user`. + await grant(stranger, app, 'write'); + + const appActor = asApp(user, app); + expect(await listed(appActor)).toEqual([]); + for (const call of [ + () => driver.read({ uid: mine.uuid }), + () => driver.read({ uid: strangers.uuid }), + () => driver.delete({ uid: strangers.uuid }), + ]) { + await expect(as(appActor, call)).rejects.toMatchObject({ + statusCode: 403, + }); + } + }); +}); diff --git a/src/backend/drivers/subdomain/SubdomainDriver.ts b/src/backend/drivers/subdomain/SubdomainDriver.ts index 82b2ef762..94894c4cb 100644 --- a/src/backend/drivers/subdomain/SubdomainDriver.ts +++ b/src/backend/drivers/subdomain/SubdomainDriver.ts @@ -87,7 +87,8 @@ const RESERVED_SUBDOMAINS = new Set([ * * - Owner (user_id) can read/write their own subdomains * - An app actor is further scoped to the rows it created (app_owner), for reads - * as well as writes — never widened past its own user + * as well as writes, unless the user granted it + * `subdomains-of-user::read|write` — never widened past its own user * - `system:es:write-all-owners` grants blanket write * - `read-all-subdomains` grants cross-user reads, and is the only thing that * does @@ -284,8 +285,15 @@ export class SubdomainDriver extends PuterDriver { predicate !== 'user-can-edit' && (await this.#hasPermission(actor, 'read-all-subdomains')); - // App actors only see subdomains they own; read-all bypasses scoping. - const appOwner = widenToAll ? undefined : actor.effectiveApp?.id; + // App actors only see subdomains they own unless granted the user's; + // read-all bypasses scoping. + let appOwner = widenToAll ? undefined : actor.effectiveApp?.id; + if ( + appOwner !== undefined && + (await this.#appHoldsUserSubdomains(actor, 'read')) + ) { + appOwner = undefined; + } // Worker deployments live in the same table but aren't sites — // they're listed through the workers driver instead. const listOpts = { @@ -536,6 +544,21 @@ export class SubdomainDriver extends PuterDriver { } } + /** + * Whether the user granted this app actor all of their own subdomains + * (`puter.perms.request('subdomains')`). Write implies read. + */ + async #appHoldsUserSubdomains( + actor: Actor, + access: 'read' | 'write', + ): Promise { + if (!actor.effectiveApp?.id || !actor.user?.uuid) return false; + return this.#hasPermission( + actor, + `subdomains-of-user:${actor.user.uuid}:${access}`, + ); + } + /** * Both grants are nested under "the caller owns this row" on purpose. * @@ -548,7 +571,8 @@ export class SubdomainDriver extends PuterDriver { * of its branches; this is the same rule, written as nesting. * * The inner check is the predicate `select` applies in SQL: an app sees - * what it created, not everything its user owns. + * what it created, not everything its user owns, unless the user granted it + * their subdomains. */ async #checkReadAccess( row: Record, @@ -558,6 +582,7 @@ export class SubdomainDriver extends PuterDriver { const app = actor.effectiveApp; if (!app?.id) return; if (app.id === row.app_owner) return; + if (await this.#appHoldsUserSubdomains(actor, 'read')) return; } // Cross-user read permission if (await this.#hasPermission(actor, 'read-all-subdomains')) return; @@ -606,6 +631,12 @@ export class SubdomainDriver extends PuterDriver { appOwnerId: row.app_owner, }); if (owns) return; + if ( + actor.user?.id === row.user_id && + (await this.#appHoldsUserSubdomains(actor, 'write')) + ) { + return; + } if (await this.#hasPermission(actor, 'system:es:write-all-owners')) { return; } diff --git a/src/backend/services/apps/AppService.filetypes.test.ts b/src/backend/services/apps/AppService.filetypes.test.ts new file mode 100644 index 000000000..fe8e08e87 --- /dev/null +++ b/src/backend/services/apps/AppService.filetypes.test.ts @@ -0,0 +1,101 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import type { PuterServer } from '../../server.js'; +import { setupTestServer } from '../../testUtil.js'; + +let server: PuterServer; + +beforeAll(async () => { + server = await setupTestServer(); +}); + +afterAll(async () => { + await server?.shutdown(); +}); + +const slug = () => Math.random().toString(36).slice(2, 10); + +// Ends the post-write window in which app rows aren't cached. +const endChangeWindow = async () => { + const redis = server.clients.redis; + for (const key of await redis.keys('apps:*:deleted')) await redis.del(key); +}; + +const filetypeQueries = async (fn: () => Promise) => { + const read = vi.spyOn(server.clients.db, 'read'); + const pread = vi.spyOn(server.clients.db, 'pread'); + try { + const result = await fn(); + const count = [...read.mock.calls, ...pread.mock.calls].filter( + ([sql]) => /FROM `app_filetype_association`/.test(String(sql)), + ).length; + return { result, count }; + } finally { + read.mockRestore(); + pread.mockRestore(); + } +}; + +describe('AppService.views filetype associations', () => { + it('takes them from the cached app row and reflects a change', async () => { + const name = `ftv-${slug()}`; + const app = await server.stores.app.create( + { name, title: name, index_url: `https://${name}.example.com/` }, + { ownerUserId: 1 }, + ); + await server.stores.app.setFiletypeAssociations(app.id, ['txt']); + await endChangeWindow(); + // Fill the row cache. + await server.stores.app.getByUid(app.uid); + await new Promise((r) => setTimeout(r, 20)); + + const warm = await filetypeQueries(async () => { + const row = await server.stores.app.getByUid(app.uid); + return server.services.app.views([row!], undefined); + }); + expect(warm.count).toBe(0); + expect(warm.result[0]!.view.filetype_associations).toEqual(['txt']); + + await server.stores.app.setFiletypeAssociations(app.id, ['csv', 'md']); + const row = await server.stores.app.getByUid(app.uid); + const [{ view }] = await server.services.app.views([row!], undefined); + expect([...view.filetype_associations].sort()).toEqual(['csv', 'md']); + }); + + it('queries rows that were loaded without them', async () => { + const name = `ftv-${slug()}`; + const app = await server.stores.app.create( + { name, title: name, index_url: `https://${name}.example.com/` }, + { ownerUserId: 1 }, + ); + await server.stores.app.setFiletypeAssociations(app.id, ['txt']); + await endChangeWindow(); + const { filetype_associations: _dropped, ...bare } = + (await server.stores.app.getByUid(app.uid))!; + await new Promise((r) => setTimeout(r, 20)); + + const { result, count } = await filetypeQueries(() => + server.services.app.views([bare], undefined), + ); + expect(count).toBe(1); + expect(result[0]!.view.filetype_associations).toEqual(['txt']); + }); +}); diff --git a/src/backend/services/apps/AppService.test.ts b/src/backend/services/apps/AppService.test.ts index 310c32803..e50071063 100644 --- a/src/backend/services/apps/AppService.test.ts +++ b/src/backend/services/apps/AppService.test.ts @@ -145,7 +145,8 @@ describe('AppService.views', () => { apps.map((app) => app.uid), ); expect(origins).toHaveBeenCalledTimes(1); - expect(filetypes).toHaveBeenCalledTimes(1); + // Rows from the app cache carry their filetype associations. + expect(filetypes).toHaveBeenCalledTimes(0); // Each row is its own origin's oldest app. for (const [i, { createdFromOrigin }] of views.entries()) { expect(createdFromOrigin).toBe( diff --git a/src/backend/services/apps/AppService.ts b/src/backend/services/apps/AppService.ts index 362406ba7..cad81b64e 100644 --- a/src/backend/services/apps/AppService.ts +++ b/src/backend/services/apps/AppService.ts @@ -106,10 +106,7 @@ export class AppService extends PuterService { ): Promise { if (apps.length === 0) return []; const [filetypes, canonicals, hostedUnavailable] = await Promise.all([ - filetypesByAppId ?? - (this.stores.app.getFiletypeAssociationsByIds( - apps.map((app) => app.id), - ) as Promise>), + filetypesByAppId ?? this.#filetypesFor(apps), this.#resolveCanonicalForIndexUrls(apps), hostedIndexUrlBackingsAreUnavailable({ apps, @@ -142,6 +139,24 @@ export class AppService extends PuterService { ); } + /** Associations from the cached rows; rows loaded without them are queried. */ + async #filetypesFor(apps: AppRow[]): Promise> { + const out = new Map(); + const missing: unknown[] = []; + for (const app of apps) { + const cached = app.filetype_associations; + if (Array.isArray(cached)) out.set(app.id, cached as string[]); + else missing.push(app.id); + } + if (missing.length > 0) { + const loaded = (await this.stores.app.getFiletypeAssociationsByIds( + missing, + )) as Map; + for (const [id, types] of loaded) out.set(id, types); + } + return out; + } + /** * Private-app entitlement. A row whose `index_url` belongs to a different, * private canonical app is gated as that app: pre-existing rows squatting diff --git a/src/backend/services/auth/AuthService.ts b/src/backend/services/auth/AuthService.ts index 4e23b7279..c2e85c3b2 100644 --- a/src/backend/services/auth/AuthService.ts +++ b/src/backend/services/auth/AuthService.ts @@ -1905,7 +1905,7 @@ export class AuthService extends PuterService { }, ): Promise { const { assetSessionUuid, authId } = - await this.#mintAssetSessionContext(claims.sessionUuid); + await this.#mintAssetSessionContext(claims.sessionUuid, kind); return this.services.token.sign('hosted-asset', { kind, version: '2', @@ -1923,26 +1923,12 @@ export class AuthService extends PuterService { } /** - * Materialize the `kind='asset'` session row that the cookie's - * `session_uuid` claim points at. Parented to the minting session so - * revoking it kills the cookie. The cascade is one level deep, so a cookie - * minted from a godmode token (itself under a web session) outlives logout - * of that web session; accepted, as app tokens outlive it too and the - * cookie only serves one app's files after an entitlement check. - * - * Both fields are `null` only when the caller didn't supply a session at - * all — the cookie still mints unparented and without an `auth_id` claim - * (matches v1 behavior for access-token-minted cookies that aren't tied to - * an interactive session). - * - * If the caller DID supply a `webSessionUuid` but the lookup misses (row - * revoked / expired between mint request and this lookup), throw — - * otherwise we'd quietly emit an unparented "ghost" cookie that has no - * revocation hook for 7 days. The extra check piggybacks on the lookup we - * already had to do, so no added perf cost. + * Public cookies reuse the parent; private cookies get a child with an + * asset expiry. */ async #mintAssetSessionContext( webSessionUuid: string | undefined, + kind: HostedAssetKind, ): Promise<{ assetSessionUuid: string | null; authId: string | null }> { if (!webSessionUuid) return { assetSessionUuid: null, authId: null }; const webSession = await this.stores.session.getByUuid(webSessionUuid); @@ -1953,6 +1939,8 @@ export class AuthService extends PuterService { } const authId = ((webSession as SessionRow).auth_id as string | null) ?? null; + if (kind === 'public') + return { assetSessionUuid: webSessionUuid, authId }; const row = await this.stores.session.create( (webSession as SessionRow).user_id as number, { diff --git a/src/backend/services/cache/CacheReplicationService.replica.test.ts b/src/backend/services/cache/CacheReplicationService.replica.test.ts new file mode 100644 index 000000000..69871b954 --- /dev/null +++ b/src/backend/services/cache/CacheReplicationService.replica.test.ts @@ -0,0 +1,155 @@ +import { randomUUID } from 'node:crypto'; +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import type { EventMap } from '../../clients/event/types'; +import type { PuterServer } from '../../server'; +import { setupTestServer } from '../../testUtil'; + +let server: PuterServer; +beforeAll(async () => { + server = await setupTestServer(); +}); +afterAll(async () => { + await server?.shutdown(); +}); +const user = () => + server.stores.user.create({ + username: `peer-${randomUUID().slice(0, 8)}`, + uuid: randomUUID(), + password: null, + }); +const replicate = async (mutate: () => Promise) => { + const emitted = vi.spyOn(server.clients.event, 'emit'); + let updates: EventMap['outer.cacheUpdate'][]; + try { + await mutate(); + updates = emitted.mock.calls + .filter(([key]) => key === 'outer.cacheUpdate') + .map( + ([, data]) => + structuredClone(data) as EventMap['outer.cacheUpdate'], + ); + } finally { + emitted.mockRestore(); + } + const keys = [...new Set(updates.flatMap((data) => data.cacheKey))]; + for (const key of keys) await server.clients.redis.del(key); + for (const data of updates) { + await server.clients.event.emitAndWait('outer.cacheUpdate', data, { + from_outside: true, + }); + } +}; +describe('peer cache refills during replica lag', () => { + it.each(['user', 'app'] as const)( + 'does not revive a revoked %s grant', + async (kind) => { + const issuer = await user(); + const holder = await user(); + const app = await server.stores.app.create( + { + title: 'Peer', + name: `peer-${randomUUID()}`, + index_url: 'https://peer.test/', + }, + { ownerUserId: issuer.id }, + ); + const store = server.stores.permission; + const permission = `fs:${randomUUID()}:read`; + const read = () => + kind === 'user' + ? store.readLinkedUserUserPerms(holder.id, [permission]) + : store.readUserAppPerms(holder.id, app.id, [permission]); + if (kind === 'user') + await store.upsertUserUserPerm( + holder.id, + issuer.id, + permission, + {}, + ); + else + await store.upsertUserAppPerm( + holder.id, + app.id, + permission, + {}, + ); + const table = + kind === 'user' + ? 'user_to_user_permissions' + : 'user_to_app_permissions'; + const snapshot = await server.clients.db.pread( + `SELECT * FROM ${table}`, + ); + await replicate(() => + kind === 'user' + ? store.deleteUserUserPermByHolder( + holder.id, + permission, + issuer.id, + ) + : store.deleteUserAppPerm(holder.id, app.id, permission), + ); + const db = server.clients.db; + const original = db.read.bind(db); + const primary = vi.spyOn(db, 'pread').mockImplementation(original); + const lag = vi + .spyOn(db, 'read') + .mockImplementation(async (sql, params) => + sql.includes(table) ? snapshot : original(sql, params), + ); + try { + expect(await read()).toEqual([]); + if (kind === 'user') + expect( + await store.readLinkedUserUserPermsForHolders( + [holder.id], + [permission], + ), + ).toEqual([]); + } finally { + lag.mockRestore(); + primary.mockRestore(); + } + expect(await read()).toEqual([]); + }, + ); + it('reads updated app rows from the primary after the peer broadcast', async () => { + const owner = await user(); + const app = await server.stores.app.create( + { + title: 'Peer', + name: `peer-${randomUUID()}`, + index_url: 'https://old.test/', + }, + { ownerUserId: owner.id }, + ); + const db = server.clients.db; + const snapshot = await db.pread('SELECT * FROM apps WHERE id = ?', [ + app.id, + ]); + await replicate(() => + server.stores.app.update(app.id, { + index_url: 'https://new.test/', + }), + ); + const original = db.read.bind(db); + const primary = vi.spyOn(db, 'pread').mockImplementation(original); + const lag = vi + .spyOn(db, 'read') + .mockImplementation(async (sql, params) => + sql.includes('FROM `apps`') ? snapshot : original(sql, params), + ); + try { + expect((await server.stores.app.getByUid(app.uid))?.index_url).toBe( + 'https://new.test/', + ); + expect( + (await server.stores.app.getByUids([app.uid])).get(app.uid) + ?.index_url, + ).toBe('https://new.test/'); + } finally { + lag.mockRestore(); + primary.mockRestore(); + } + }); +}); diff --git a/src/backend/services/cache/CacheReplicationService.test.ts b/src/backend/services/cache/CacheReplicationService.test.ts index 773fa32f1..3032501b7 100644 --- a/src/backend/services/cache/CacheReplicationService.test.ts +++ b/src/backend/services/cache/CacheReplicationService.test.ts @@ -20,6 +20,7 @@ import { v4 as uuidv4 } from 'uuid'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { PuterServer } from '../../server.ts'; +import { CACHE_TOMBSTONE_TTL_SECONDS } from '../../stores/types.ts'; import { setupTestServer } from '../../testUtil.ts'; describe('CacheReplicationService', () => { @@ -81,7 +82,11 @@ describe('CacheReplicationService', () => { await server.clients.event.emitAndWait( 'outer.cacheUpdate', - { cacheKey: [key], data: '1', ttlSeconds: 60 } as never, + { + cacheKey: [key], + data: '1', + ttlSeconds: CACHE_TOMBSTONE_TTL_SECONDS, + } as never, { from_outside: true }, ); @@ -89,6 +94,36 @@ describe('CacheReplicationService', () => { expect(await server.clients.redis.get(key)).toBe('1'); }); + it('times a tombstone from its own receipt, not the sender payload', async () => { + const key = `cacherepl-${uuidv4()}:deleted`; + + await server.clients.event.emitAndWait( + 'outer.cacheUpdate', + { cacheKey: [key], data: '1', ttlSeconds: 600 } as never, + { from_outside: true }, + ); + + const ttl = await server.clients.redis.ttl(key); + expect(ttl).toBeGreaterThan(0); + expect(ttl).toBeLessThanOrEqual(CACHE_TOMBSTONE_TTL_SECONDS); + }); + + it('evicts a row refilled before a delayed tombstone arrives', async () => { + const key = `cacherepl-${uuidv4()}`; + await server.clients.redis.set(key, 'stale refill'); + await server.clients.event.emitAndWait( + 'outer.cacheUpdate', + { + cacheKey: [`${key}:deleted`], + data: '1', + ttlSeconds: CACHE_TOMBSTONE_TTL_SECONDS, + }, + { from_outside: true }, + ); + expect(await server.clients.redis.get(key)).toBeNull(); + expect(await server.clients.redis.get(`${key}:deleted`)).toBe('1'); + }); + it('drops a tombstone when the peer cleared it', async () => { const key = `cacherepl-${uuidv4()}:deleted`; await server.clients.redis.set(key, '1'); diff --git a/src/backend/services/cache/CacheReplicationService.ts b/src/backend/services/cache/CacheReplicationService.ts index 259447531..5bba9ffba 100644 --- a/src/backend/services/cache/CacheReplicationService.ts +++ b/src/backend/services/cache/CacheReplicationService.ts @@ -18,23 +18,13 @@ */ import { + CACHE_TOMBSTONE_SUFFIX, CACHE_TOMBSTONE_TTL_SECONDS, isCacheTombstoneKey, } from '../../stores/types'; import { PuterService } from '../types'; -/** - * Applies `outer.cacheUpdate` from peer regions. - * - * `PuterStore.publishCacheKeys({ broadcast: true })` writes its own cluster's - * Redis and emits the same mutation for peers; `BroadcastService` ships it over - * a webhook. Nothing consumed it on the far side, so cross-region cache - * replication silently no-oped. - * - * Deletes rather than re-writing the sender's payload: their value was derived - * from their own replica, so forcing a re-read here is the conservative move. - * Tombstones are the exception — they carry no row, only the fact of a delete. - */ +/** Applies peer invalidations, evicting rows that raced a delayed tombstone. */ export class CacheReplicationService extends PuterService { override onServerStart(): void { this.clients.event.on('outer.cacheUpdate', (_key, data, meta) => { @@ -46,17 +36,18 @@ export class CacheReplicationService extends PuterService { ); if (keys.length === 0) return; const hasData = (data as { data?: unknown })?.data !== undefined; - void this.#invalidate(keys, hasData); + return this.#invalidate(keys, hasData); }); } - // Pipelined rather than a multi-key DEL, which would CROSSSLOT on Valkey. + // Pipelined so keys can live in different cache slots. async #invalidate(keys: string[], hasData: boolean): Promise { try { const pipeline = this.clients.redis.pipeline(); for (const key of keys) { if (hasData && isCacheTombstoneKey(key)) { pipeline.set(key, '1', 'EX', CACHE_TOMBSTONE_TTL_SECONDS); + pipeline.del(key.slice(0, -CACHE_TOMBSTONE_SUFFIX.length)); } else { pipeline.del(key); } diff --git a/src/backend/services/fs/FSService.ts b/src/backend/services/fs/FSService.ts index 8fe3ad5d7..548739514 100644 --- a/src/backend/services/fs/FSService.ts +++ b/src/backend/services/fs/FSService.ts @@ -170,6 +170,9 @@ const isAppDataRootPath = (path: string): boolean => { ); }; +/** Ends a subtree removal whose `proceed` check said to stop. */ +class RemovalStopped extends Error {} + const isNoSuchKeyError = (err: unknown): boolean => { if (!err || typeof err !== 'object') return false; const e = err as { name?: unknown; Code?: unknown }; @@ -303,12 +306,18 @@ export class FSService extends PuterService { * Overridable so tests can split a page of directories. */ static SUBTREE_WALK_PARENTS = 100; + /** Rows one page of `listDirectoryPages` reads. Overridable for tests. */ + static LISTING_PAGE_SIZE = 1000; declare protected stores: LayerInstances; declare protected services: LayerInstances; override onServerStart(): void { this.#registerPermissionRules(); + this.clients.event.on('app.uninstalled', async (_key, data) => { + if (!data?.deleteData) return; + void this.#removeUninstalledAppData(data.userId, data.appUid); + }); } /** @@ -3731,6 +3740,19 @@ export class FSService extends PuterService { return this.stores.fsEntry.listChildrenPage(parentUid, options); } + /** A directory's children in name order, for listings with no paging. */ + async *listDirectoryPages(parentUid: string): AsyncGenerator { + let cursor: string | undefined; + do { + const page = await this.stores.fsEntry.listChildrenPage(parentUid, { + limit: FSService.LISTING_PAGE_SIZE, + cursor, + }); + yield page.entries; + cursor = page.cursor; + } while (cursor); + } + async countDirectory(parentUid: string): Promise { return this.stores.fsEntry.countChildren(parentUid); } @@ -3745,6 +3767,7 @@ export class FSService extends PuterService { path: string, options: { limit?: number; + offset?: number; cursor?: string | null; maxDepth: number; sortBy?: 'name' | 'modified' | 'type' | 'size' | null; @@ -3876,7 +3899,8 @@ export class FSService extends PuterService { { bucket: this.stores.s3Object.resolveBucket(entry.bucket), objectKey, - range: options.range, + // The store refuses every range on a zero-byte object. + range: entry.size === 0 ? undefined : options.range, }, this.stores.s3Object.resolveRegion(entry.bucketRegion), ); @@ -4548,27 +4572,111 @@ export class FSService extends PuterService { /** * Delete a directory's contents a page at a time, leaves first, so neither * memory nor a single statement grows with the subtree: a page's files go - * as it is read, a directory once everything under it has. + * as it is read, a directory once everything under it has. With `proceed`, + * each batch is deleted only while it answers true; false if it stopped. */ async #removeDescendants( root: FSEntry, survivors: () => Promise, - ): Promise { + proceed?: () => Promise, + ): Promise { const removeEntries = async (entries: FSEntry[]) => { if (entries.length === 0) return; + if (proceed && !(await proceed())) throw new RemovalStopped(); // S3 objects are batched per bucket+region. await this.#removeDescendantsStorage(entries); await this.stores.fsEntry.deleteEntries(entries); this.#emitRemoveEvents(entries, survivors); }; - await this.#walkSubtree( - root, - async (page) => { - await removeEntries(page.filter((entry) => !entry.isDir)); - return page.filter((entry) => entry.isDir); - }, - removeEntries, - ); + try { + await this.#walkSubtree( + root, + async (page) => { + await removeEntries(page.filter((entry) => !entry.isDir)); + return page.filter((entry) => entry.isDir); + }, + removeEntries, + ); + } catch (err) { + if (err instanceof RemovalStopped) return false; + throw err; + } + return true; + } + + /** + * Delete `//AppData/` once the user removed the app with its + * data. Stops if the user authorizes the app again, so files written after + * that are kept. A failure is logged, not retried. + */ + async #removeUninstalledAppData( + userId: number, + appUid: string, + ): Promise { + if (!userId || !appUid || ['.', '..'].includes(appUid)) return; + if (appUid.includes('/')) return; + try { + const username = (await this.stores.user.getById(userId))?.username; + if (!username) return; + const path = `/${username}/AppData/${appUid}`; + const root = await this.stores.fsEntry.getEntryByPath(path, { + lineage: false, + skipCache: true, + }); + // The path column's collation also matches other spellings. + if (!root?.isDir || root.path !== path || root.userId !== userId) + return; + if (await this.#subtreeHostsContent(root)) { + console.warn( + `[fs] kept uninstalled app data at ${path}: it hosts a site or worker`, + ); + return; + } + + const appId = (await this.stores.app.getByUid(appUid))?.id; + const stillUninstalled = async () => + typeof appId !== 'number' || + ( + await this.stores.permission.listUserAppPermsFromPrimary( + userId, + appId, + ) + ).length === 0; + if (!(await stillUninstalled())) return; + + const survivors = this.#memoizedChain(pathPosix.dirname(path)); + const emptied = await this.#removeDescendants( + root, + survivors, + stillUninstalled, + ); + if (!emptied || !(await stillUninstalled())) return; + await this.stores.fsEntry.deleteEntry(root); + this.#emitRemoveEvents([root], survivors); + } catch (err) { + console.warn('[fs] uninstalled app data removal failed', err); + } + } + + /** Whether a site or worker is rooted at `root` or anywhere below it. */ + async #subtreeHostsContent(root: FSEntry): Promise { + const hosts = (entry: FSEntry) => + entry.subdomains.length > 0 || entry.workers.length > 0; + if (hosts(root)) return true; + try { + await this.#walkSubtree( + root, + async (page) => { + if (page.some(hosts)) throw new RemovalStopped(); + return page.filter((entry) => entry.isDir); + }, + async () => {}, + ); + } catch (err) { + if (err instanceof RemovalStopped) return true; + throw err; + } + return false; } /** @@ -5193,6 +5301,8 @@ export class FSService extends PuterService { : `${destinationParent.path}/${name}`; const collision = await this.stores.fsEntry.getEntryByPath(targetPath); + // Overwriting the source would delete it before it is read. + const replacesSource = collision?.uuid === source.uuid; // A copy duplicates the bytes for real, so it costs the same against // the allowance as writing them. Check before the overwrite below @@ -5208,14 +5318,14 @@ export class FSService extends PuterService { await this.#assertStorageAllowance( allowance.ownerId, await this.#entryStorageSize(source), - collision && input.overwrite + collision && input.overwrite && !replacesSource ? await this.#entryStorageSize(collision) : 0, allowance.max, ); if (collision) { - if (input.overwrite) { + if (input.overwrite && !replacesSource) { this.#assertNotReplacingAncestor(source, collision); await this.remove(userId, { entry: collision, @@ -5223,6 +5333,10 @@ export class FSService extends PuterService { }); } else if (input.dedupeName) { name = await this.#findDedupedName(destinationParent, name); + } else if (input.overwrite) { + throw new HttpError(400, 'Cannot copy an item over itself', { + legacyCode: 'bad_request', + }); } else { // v1 wire contract, as in move() above. throw new HttpError( diff --git a/src/backend/services/fs/FSService.uninstallAppData.test.ts b/src/backend/services/fs/FSService.uninstallAppData.test.ts new file mode 100644 index 000000000..4b04413c0 --- /dev/null +++ b/src/backend/services/fs/FSService.uninstallAppData.test.ts @@ -0,0 +1,234 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { + afterAll, + afterEach, + beforeAll, + describe, + expect, + it, + vi, +} from 'vitest'; +import { setupPuterTestEnv, type PuterTestEnv } from '../../testUtil.js'; +import { FSService } from './FSService.js'; + +/** Removing an app "with its data" takes its AppData folder too. */ +describe('uninstalling an app with its data', () => { + let env: PuterTestEnv; + const pageSize = FSService.SUBTREE_PAGE_SIZE; + + beforeAll(async () => { + env = await setupPuterTestEnv(); + }, 120_000); + + afterAll(async () => { + await env?.shutdown(); + }); + + afterEach(() => { + FSService.SUBTREE_PAGE_SIZE = pageSize; + vi.restoreAllMocks(); + }); + + const post = (path: string, token: string, body: unknown) => + fetch(new URL(path, env.apiOrigin), { + method: 'POST', + headers: { + authorization: `Bearer ${token}`, + 'content-type': 'application/json', + }, + body: JSON.stringify(body), + }); + + const exists = async (path: string) => + (await env.server.stores.fsEntry.getEntryByPath(path, { + skipCache: true, + })) !== null; + + const launch = async (origin: string) => { + const launched = await post( + '/auth/get-user-app-token', + env.users.user.token, + { origin }, + ); + expect(launched.status).toBe(200); + return (await launched.json()) as { token: string; app_uid: string }; + }; + + /** + * An app with `count` files in its AppData, plus a folder whose name + * extends its uid. + */ + const installWithFiles = async (count: number) => { + const { username, token } = env.users.user; + const origin = `https://uninstall-${Math.random().toString(36).slice(2, 10)}.example.com`; + const app = await launch(origin); + expect( + ( + await post('/auth/request-app-root-dir', app.token, { + app_uid: app.app_uid, + }) + ).status, + ).toBe(200); + const root = `/${username}/AppData/${app.app_uid}`; + const files: string[] = []; + for (let i = 0; i < count; i++) { + const path = `${root}/notes-${i}.txt`; + const written = await post('/fs/write', app.token, { + fileMetadata: { path, size: 1, contentType: 'text/plain' }, + fileContent: 'x', + }); + expect(written.status).toBe(200); + files.push(path); + } + const lookalike = `${root}-keep`; + expect( + (await post('/fs/mkdir', token, { path: lookalike })).status, + ).toBe(200); + return { app, origin, root, files, lookalike }; + }; + + const uninstall = (appUid: string, deleteData: boolean) => + post('/auth/revoke-user-app', env.users.user.token, { + app_uid: appUid, + permission: '*', + deleteData, + }); + + it("deletes exactly the app's AppData folder", async () => { + const { app, root, files, lookalike } = await installWithFiles(3); + + expect((await uninstall(app.app_uid, true)).status).toBe(200); + + await vi.waitFor(async () => expect(await exists(root)).toBe(false)); + for (const file of files) expect(await exists(file)).toBe(false); + expect(await exists(lookalike)).toBe(true); + }); + + /** A site rooted at `dir`, serving an index.html written by the app. */ + const hostSite = async (appToken: string, dir: string) => { + const written = await post('/fs/write', appToken, { + fileMetadata: { + path: `${dir}/index.html`, + size: 6, + contentType: 'text/html', + }, + fileContent: 'hosted', + }); + expect(written.status).toBe(200); + const entry = (await env.server.stores.fsEntry.getEntryByPath(dir, { + skipCache: true, + }))!; + const subdomain = `uninstall-${Math.random().toString(36).slice(2, 10)}`; + await env.server.stores.subdomain.create({ + userId: entry.userId, + subdomain, + rootDirId: entry.id, + }); + const port = new URL(env.origin).port; + const serve = () => + fetch( + `http://${subdomain}.site.puter.localhost:${port}/index.html`, + ); + const served = await serve(); + expect(served.status).toBe(200); + expect(await served.text()).toBe('hosted'); + return serve; + }; + + it.each([ + ['a folder inside it', '/www/public'], + ['the folder itself', ''], + ])('keeps AppData that hosts a site at %s', async (_case, sub) => { + const { app, root, files } = await installWithFiles(2); + const siteDir = `${root}${sub}`; + if (sub) { + const made = await post('/fs/mkdir', app.token, { + path: siteDir, + create_missing_parents: true, + }); + expect(made.status).toBe(200); + } + const serve = await hostSite(app.token, siteDir); + const warn = vi.spyOn(console, 'warn'); + + expect((await uninstall(app.app_uid, true)).status).toBe(200); + + await vi.waitFor(() => + expect(warn).toHaveBeenCalledWith( + expect.stringContaining(`kept uninstalled app data at ${root}`), + ), + ); + const served = await serve(); + expect(served.status).toBe(200); + expect(await served.text()).toBe('hosted'); + for (const file of files) expect(await exists(file)).toBe(true); + }); + + it('keeps the folder without deleteData', async () => { + const { app, root, files } = await installWithFiles(1); + const checks = vi.spyOn( + env.server.stores.fsEntry, + 'deleteEntries', + ); + + expect((await uninstall(app.app_uid, false)).status).toBe(200); + + expect(checks).not.toHaveBeenCalled(); + expect(await exists(root)).toBe(true); + expect(await exists(files[0]!)).toBe(true); + }); + + it('stops once the user authorizes the app again', async () => { + FSService.SUBTREE_PAGE_SIZE = 1; + const { app, origin, root, files } = await installWithFiles(4); + const { fsEntry, permission } = env.server.stores; + + // The app is opened again right after its first file is deleted. + const deleteEntries = fsEntry.deleteEntries.bind(fsEntry); + let reopened = false; + vi.spyOn(fsEntry, 'deleteEntries').mockImplementation( + async (entries) => { + await deleteEntries(entries); + if (!reopened) { + reopened = true; + await launch(origin); + } + }, + ); + const listGrants = + permission.listUserAppPermsFromPrimary.bind(permission); + let sawGrant = false; + vi.spyOn(permission, 'listUserAppPermsFromPrimary').mockImplementation( + async (userId, appId) => { + const rows = await listGrants(userId, appId); + if (rows.length > 0) sawGrant = true; + return rows; + }, + ); + + expect((await uninstall(app.app_uid, true)).status).toBe(200); + + await vi.waitFor(() => expect(sawGrant).toBe(true)); + const left = (await Promise.all(files.map(exists))).filter(Boolean); + expect(left).toHaveLength(files.length - 1); + expect(await exists(root)).toBe(true); + }); +}); diff --git a/src/backend/services/permission/PermissionService.defaultGrants.test.ts b/src/backend/services/permission/PermissionService.defaultGrants.test.ts index 8947f60dd..838eaffeb 100644 --- a/src/backend/services/permission/PermissionService.defaultGrants.test.ts +++ b/src/backend/services/permission/PermissionService.defaultGrants.test.ts @@ -195,7 +195,13 @@ describe('check on default grants', () => { it('answers a default grant without reading the scan cache', async () => { const getScanCache = vi.spyOn(server.stores.permission, 'getScanCache'); try { - for (const actor of [actors.user, actors.looseUser, actors.app]) { + // A full-access token holds its issuer's defaults. + for (const actor of [ + actors.user, + actors.looseUser, + actors.app, + actors.fullToken, + ]) { expect( await permService.check( actor, @@ -211,10 +217,7 @@ describe('check on default grants', () => { actors.scopedToken, 'service:puter-chat-completion:ii:puter-chat-completion', ], - [ - actors.fullToken, - 'service:puter-chat-completion:ii:puter-chat-completion', - ], + [actors.fullToken, `fs:${fileUid}:read`], [actors.app, 'feature:something'], [actors.user, `fs:${fileUid}:read`], ] as const) { diff --git a/src/backend/services/permission/PermissionService.test.ts b/src/backend/services/permission/PermissionService.test.ts index 40239d1e5..dbf9d30cc 100644 --- a/src/backend/services/permission/PermissionService.test.ts +++ b/src/backend/services/permission/PermissionService.test.ts @@ -2113,6 +2113,31 @@ describe('PermissionService — scan paths', () => { ).toBe(false); }); + it('answers default grants for full-access tokens without reading the scan cache', async () => { + const { actor } = await makeGroupedUser(); + const token: Actor = { + user: actor.user, + accessToken: { + uid: `tok-${uuidv4()}`, + issuer: actor, + authorized: null, + fullAccess: true, + }, + }; + const get = vi.spyOn(server.clients.redis, 'get'); + try { + expect( + await permService.check( + token, + 'service:puter-kvstore:ii:puter-kvstore', + ), + ).toBe(true); + expect(get).not.toHaveBeenCalled(); + } finally { + get.mockRestore(); + } + }); + it('a full-access token resolves anything its issuer holds, and nothing more', async () => { const { row, actor } = await makeGroupedUser(); const held = `zztest:full-${uuidv4()}:ii:read`; diff --git a/src/backend/services/permission/PermissionService.ts b/src/backend/services/permission/PermissionService.ts index 80f35d252..cb8a6cc6a 100644 --- a/src/backend/services/permission/PermissionService.ts +++ b/src/backend/services/permission/PermissionService.ts @@ -239,7 +239,10 @@ export class PermissionService extends PuterService { permissionOptions: string | string[], scanOptions?: ScanOptions, ): Promise { - if (this.#heldByDefault(actor, permissionOptions)) return true; + const defaultActor = actor.accessToken?.fullAccess + ? actor.accessToken.issuer + : actor; + if (this.#heldByDefault(defaultActor, permissionOptions)) return true; const reading = await this.scan( actor, permissionOptions, @@ -626,8 +629,11 @@ export class PermissionService extends PuterService { // Account-management endpoints stay closed because they gate on actor // type (requireUserActor / session cookie), not permissions. if (actor.accessToken.fullAccess) { - for (const permission of options) { - const issuerReading = await this.scan(issuerActor, permission); + const issuerReadings = await Promise.all( + options.map((permission) => this.scan(issuerActor, permission)), + ); + for (const [index, permission] of options.entries()) { + const issuerReading = issuerReadings[index]; reading.push({ $: 'path', via: 'access-token', diff --git a/src/backend/services/permission/appGrantable.ts b/src/backend/services/permission/appGrantable.ts index c7917323b..2822307c9 100644 --- a/src/backend/services/permission/appGrantable.ts +++ b/src/backend/services/permission/appGrantable.ts @@ -41,8 +41,8 @@ type FsMode = (typeof FS_MODES)[number]; /** * A permission a user may grant an app through the consent prompt, parsed. No - * app-actor check reads `apps-of-user::read` or `subdomains-of-user`; - * they stay grantable because apps already request them. + * app-actor check reads `apps-of-user::read`; it stays grantable because + * apps already request it. `SubdomainDriver` honors `subdomains-of-user`. */ export type AppGrantablePermission = | { family: 'fs'; target: string; mode: FsMode } diff --git a/src/backend/services/share/ShareService.test.ts b/src/backend/services/share/ShareService.test.ts index e8b09ffb9..6a6894844 100644 --- a/src/backend/services/share/ShareService.test.ts +++ b/src/backend/services/share/ShareService.test.ts @@ -6516,4 +6516,153 @@ describe('ShareService', () => { } }); }); + + describe('a team share seen by its own members', () => { + // Teams is optional, so the group, its members and its grant are + // written directly and the team store is stood up around them. + const makeTeam = async ( + ownerId: number, + memberIds: number[], + ): Promise<{ id: number; uid: string }> => { + const uid = uuidv4(); + await server.clients.db.write( + 'INSERT INTO `group` (`uid`, `owner_user_id`, `kind`, ' + + '`name`, `extra`, `metadata`) VALUES (?, ?, ?, ?, ?, ?)', + [uid, ownerId, 'team', 'Own team', '{}', '{}'], + ); + const [group] = (await server.clients.db.read( + 'SELECT id FROM `group` WHERE uid = ?', + [uid], + )) as { id: number }[]; + for (const userId of [ownerId, ...memberIds]) { + await server.clients.db.write( + 'INSERT INTO `jct_user_group` (`user_id`, `group_id`) ' + + 'VALUES (?, ?)', + [userId, group.id], + ); + } + return { id: Number(group.id), uid }; + }; + + const shareWithTeam = async ( + issuerId: number, + team: { id: number }, + entry: { id: number; uuid: string }, + ) => { + await server.stores.share.upsertActiveGroup({ + issuerUserId: issuerId, + holderGroupId: team.id, + fsentryId: entry.id, + mode: 'read', + }); + await server.stores.permission.upsertUserGroupPerm( + team.id, + issuerId, + `fs:${entry.uuid}:read`, + {}, + ); + }; + + const standUpTeams = (team: { id: number; uid: string }) => { + installSeatStub(server as never, []); + const store = server.stores.team as unknown as Record< + string, + unknown + >; + store.listGroupIdsForUser = async () => [team.id]; + store.getByIdsIncludingDeleted = async (ids: number[]) => + new Map( + ids + .filter((id) => id === team.id) + .map((id) => [ + id, + { + id, + uid: team.uid, + name: 'Own team', + handle: null, + }, + ]), + ); + }; + + const inbox = (actor: Actor) => + runWithContext({ actor }, () => + server.services.share.listSharedWithMe(actor, { + includeTotal: true, + }), + ); + + it("leaves the sharer's own items out of their Shared view and total", async () => { + const sharer = await makeUser(); + const mate = await makeUser(); + const team = await makeTeam(sharer.user.id, [mate.user.id]); + standUpTeams(team); + + const own = await makeFile(sharer.user); + await shareWithTeam(sharer.user.id, team, own); + // A teammate's share of something the sharer owns is theirs too. + const delegated = await makeFile(sharer.user); + await shareWithTeam(mate.user.id, team, delegated); + const theirs = await makeFile(mate.user); + await shareWithTeam(mate.user.id, team, theirs); + + const mine = await inbox(sharer.actor); + expect(mine.items.map((row) => row.entryUid)).toEqual([ + theirs.uuid, + ]); + expect(mine.total).toBe(1); + expect(mine.items[0].holderTeam).toEqual({ + uid: team.uid, + name: 'Own team', + handle: null, + }); + + // The teammate still has what was shared with them, and not what + // they shared themselves. + const mates = await inbox(mate.actor); + expect(mates.items.map((row) => row.entryUid)).toEqual([own.uuid]); + expect(mates.total).toBe(1); + }); + + it('refuses to let a member leave a share only their team holds', async () => { + const sharer = await makeUser(); + const mate = await makeUser(); + const team = await makeTeam(sharer.user.id, [mate.user.id]); + standUpTeams(team); + const file = await makeFile(sharer.user); + await shareWithTeam(sharer.user.id, team, file); + + await expect( + unshare(mate.actor, { + uid: file.uuid, + recipient: { username: mate.user.username }, + }), + ).rejects.toMatchObject({ + statusCode: 409, + legacyCode: 'team_share', + }); + expect(await canRead(mate.actor, file.path)).toBe(true); + }); + + it('still lets a member leave a direct share they also hold through the team', async () => { + const sharer = await makeUser(); + const mate = await makeUser(); + const team = await makeTeam(sharer.user.id, [mate.user.id]); + standUpTeams(team); + const file = await makeFile(sharer.user); + await shareWithTeam(sharer.user.id, team, file); + await share(sharer.actor, { + uid: file.uuid, + recipient: { email: mate.email }, + mode: 'read', + }); + + const result = await unshare(mate.actor, { + uid: file.uuid, + recipient: { username: mate.user.username }, + }); + expect(result.revoked).toBeGreaterThan(0); + }); + }); }); diff --git a/src/backend/services/share/ShareService.ts b/src/backend/services/share/ShareService.ts index e0e27d14c..3d0d86ca7 100644 --- a/src/backend/services/share/ShareService.ts +++ b/src/backend/services/share/ShareService.ts @@ -1350,7 +1350,10 @@ export class ShareService extends PuterService { if (resolved.kind === 'anyone') { await this.#assertCanManage(actor, entry); this.#assertOwnsLinkShare(actor, entry, issuerId); - const removed = await this.stores.share.deleteAnyone(entry.id); + const removed = await this.stores.share.deleteAnyone( + entry.id, + entry.uuid, + ); return { revoked: removed ? 1 : 0 }; } // Nothing was granted, so there is only the invitation to take back. @@ -1421,6 +1424,22 @@ export class ShareService extends PuterService { ), ] : await this.#issuersGranting(entry, holder.id); + // Held only through a team: there is nothing of the caller's own to + // drop, and the team's grant is not theirs to withdraw. + if ( + isLeaving && + issuers.length === 0 && + ( + await this.stores.permission.readUserGroupPerms( + holder.id, + entryPermissions(entry.uuid), + ) + ).length > 0 + ) { + throw new HttpError(409, 'cannot leave a share held by your team', { + legacyCode: 'team_share', + }); + } if (!isOwner && !isLeaving) { issuers = issuers.filter((issuer) => issuer === issuerId); } @@ -1930,6 +1949,16 @@ export class ShareService extends PuterService { groupIds, }); + // Named, so a client can tell a team share from one made to the caller. + const teamsLookup = awaitLater( + this.#teamsByIds( + page.items + .filter((row: OutboundShareRow) => row.holder_group_id) + .map((row: OutboundShareRow) => + Number(row.holder_group_id), + ), + ), + ); const entries = await this.stores.fsEntry.getEntriesByIds( page.items.map((row: { fsentry_id: number }) => row.fsentry_id), ); @@ -1952,6 +1981,7 @@ export class ShareService extends PuterService { this.#liveGrants(holderId, [...entries.values()]), this.#reachableBy(actor, [...entries.values()]), ]); + const teamsById = await teamsLookup; const items: ResolvedShare[] = []; for (const row of page.items) { @@ -1959,6 +1989,9 @@ export class ShareService extends PuterService { if (!entry || this.#isTrashed(entry)) continue; if (!live.has(entry.uuid)) continue; if (!reachable.has(entry.uuid)) continue; + const team = row.holder_group_id + ? teamsById.get(Number(row.holder_group_id)) + : undefined; items.push( this.#resolvedShareRow(row, entry, issuers, { entryMeta: true, @@ -1966,6 +1999,15 @@ export class ShareService extends PuterService { // the recipient's. provenance: false, holderUsername: actor.user.username ?? null, + ...(team + ? { + holderTeam: { + uid: team.uid, + name: team.name, + handle: team.handle, + }, + } + : {}), }), ); } @@ -2242,7 +2284,10 @@ export class ShareService extends PuterService { if (row.anyone) { await this.#assertCanManage(actor, entry); this.#assertOwnsLinkShare(actor, entry, userId); - const removed = await this.stores.share.deleteAnyone(entry.id); + const removed = await this.stores.share.deleteAnyone( + entry.id, + entry.uuid, + ); return { revoked: removed ? 1 : 0 }; } diff --git a/src/backend/stores/app/AppStore.cachedLookups.test.js b/src/backend/stores/app/AppStore.cachedLookups.test.js new file mode 100644 index 000000000..18abf98c7 --- /dev/null +++ b/src/backend/stores/app/AppStore.cachedLookups.test.js @@ -0,0 +1,400 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import { setupTestServer } from '../../testUtil.ts'; + +let server; +let appStore; +let db; +let redis; + +beforeAll(async () => { + server = await setupTestServer(); + appStore = server.stores.app; + db = server.clients.db; + redis = server.clients.redis; + // ioredis-mock is shared across servers in one process; ids restart. + for (const key of await redis.keys('apps:*')) await redis.del(key); +}); + +afterAll(async () => { + await server?.shutdown(); +}); + +let ownerSeq = 7100; +const freshOwner = () => ownerSeq++; +const slug = () => Math.random().toString(36).slice(2, 10); + +const createApp = (ownerUserId, fields = {}, opts = {}) => { + const name = `cl-${slug()}`; + return appStore.create( + { + name, + title: name, + index_url: `https://${name}.example.com/`, + ...fields, + }, + { ownerUserId, ...opts }, + ); +}; + +const rawMetadata = async (id) => { + const [row] = await db.read('SELECT `metadata` FROM `apps` WHERE `id` = ?', [ + id, + ]); + const value = row?.metadata; + return typeof value === 'string' ? JSON.parse(value) : (value ?? null); +}; + +/** Replica reads of `apps` (and filetypes) return `stale`; the primary is real. */ +const withLaggingReplica = async (stale, fn) => { + const realRead = db.read.bind(db); + // SQLite's `pread` goes through `read`; keep it on the real one. + const preadSpy = vi.spyOn(db, 'pread').mockImplementation(realRead); + const readSpy = vi + .spyOn(db, 'read') + .mockImplementation(async (sql, params) => + /FROM `apps`/.test(sql) + ? stale.apps.map((r) => ({ ...r })) + : /FROM `app_filetype_association`/.test(sql) + ? stale.filetypes.map((r) => ({ ...r })) + : realRead(sql, params), + ); + try { + return await fn(); + } finally { + readSpy.mockRestore(); + preadSpy.mockRestore(); + } +}; + +const settle = () => new Promise((r) => setTimeout(r, 20)); + +// Ends the post-write window in which reads go to the primary and nothing is +// cached. ioredis-mock mishandles a pipelined SET on a key whose EX lapsed, so +// tests drop the markers instead of waiting them out. +const endChangeWindow = async () => { + for (const key of await redis.keys('apps:*:deleted')) await redis.del(key); +}; + +describe('AppStore creating-app marker', () => { + it('shows metadata written beside the markers by a raw update', async () => { + const owner = freshOwner(); + const parent = await createApp(owner); + const child = await createApp(owner, {}, { appOwner: parent.id }); + const stored = await rawMetadata(child.id); + await db.write('UPDATE `apps` SET `metadata` = ? WHERE `id` = ?', [ + JSON.stringify({ ...stored, category: 'games' }), + child.id, + ]); + await appStore.invalidateByUid(child.uid); + + const reread = await appStore.getById(child.id); + expect(reread.metadata).toEqual({ category: 'games' }); + expect(reread.created_by_app).toBe(parent.uid); + }); + + it('records the creating app in metadata and keeps it out of the row metadata', async () => { + const owner = freshOwner(); + const parent = await createApp(owner); + const child = await createApp(owner, {}, { appOwner: parent.id }); + + expect(child.created_by_app).toBe(parent.uid); + expect(child.metadata).toBeNull(); + expect(await rawMetadata(child.id)).toEqual({ + _createdByApp: parent.uid, + _metadataNull: true, + }); + expect((await appStore.getByUid(child.uid)).created_by_app).toBe( + parent.uid, + ); + + const plain = await createApp(owner, { + metadata: JSON.stringify({ a: 1 }), + }); + expect(plain.created_by_app).toBeNull(); + expect(plain.metadata).toEqual({ a: 1 }); + }); + + it('ignores a caller-supplied marker on create and update', async () => { + const owner = freshOwner(); + const app = await createApp(owner, { + metadata: JSON.stringify({ _createdByApp: 'app-forged', a: 1 }), + }); + expect(app.created_by_app).toBeNull(); + expect(await rawMetadata(app.id)).toEqual({ a: 1 }); + + await appStore.update(app.id, { + metadata: JSON.stringify({ _createdByApp: 'app-forged' }), + }); + expect(await rawMetadata(app.id)).toEqual({}); + expect((await appStore.getById(app.id)).created_by_app).toBeNull(); + }); + + it('keeps the marker through metadata rewrites and the creating app’s deletion', async () => { + const owner = freshOwner(); + const parent = await createApp(owner); + const child = await createApp( + owner, + { metadata: JSON.stringify({ keep: true }) }, + { appOwner: parent.id }, + ); + + await appStore.update(child.id, { + metadata: JSON.stringify({ note: 'x', _createdByApp: null }), + }); + await appStore.update(child.id, { metadata: null }); + expect(await rawMetadata(child.id)).toEqual({ + _createdByApp: parent.uid, + _metadataNull: true, + }); + expect((await appStore.getById(child.id)).metadata).toBeNull(); + + await appStore.delete(parent.id); + await appStore.invalidateByUid(child.uid); + const reread = await appStore.getByUid(child.uid); + expect(reread.app_owner ?? null).toBeNull(); + expect(reread.created_by_app).toBe(parent.uid); + expect(reread.metadata).toBeNull(); + }); +}); + +describe('AppStore hosted-site app lookup cache', () => { + const candidatesFor = (app) => [ + app.index_url, + app.index_url.replace(/\/$/, ''), + ]; + const countLookups = async (fn) => { + const read = vi.spyOn(db, 'read'); + const pread = vi.spyOn(db, 'pread'); + const isLookup = ([sql]) => + /`owner_user_id` = \?/.test(sql) && /`index_url` IN/.test(sql); + try { + const result = await fn(); + return { + result, + reads: read.mock.calls.filter(isLookup).length, + preads: pread.mock.calls.filter(isLookup).length, + }; + } finally { + read.mockRestore(); + pread.mockRestore(); + } + }; + + it('serves repeat lookups from the cache, misses included', async () => { + const owner = freshOwner(); + const app = await createApp(owner); + const other = `https://cl-none-${slug()}.example.com/`; + await endChangeWindow(); + + const { result, reads } = await countLookups(async () => { + const out = []; + for (let i = 0; i < 3; i++) { + out.push( + await appStore.listOwnedByIndexUrlCandidates( + owner, + candidatesFor(app), + ), + ); + out.push( + await appStore.listOwnedByIndexUrlCandidates(owner, [ + other, + ]), + ); + await settle(); + } + return out; + }); + + expect(reads).toBe(2); + expect(result[0].map((r) => r.uid)).toEqual([app.uid]); + expect(result[4].map((r) => r.uid)).toEqual([app.uid]); + expect(result[5]).toEqual([]); + }); + + it('sees a privacy flip made by direct SQL plus invalidateByUid on the next lookup', async () => { + const owner = freshOwner(); + const app = await createApp(owner); + await endChangeWindow(); + const before = await appStore.listOwnedByIndexUrlCandidates( + owner, + candidatesFor(app), + ); + await settle(); + expect(Number(before[0].is_private)).toBe(0); + + await db.write('UPDATE `apps` SET `is_private` = 1 WHERE `uid` = ?', [ + app.uid, + ]); + await appStore.invalidateByUid(app.uid); + // Past the window, only the generation keeps the old entry out. + await endChangeWindow(); + + const after = await appStore.listOwnedByIndexUrlCandidates( + owner, + candidatesFor(app), + { requirePrivate: true }, + ); + expect(after.map((r) => r.uid)).toEqual([app.uid]); + const unfiltered = await appStore.listOwnedByIndexUrlCandidates( + owner, + candidatesFor(app), + ); + expect(Number(unfiltered[0].is_private)).toBe(1); + }); + + it('finds an app created after a cached miss', async () => { + const owner = freshOwner(); + const name = `cl-${slug()}`; + const url = `https://${name}.example.com/`; + expect( + await appStore.listOwnedByIndexUrlCandidates(owner, [url]), + ).toEqual([]); + await settle(); + + const app = await createApp(owner, { name, index_url: url }); + await endChangeWindow(); + const found = await appStore.listOwnedByIndexUrlCandidates(owner, [ + url, + ]); + expect(found.map((r) => r.uid)).toEqual([app.uid]); + }); + + it('reads the primary right after a change and caches nothing a lagging replica served', async () => { + const owner = freshOwner(); + const app = await createApp(owner); + await endChangeWindow(); + const [stale] = await db.read( + 'SELECT `id`, `uid`, `name`, `title`, `is_private`, `owner_user_id`, `index_url` FROM `apps` WHERE `id` = ?', + [app.id], + ); + + await db.write('UPDATE `apps` SET `is_private` = 1 WHERE `uid` = ?', [ + app.uid, + ]); + await appStore.invalidateByUid(app.uid); + const result = await withLaggingReplica( + { apps: [stale], filetypes: [] }, + async () => { + const rows = await appStore.listOwnedByIndexUrlCandidates( + owner, + candidatesFor(app), + ); + await settle(); + return rows; + }, + ); + expect(Number(result[0].is_private)).toBe(1); + expect(await redis.keys(`apps:hosted:v1:${owner}:*`)).toEqual([]); + }); + + it('keys entries by owner, candidate set and requirePrivate', async () => { + const owner = freshOwner(); + const app = await createApp(owner); + await endChangeWindow(); + await appStore.listOwnedByIndexUrlCandidates(owner, candidatesFor(app)); + await settle(); + + expect( + await appStore.listOwnedByIndexUrlCandidates( + owner, + candidatesFor(app), + { requirePrivate: true }, + ), + ).toEqual([]); + expect( + await appStore.listOwnedByIndexUrlCandidates( + freshOwner(), + candidatesFor(app), + ), + ).toEqual([]); + expect( + ( + await appStore.listOwnedByIndexUrlCandidates( + owner, + [...candidatesFor(app)].reverse(), + ) + ).map((r) => r.uid), + ).toEqual([app.uid]); + }); +}); + +describe('AppStore filetype associations on the cached row', () => { + const filetypeReads = (spy) => + spy.mock.calls.filter(([sql]) => + /FROM `app_filetype_association`/.test(sql), + ).length; + + it('caches associations with the row and refreshes them after a change', async () => { + const app = await createApp(freshOwner()); + await appStore.setFiletypeAssociations(app.id, ['txt', '.MD']); + await endChangeWindow(); + + expect( + (await appStore.getByUid(app.uid)).filetype_associations.sort(), + ).toEqual(['md', 'txt']); + await settle(); + + const read = vi.spyOn(db, 'read'); + try { + const warm = await appStore.getById(app.id); + expect(warm.filetype_associations.sort()).toEqual(['md', 'txt']); + expect(filetypeReads(read)).toBe(0); + } finally { + read.mockRestore(); + } + + await appStore.setFiletypeAssociations(app.id, ['csv']); + expect((await appStore.getByUid(app.uid)).filetype_associations).toEqual( + ['csv'], + ); + expect( + (await appStore.getByNames([app.name])).get(app.name) + .filetype_associations, + ).toEqual(['csv']); + }); + + it('does not cache associations a lagging replica still serves after a change', async () => { + const app = await createApp(freshOwner()); + await appStore.setFiletypeAssociations(app.id, ['txt']); + const [staleRow] = await db.read( + 'SELECT * FROM `apps` WHERE `id` = ?', + [app.id], + ); + await appStore.setFiletypeAssociations(app.id, ['csv']); + + const fresh = await withLaggingReplica( + { + apps: [staleRow], + filetypes: [{ app_id: app.id, type: 'txt' }], + }, + async () => { + const row = await appStore.getByUid(app.uid); + await settle(); + return row; + }, + ); + expect(fresh.filetype_associations).toEqual(['csv']); + expect( + JSON.parse((await redis.get(`apps:uid:${app.uid}`)) ?? 'null'), + ).toBeNull(); + }); +}); diff --git a/src/backend/stores/app/AppStore.js b/src/backend/stores/app/AppStore.js index dab2d7335..47f17e901 100644 --- a/src/backend/stores/app/AppStore.js +++ b/src/backend/stores/app/AppStore.js @@ -18,8 +18,13 @@ */ import { v4 as uuidv4 } from 'uuid'; +import { createHash } from 'node:crypto'; import { domainToUnicode } from 'node:url'; -import { PuterStore } from '../types'; +import { + CACHE_TOMBSTONE_SUFFIX, + CACHE_TOMBSTONE_TTL_SECONDS, + PuterStore, +} from '../types'; import { HttpError } from '../../core/http/HttpError.js'; import { isUniqueViolation } from '../../util/dbError.js'; import { @@ -43,6 +48,16 @@ const LIST_CACHE_TTL_SECONDS = 15 * 60; // Outlives every page cached under it, so a lapsed generation can't bring // one back. const LIST_GENERATION_TTL_SECONDS = 2 * LIST_CACHE_TTL_SECONDS; +// Hosted-site → owned-app lookups, under the owner's list generation. Bump the +// version when the matching changes. +const HOSTED_APP_CACHE_KEY_PREFIX = `${CACHE_KEY_PREFIX}:hosted:v1`; +const HOSTED_APP_CACHE_TTL_SECONDS = 5 * 60; +// Server-set `metadata` key holding the uid of the app that created this one. +// Unlike `app_owner`, it survives that app's deletion. +const CREATED_BY_APP_KEY = '_createdByApp'; +// Set next to the creator key when the app had no metadata, so reads return +// null until something else is written there. +const METADATA_NULL_KEY = '_metadataNull'; const FILETYPE_CACHE_KEY_PREFIX = 'apps:by-filetype'; const FILETYPE_CACHE_TTL_SECONDS = 60; // Filetype associations are matched against the bare lowercase extension @@ -127,6 +142,9 @@ const READ_ONLY_COLUMNS = new Set([ 'app_owner', 'protected', 'is_private', + // Derived on read, not columns. + 'created_by_app', + 'filetype_associations', ]); // Unique-key columns `#getManyByProperty` may build an IN-list against. const BATCH_LOOKUP_COLUMNS = new Set(['id', 'uid', 'name']); @@ -304,10 +322,10 @@ export class AppStore extends PuterStore { const rows = primary ? await this.clients.db.pread(sql, chunk) : await this.clients.db.read(sql, chunk); - for (const row of rows) { - const app = this.#normalizeRow(row); - if (app) apps.push(app); - } + const loaded = rows + .map((row) => this.#normalizeRow(row)) + .filter(Boolean); + apps.push(...(await this.#withFiletypes(loaded, { primary }))); } return apps; }); @@ -427,16 +445,89 @@ export class AppStore extends PuterStore { { requirePrivate = false } = {}, ) { if (!Array.isArray(candidates) || candidates.length === 0) return []; + const entryKey = `${HOSTED_APP_CACHE_KEY_PREFIX}:${ownerUserId}:${ + requirePrivate ? 1 : 0 + }:${createHash('sha256') + .update(JSON.stringify([...new Set(candidates)].sort())) + .digest('base64url')}`; + const generationKey = this.#listGenerationKey(ownerUserId); + const changedKey = this.#hostedChangedKey(ownerUserId); + + let generation = null; + let changed = false; + try { + const results = + (await this.clients.redis + .pipeline() + .get(generationKey) + .exists(changedKey) + .get(entryKey) + .exec()) ?? []; + const currentGeneration = results[0]?.[1]; + generation = + typeof currentGeneration === 'string' + ? currentGeneration + : null; + changed = Number(results[1]?.[1]) > 0; + const raw = results[2]?.[1]; + if (!changed && generation && typeof raw === 'string') { + const cached = JSON.parse(raw); + if ( + cached?.generation === generation && + Array.isArray(cached.rows) + ) { + return cached.rows; + } + } + } catch { + // Fall through to the database. + } + // Read before the query, so a write landing during it orphans the entry. + if (!changed && !generation) { + generation = await this.#listGeneration(ownerUserId); + } + const isPrivate = `\`is_private\` = ${this.clients.db.booleanLiteral(true)}`; const { predicate, params } = this.#indexUrlMatch(candidates); - return this.clients.db.read( + const sql = 'SELECT `id`, `uid`, `name`, `title`, `is_private`, `owner_user_id`, `index_url` ' + - 'FROM `apps` WHERE `owner_user_id` = ? ' + - (requirePrivate ? `AND ${isPrivate} ` : '') + - `AND ${predicate} ` + - `ORDER BY CASE WHEN ${isPrivate} THEN 0 ELSE 1 END, \`id\` LIMIT 2`, - [ownerUserId, ...params], - ); + 'FROM `apps` WHERE `owner_user_id` = ? ' + + (requirePrivate ? `AND ${isPrivate} ` : '') + + `AND ${predicate} ` + + `ORDER BY CASE WHEN ${isPrivate} THEN 0 ELSE 1 END, \`id\` LIMIT 2`; + // Changed moments ago: the replica may not have it yet. + const rows = changed + ? await this.clients.db.pread(sql, [ownerUserId, ...params]) + : await this.clients.db.read(sql, [ownerUserId, ...params]); + if (!changed && generation) { + void this.#writeHostedAppCache(entryKey, changedKey, { + generation, + rows, + }); + } + return rows; + } + + /** Caches a hosted-app lookup unless the owner's apps changed meanwhile. */ + async #writeHostedAppCache(entryKey, changedKey, value) { + try { + await this.clients.redis.set( + entryKey, + JSON.stringify(value), + 'EX', + HOSTED_APP_CACHE_TTL_SECONDS, + ); + if (await this.clients.redis.exists(changedKey)) { + await this.clients.redis.del(entryKey); + } + } catch { + // The next read fills it. + } + } + + /** Set for a moment after any of `ownerUserId`'s apps change. */ + #hostedChangedKey(ownerUserId) { + return `${HOSTED_APP_CACHE_KEY_PREFIX}:changed:${ownerUserId}${CACHE_TOMBSTONE_SUFFIX}`; } /** @@ -653,8 +744,16 @@ export class AppStore extends PuterStore { const uid = `app-${uuidv4()}`; const allowed = this.#filterEditable(fields); allowed.owner_user_id = ownerUserId; + let createdByApp = null; if (appOwner !== null && appOwner !== undefined) { allowed.app_owner = appOwner; + createdByApp = (await this.getById(appOwner))?.uid ?? appOwner; + } + if (createdByApp !== null || allowed.metadata !== undefined) { + allowed.metadata = this.#withCreatedByApp( + allowed.metadata ?? null, + createdByApp, + ); } const written = { uid, ...allowed }; @@ -760,13 +859,19 @@ export class AppStore extends PuterStore { return fresh; } - /** Updates + refreshes cache (local + peers) with the post-update row. */ + /** Updates the row and invalidates its cache locally and on peers. */ async update(appId, patch) { const allowed = this.#filterEditable(patch); const keys = Object.keys(allowed); if (keys.length === 0) return this.getById(appId); - const before = await this.#readFromDb('id', appId); + const before = await this.#readFromDb('id', appId, { primary: true }); + if (allowed.metadata !== undefined) { + allowed.metadata = this.#withCreatedByApp( + allowed.metadata, + before?.created_by_app ?? null, + ); + } const setClause = keys.map((k) => `\`${k}\` = ?`).join(', '); const values = keys.map((k) => allowed[k]); @@ -789,13 +894,16 @@ export class AppStore extends PuterStore { await this.#clearOldAppNamesForName(allowed.name); } - // Not `{ ...before, ...allowed }`: `before` is the replica's, and a - // column it has stale would go into the cache. + // Read back persisted values instead of merging a pre-update snapshot. const fresh = await this.#readFromDb('id', appId, { primary: true }); await this.#invalidateListCachesForApps([before, fresh]); - if (fresh) { - await this.#refreshCache({ ...fresh, ...allowed }); - } + await this.tombstoneCacheKeys([ + ...new Set( + [before, fresh] + .filter(Boolean) + .flatMap((app) => this.#cacheKeysForApp(app)), + ), + ]); if (renamed) { // Drop any stale `apps:name:` cache so the next // getByName(old) goes through the redirect path with fresh @@ -954,6 +1062,10 @@ export class AppStore extends PuterStore { * `[]` for apps that have no associations). */ async getFiletypeAssociationsByIds(appIds) { + return this.#readFiletypesByIds(appIds); + } + + async #readFiletypesByIds(appIds, { primary = false } = {}) { const ids = Array.isArray(appIds) ? Array.from( new Set( @@ -966,11 +1078,11 @@ export class AppStore extends PuterStore { if (ids.length === 0) return out; const placeholders = ids.map(() => '?').join(','); - const rows = await this.clients.db.read( - `SELECT \`app_id\`, \`type\` FROM \`app_filetype_association\` - WHERE \`app_id\` IN (${placeholders})`, - ids, - ); + const sql = `SELECT \`app_id\`, \`type\` FROM \`app_filetype_association\` + WHERE \`app_id\` IN (${placeholders})`; + const rows = primary + ? await this.clients.db.pread(sql, ids) + : await this.clients.db.read(sql, ids); for (const row of rows) { const list = out.get(row.app_id); if (list) list.push(row.type); @@ -1080,6 +1192,11 @@ export class AppStore extends PuterStore { [...previous.map(normalizeFiletype), ...newTypes].filter(Boolean), ); if (affected.size === 0) return; + // The cached row carries its associations. + const app = + (await this.#cache.read(this.#cache.key('id', appId))) ?? + (await this.#readFromDb('id', appId, { primary: true })); + if (app) await this.tombstoneCacheKeys(this.#cacheKeysForApp(app)); const keys = [...affected].map( (t) => `${FILETYPE_CACHE_KEY_PREFIX}:${t}`, ); @@ -1125,11 +1242,32 @@ export class AppStore extends PuterStore { async #getByProperty(prop, value) { if (value === undefined || value === null) return null; - return this.#cache.get(this.#cache.key(prop, value), (primary) => - this.#readFromDb(prop, value, { primary }), + return this.#cache.get( + this.#cache.key(prop, value), + async (primary) => { + const app = await this.#readFromDb(prop, value, { primary }); + if (!app) return null; + return (await this.#withFiletypes([app], { primary }))[0]; + }, ); } + /** + * `apps` with `filetype_associations` set, so the cached row carries them. + * `setFiletypeAssociations` invalidates the row. + */ + async #withFiletypes(apps, { primary = false } = {}) { + if (apps.length === 0) return apps; + const byId = await this.#readFiletypesByIds( + apps.map((app) => app.id), + { primary }, + ); + for (const app of apps) { + app.filetype_associations = byId.get(app.id) ?? []; + } + return apps; + } + /** * The INSERT for a new row: `written`'s columns, and `timestamp` stamped * from `createdAt` (unix seconds) instead of defaulted. @@ -1160,6 +1298,7 @@ export class AppStore extends PuterStore { id, timestamp: this.clients.db.timestampValue(createdAt), created_epoch: createdAt, + filetype_associations: [], }); try { await this.#writeCache(app); @@ -1293,6 +1432,15 @@ export class AppStore extends PuterStore { for (const app of apps) { if (app?.owner_user_id != null) scopes.add(app.owner_user_id); } + // Before the generation, so a peer that sees the new generation also + // reads the primary until its replica catches up. + const owners = [...scopes].filter((scope) => scope !== 'all'); + await this.publishCacheKeys({ + keys: owners.map((owner) => this.#hostedChangedKey(owner)), + serializedData: '1', + ttlSeconds: CACHE_TOMBSTONE_TTL_SECONDS, + broadcast: true, + }); await this.publishCacheKeys({ keys: [...scopes].map((scope) => this.#listGenerationKey(scope)), serializedData: uuidv4(), @@ -1301,23 +1449,6 @@ export class AppStore extends PuterStore { }); } - async #refreshCache(app) { - const keys = this.#cacheKeysForApp(app); - // A delete can land between `update`'s read and this refresh. - await this.writeCacheUnlessDeleted(keys, () => - this.#publishRefresh(keys, app), - ); - } - - async #publishRefresh(keys, app) { - await this.publishCacheKeys({ - keys, - serializedData: JSON.stringify(app), - ttlSeconds: CACHE_TTL_SECONDS, - broadcast: true, - }); - } - #indexUrlMatch(candidates) { const variants = new Set(candidates); for (const candidate of candidates) { @@ -1357,6 +1488,48 @@ export class AppStore extends PuterStore { }); } + /** + * `metadata` to store: a caller-supplied creator key is dropped, and + * `createdByApp` (when set) written in its place. + */ + #withCreatedByApp(metadata, createdByApp) { + let parsed = metadata; + if (typeof metadata === 'string') { + try { + parsed = JSON.parse(metadata); + } catch { + parsed = null; + } + } + const isObject = + parsed !== null && + typeof parsed === 'object' && + !Array.isArray(parsed); + if (createdByApp === null) { + if (!isObject || !Object.hasOwn(parsed, CREATED_BY_APP_KEY)) { + return metadata; + } + const { + [CREATED_BY_APP_KEY]: _dropped, + [METADATA_NULL_KEY]: wasNull, + ...rest + } = parsed; + return wasNull && Object.keys(rest).length === 0 + ? null + : JSON.stringify(rest); + } + const { + [CREATED_BY_APP_KEY]: _ignored, + [METADATA_NULL_KEY]: _ignoredNull, + ...rest + } = isObject ? parsed : {}; + return JSON.stringify({ + ...rest, + [CREATED_BY_APP_KEY]: createdByApp, + ...(isObject ? {} : { [METADATA_NULL_KEY]: true }), + }); + } + #filterEditable(fields) { const out = {}; for (const [k, v] of Object.entries(fields)) { @@ -1401,6 +1574,23 @@ export class AppStore extends PuterStore { row.metadata = null; } } + // Lifted out so client views never carry it. + if ( + row.metadata && + typeof row.metadata === 'object' && + Object.hasOwn(row.metadata, CREATED_BY_APP_KEY) + ) { + const { + [CREATED_BY_APP_KEY]: createdByApp, + [METADATA_NULL_KEY]: wasNull, + ...rest + } = row.metadata; + row.created_by_app = createdByApp ?? null; + row.metadata = + wasNull && Object.keys(rest).length === 0 ? null : rest; + } else if (row.metadata !== undefined) { + row.created_by_app ??= null; + } if (row.created_epoch !== undefined && row.created_epoch !== null) { row.created_epoch = Number(row.created_epoch); } diff --git a/src/backend/stores/app/AppStore.test.js b/src/backend/stores/app/AppStore.test.js index a33dd46ba..fdb4c2392 100644 --- a/src/backend/stores/app/AppStore.test.js +++ b/src/backend/stores/app/AppStore.test.js @@ -1436,7 +1436,7 @@ describe('AppStore CRUD and cache invalidation', () => { expect((await cachedByUid(uid))?.index_url).toBe(origin); }); - it('returns and caches the updated app, not a replica copy missing an earlier update', async () => { + it('returns the updated app and prevents replica cache refills', async () => { const app = await createApp({ title: 'One' }); const stale = await rawRow(app.id); await appStore.update(app.id, { title: 'Two' }); @@ -1446,9 +1446,28 @@ describe('AppStore CRUD and cache invalidation', () => { ); expect(updated).toMatchObject({ title: 'Two', description: 'later' }); - expect(await cachedByUid(app.uid)).toMatchObject({ - title: 'Two', - description: 'later', + expect(await cachedByUid(app.uid)).toBeNull(); + expect(await appStore.getByUid(app.uid)).toMatchObject({ + title: 'Two', description: 'later', + }); + }); + + it('preserves intermediate name redirects across consecutive renames with replica lag', async () => { + const app = await createApp(); + const stale = await rawRow(app.id); + const intermediateName = freshName(); + const finalName = freshName(); + await appStore.update(app.id, { name: intermediateName }); + + await withLaggingReplica([stale], () => + appStore.update(app.id, { name: finalName }), + ); + + expect(await appStore.getByName(intermediateName)).toMatchObject({ + uid: app.uid, name: finalName, + }); + expect(await appStore.getByName(app.name)).toMatchObject({ + uid: app.uid, name: finalName, }); }); diff --git a/src/backend/stores/fs/FSEntryStore.test.ts b/src/backend/stores/fs/FSEntryStore.test.ts index 4a8c7e9f4..383d763c5 100644 --- a/src/backend/stores/fs/FSEntryStore.test.ts +++ b/src/backend/stores/fs/FSEntryStore.test.ts @@ -3959,3 +3959,55 @@ describe('FSEntryStore subtree walks', () => { }); }); }); + +describe('FSEntryStore lineage backfill under a lenient collation', () => { + // MySQL's `name` collation matches some names the `path` collation + // doesn't (`ß` = `s` by name, not by path). SQLite compares both + // exactly, so the lineage read is handed the stored spelling instead. + const matchNamesLeniently = (alias: string, stored: string) => { + const db = server.clients.db; + const originalRead = (Object.getPrototypeOf(db) as typeof db).read; + return vi + .spyOn(db, 'read') + .mockImplementation(async (sql: string, params?: unknown[]) => { + const lenient = sql.includes('segments(depth, name)') + ? params?.map((p) => (p === alias ? stored : p)) + : params; + return originalRead.call(db, sql, lenient); + }); + }; + + it('writes the stored path, not the requested spelling', async () => { + const user = await makeUser(); + const desktop = (await store.getEntryByPath(`${user.home}/Desktop`))!; + await server.clients.redis.flushall?.(); + + const spy = matchNamesLeniently('Deßktop', 'Desktop'); + try { + const resolved = await store.getEntryByPath(`${user.home}/Deßktop`); + expect(resolved?.uuid).toBe(desktop.uuid); + expect(resolved?.path).toBe(`${user.home}/Desktop`); + } finally { + spy.mockRestore(); + } + await expect(pathOf(desktop.uuid)).resolves.toBe( + `${user.home}/Desktop`, + ); + }); + + it('heals a path-less row with its stored names', async () => { + const user = await makeUser(); + const desktop = (await store.getEntryByPath(`${user.home}/Desktop`))!; + await blankPath(desktop.uuid); + + const spy = matchNamesLeniently('Deßktop', 'Desktop'); + try { + await store.getEntryByPath(`${user.home}/Deßktop`); + } finally { + spy.mockRestore(); + } + await expect(pathOf(desktop.uuid)).resolves.toBe( + `${user.home}/Desktop`, + ); + }); +}); diff --git a/src/backend/stores/fs/FSEntryStore.ts b/src/backend/stores/fs/FSEntryStore.ts index be946642f..4296e075d 100644 --- a/src/backend/stores/fs/FSEntryStore.ts +++ b/src/backend/stores/fs/FSEntryStore.ts @@ -1229,43 +1229,49 @@ export class FSEntryStore extends PuterStore { segments(depth, name) AS ( ${segmentRows} ), - lineage(id, uuid, depth) AS ( - SELECT f.id, f.uuid, 1 + lineage(id, uuid, name, depth) AS ( + SELECT f.id, f.uuid, f.name, 1 FROM fsentries f INNER JOIN segments s ON s.depth = 1 WHERE f.parent_uid IS NULL AND f.name = s.name UNION ALL - SELECT f.id, f.uuid, l.depth + 1 + SELECT f.id, f.uuid, f.name, l.depth + 1 FROM fsentries f INNER JOIN lineage l ON f.parent_uid = l.uuid INNER JOIN segments s ON s.depth = l.depth + 1 WHERE f.name = s.name ) - SELECT id, uuid, depth FROM lineage ORDER BY depth ASC, id ASC + SELECT id, uuid, name, depth FROM lineage ORDER BY depth ASC, id ASC `; const rows = (await this.clients.db.read(sql, segments)) as { id: number | string; uuid: string; + name: string; depth: number | string; }[]; // Take the lowest-id row at each depth (deterministic pick that // matches v1's `LIMIT 1` per-segment behaviour). const byDepth = new Map(); + const storedNames: string[] = []; for (const r of rows) { const depth = Number(r.depth); if (!byDepth.has(depth)) { byDepth.set(depth, { id: Number(r.id), uuid: r.uuid }); + storedNames[depth - 1] = r.name; } } if (byDepth.size !== segments.length) { return null; } - await this.#backfillLineagePaths(byDepth, segments); + // The stored names, not the caller's spelling: a collation can match + // a segment to a name spelled differently, and this runs before any + // access check. + await this.#backfillLineagePaths(byDepth, storedNames); const target = byDepth.get(segments.length); if (!target) return null; @@ -3275,6 +3281,7 @@ export class FSEntryStore extends PuterStore { pathPrefix: string, options: { limit?: number; + offset?: number; cursor?: string | null; maxDepth: number; sortBy?: DescendantSortField | null; @@ -3291,13 +3298,21 @@ export class FSEntryStore extends PuterStore { const maxSlashes = this.#slashCount(normalizedPrefix) + Math.max(1, options.maxDepth); const limit = normalizeLimit(options.limit, { cap: 10_000 }) ?? 1000; - const rawPayload = openCursor( options.cursor, this.config.jwt_secret_v2, ) as | { v?: unknown; id?: number; s?: string; o?: string; p?: string } | undefined; + // Recursive listings used to ignore `offset`; a cursor still wins and + // an invalid offset is still ignored rather than rejected. + const requestedOffset = Number(options.offset); + const offset = + !rawPayload && + Number.isFinite(requestedOffset) && + requestedOffset > 0 + ? Math.floor(requestedOffset) + : 0; // Cursors minted before this listing honored the sort carried only the // last path, always ascending. const payload = @@ -3351,6 +3366,7 @@ export class FSEntryStore extends PuterStore { maxSlashes, ...seekParams, limit + 1, + offset, ]; const rows = (await this.clients.db.read( @@ -3359,7 +3375,7 @@ export class FSEntryStore extends PuterStore { WHERE user_id = ? AND ${this.#pathLikeAny(patterns)} AND (LENGTH(path) - LENGTH(REPLACE(path, '/', ''))) <= ? ${seek} ORDER BY ${sortExpr} ${dir}${tiebreak ? `, id ${dir}` : ''} - LIMIT ?`, + LIMIT ? OFFSET ?`, params, )) as unknown as FSEntryRow[]; diff --git a/src/backend/stores/permission/PermissionStore.test.ts b/src/backend/stores/permission/PermissionStore.test.ts index 84fa4d93f..f2e5e8b94 100644 --- a/src/backend/stores/permission/PermissionStore.test.ts +++ b/src/backend/stores/permission/PermissionStore.test.ts @@ -25,7 +25,11 @@ import { PuterServer } from '../../server.ts'; import { PermissionUtil } from '../../services/permission/permissionUtil.ts'; import { setupTestServer } from '../../testUtil.ts'; import { decodeCursor } from '../../util/pagination.ts'; -import { FLAT_PERM_SCAN_CONCURRENCY } from './PermissionStore.ts'; +import { CACHE_TOMBSTONE_SUFFIX } from '../types.ts'; +import { + FLAT_PERM_SCAN_CONCURRENCY, + U2U_CACHE_MAX_ROWS, +} from './PermissionStore.ts'; describe('PermissionStore', () => { let server: PuterServer; @@ -80,6 +84,140 @@ describe('PermissionStore', () => { expect(await store.getFlatUserPerms(holder.id, [perm])).toEqual([]); }); + const seedGrants = async ( + holderId: number, + issuerId: number, + count: number, + ): Promise => { + const perms = Array.from( + { length: count }, + () => `fs:${uuidv4()}:read`, + ); + await server.clients.db.write( + 'INSERT INTO `user_to_user_permissions` (`holder_user_id`, `issuer_user_id`, `permission`, `extra`) VALUES ' + + perms.map(() => '(?, ?, ?, ?)').join(', '), + perms.flatMap((perm) => [holderId, issuerId, perm, '{}']), + ); + return perms; + }; + const cachedRowSet = async (holderId: number) => + JSON.parse( + (await server.clients.redis.get( + `perms:u2u:holder:${holderId}`, + )) ?? 'null', + ); + + it('caches a small holder whole and reads it back from the cache', async () => { + const issuer = await makeUser(); + const holder = await makeUser(); + const [perm] = await seedGrants(holder.id, issuer.id, 3); + + expect( + await store.readLinkedUserUserPerms(holder.id, [perm]), + ).toHaveLength(1); + await vi.waitFor(async () => + expect(await cachedRowSet(holder.id)).toHaveLength(3), + ); + }); + + it('reads only the named permissions for a holder past the cache cap', async () => { + const issuer = await makeUser(); + const holder = await makeUser(); + const perms = await seedGrants( + holder.id, + issuer.id, + U2U_CACHE_MAX_ROWS + 1, + ); + const reads = vi.spyOn(server.clients.db, 'read'); + try { + const rows = await store.readLinkedUserUserPerms(holder.id, [ + perms[7], + `fs:${uuidv4()}:read`, + ]); + expect(rows.map((row) => row.permission)).toEqual([perms[7]]); + await vi.waitFor(async () => + expect(await cachedRowSet(holder.id)).toBe('too-many'), + ); + + // Warm: the marker, then one read naming the permission. + reads.mockClear(); + expect( + await store.readLinkedUserUserPerms(holder.id, [perms[9]]), + ).toHaveLength(1); + expect(reads).toHaveBeenCalledTimes(1); + expect(reads.mock.calls[0][1]).toEqual([holder.id, perms[9]]); + } finally { + reads.mockRestore(); + } + }); + + it('reads a holder past the cache cap from the primary on the read-after-write path', async () => { + const issuer = await makeUser(); + const holder = await makeUser(); + const perms = await seedGrants( + holder.id, + issuer.id, + U2U_CACHE_MAX_ROWS + 1, + ); + const preads = vi.spyOn(server.clients.db, 'pread'); + try { + expect( + await store.readLinkedUserUserPermsFromPrimary(holder.id, [ + perms[3], + ]), + ).toHaveLength(1); + expect(preads).toHaveBeenCalledTimes(2); + } finally { + preads.mockRestore(); + } + }); + + it('matches permissions exactly, whatever the column collation', async () => { + const issuer = await makeUser(); + const holder = await makeUser(); + const uid = uuidv4(); + await store.upsertUserUserPerm( + holder.id, + issuer.id, + `kv:${uid}:Docs`, + {}, + ); + await seedGrants(holder.id, issuer.id, U2U_CACHE_MAX_ROWS); + + expect( + await store.readLinkedUserUserPerms(holder.id, [ + `kv:${uid}:docs`, + ]), + ).toEqual([]); + }); + + it('reads the named permissions off the holder-permission index', async () => { + const issuer = await makeUser(); + const holder = await makeUser(); + const perms = await seedGrants( + holder.id, + issuer.id, + U2U_CACHE_MAX_ROWS + 1, + ); + const reads = vi.spyOn(server.clients.db, 'read'); + let statement: unknown[] = []; + try { + await store.readLinkedUserUserPerms(holder.id, [perms[0]]); + statement = reads.mock.calls.find(([sql]) => + /`permission` IN/.test(String(sql)), + ) as unknown[]; + } finally { + reads.mockRestore(); + } + const plan = (await server.clients.db.read( + `EXPLAIN QUERY PLAN ${statement[0]}`, + statement[1] as unknown[], + )) as Array<{ detail: string }>; + expect(plan.map((step) => step.detail).join('; ')).toMatch( + /idx_user_to_user_permissions_holder_permission/, + ); + }); + it('returns nothing for an empty permission list', async () => { const holder = await makeUser(); expect(await store.getFlatUserPerms(holder.id, [])).toEqual([]); @@ -166,6 +304,82 @@ describe('PermissionStore', () => { // -- user → app ---------------------------------------------------- describe('user-to-app permissions', () => { + it('does not cache a read that raced a revoke', async () => { + const user = await makeUser(); + const app = await makeApp(user.id); + await store.upsertUserAppPerm(user.id, app.id, 'driver:kv', {}); + await server.clients.redis.del( + `perms:u2a:${user.id}:${app.id}${CACHE_TOMBSTONE_SUFFIX}`, + ); + let startLookup!: () => void; + const started = new Promise((resolve) => { + startLookup = resolve; + }); + let releaseLookup!: () => void; + const gate = new Promise((resolve) => { + releaseLookup = resolve; + }); + let held = false; + const dbRead = server.clients.db.read.bind(server.clients.db); + const spy = vi + .spyOn(server.clients.db, 'read') + .mockImplementation(async (query, params) => { + const rows = await dbRead(query, params); + if ( + !held && + query.includes('FROM `user_to_app_permissions`') && + params?.[0] === user.id && + params?.[1] === app.id + ) { + held = true; + startLookup(); + await gate; + } + return rows; + }); + try { + const stale = store.hasUserAppPerm( + user.id, + app.id, + 'driver:kv', + ); + await started; + await store.deleteUserAppPerm(user.id, app.id, 'driver:kv'); + releaseLookup(); + expect(await stale).toBe(true); + await new Promise((resolve) => setTimeout(resolve, 20)); + // Removing the marker stands in for its expiry. + await server.clients.redis.del( + `perms:u2a:${user.id}:${app.id}${CACHE_TOMBSTONE_SUFFIX}`, + ); + expect( + await server.clients.redis.get( + `perms:u2a:${user.id}:${app.id}`, + ), + ).toBeNull(); + expect( + await store.hasUserAppPerm(user.id, app.id, 'driver:kv'), + ).toBe(false); + } finally { + releaseLookup(); + spy.mockRestore(); + } + }); + + it('does not re-read the primary on revoke', async () => { + const user = await makeUser(); + const app = await makeApp(user.id); + await store.upsertUserAppPerm(user.id, app.id, 'driver:kv', {}); + const pread = vi.spyOn(server.clients.db, 'pread'); + try { + await store.deleteUserAppPerm(user.id, app.id, 'driver:kv'); + await store.deleteUserAppAll(user.id, app.id); + expect(pread).not.toHaveBeenCalled(); + } finally { + pread.mockRestore(); + } + }); + it('upserts, reads back the decoded extra, and reports membership', async () => { const user = await makeUser(); const app = await makeApp(user.id); @@ -207,23 +421,22 @@ describe('PermissionStore', () => { expect(rows[0].extra).toEqual({ v: 2 }); }); - it('re-fills the cache from the primary when a grant is revoked', async () => { + it('reads the primary while a revoked grant is marked changed', async () => { const user = await makeUser(); const app = await makeApp(user.id); await store.upsertUserAppPerm(user.id, app.id, 'driver:kv', {}); // Warm it, so a revoke has something to leave behind. await store.hasUserAppPerm(user.id, app.id, 'driver:kv'); - const read = vi.spyOn(server.clients.db, 'read'); + const read = vi.spyOn(server.clients.db, 'pread'); try { await store.deleteUserAppPerm(user.id, app.id, 'driver:kv'); read.mockClear(); - // Answered from the re-filled cache, not from a replica that - // may still carry the row that was just removed. + // The replica may still carry the row that was removed. expect( await store.hasUserAppPerm(user.id, app.id, 'driver:kv'), ).toBe(false); - expect(read).not.toHaveBeenCalled(); + expect(read).toHaveBeenCalled(); } finally { read.mockRestore(); } @@ -442,6 +655,15 @@ describe('PermissionStore', () => { // -- dev → app ----------------------------------------------------- describe('dev-to-app permissions', () => { + it('matches a grant stored in another case, as the column collation did', async () => { + const dev = await makeUser(); + const app = await makeApp(dev.id); + await store.upsertDevAppPerm(dev.id, app.id, 'Dev:Read', {}); + + const rows = await store.readDevAppPerms(app.id, ['dev:read']); + expect(rows.map((r) => r.permission)).toEqual(['Dev:Read']); + }); + it('upserts, reads, and deletes the developer grants for an app', async () => { const dev = await makeUser(); const app = await makeApp(dev.id); @@ -488,6 +710,103 @@ describe('PermissionStore', () => { expect(await store.readDevAppPerms(app.id, [])).toEqual([]); }); + const d2aKey = (appId: number) => `perms:d2a:${appId}`; + const warmDevAppCache = async (appId: number, perms: string[]) => { + await server.clients.redis.del( + `${d2aKey(appId)}${CACHE_TOMBSTONE_SUFFIX}`, + ); + await store.readDevAppPerms(appId, perms); + await vi.waitFor(async () => + expect( + await server.clients.redis.get(d2aKey(appId)), + ).not.toBeNull(), + ); + }; + + it('serves repeat reads for an app from the cache', async () => { + const dev = await makeUser(); + const app = await makeApp(dev.id); + await store.upsertDevAppPerm(dev.id, app.id, 'dev:cached', { + scope: 'c', + }); + await warmDevAppCache(app.id, ['dev:cached']); + + const spy = vi.spyOn(server.clients.db, 'read'); + try { + const rows = await store.readDevAppPerms(app.id, [ + 'dev:cached', + 'dev:other', + ]); + expect(rows.map((r) => [r.permission, r.extra])).toEqual([ + ['dev:cached', { scope: 'c' }], + ]); + expect( + spy.mock.calls.filter(([q]) => + String(q).includes('dev_to_app_permissions'), + ), + ).toHaveLength(0); + } finally { + spy.mockRestore(); + } + }); + + it('every mutator drops the cached grants on this node and its peers', async () => { + const dev = await makeUser(); + const app = await makeApp(dev.id); + const emit = vi.spyOn(server.clients.event, 'emit'); + const broadcastD2a = () => + emit.mock.calls.some( + ([name, payload]) => + name === 'outer.cacheUpdate' && + (payload as { cacheKey: string[] }).cacheKey.includes( + d2aKey(app.id), + ), + ); + try { + await warmDevAppCache(app.id, ['dev:a']); + emit.mockClear(); + await store.upsertDevAppPerm(dev.id, app.id, 'dev:a', {}); + expect(broadcastD2a()).toBe(true); + expect( + (await store.readDevAppPerms(app.id, ['dev:a'])).length, + ).toBe(1); + + await warmDevAppCache(app.id, ['dev:a']); + emit.mockClear(); + await store.deleteDevAppPerm(dev.id, app.id, 'dev:a'); + expect(broadcastD2a()).toBe(true); + expect(await store.readDevAppPerms(app.id, ['dev:a'])).toEqual( + [], + ); + + await store.upsertDevAppPerm(dev.id, app.id, 'dev:b', {}); + await warmDevAppCache(app.id, ['dev:b']); + emit.mockClear(); + await store.deleteDevAppAll(dev.id, app.id); + expect(broadcastD2a()).toBe(true); + expect(await store.readDevAppPerms(app.id, ['dev:b'])).toEqual( + [], + ); + + const prefix = `app-data:${uuidv4()}`; + await store.upsertDevAppPerm( + dev.id, + app.id, + `${prefix}:fs`, + {}, + ); + await warmDevAppCache(app.id, [`${prefix}:fs`]); + emit.mockClear(); + await store.deleteAppGrantsByPermissionPrefix(prefix); + expect(broadcastD2a()).toBe(true); + expect( + await store.readDevAppPerms(app.id, [`${prefix}:fs`]), + ).toEqual([]); + } finally { + emit.mockRestore(); + } + }); + it('records a developer audit row', async () => { const dev = await makeUser(); const app = await makeApp(dev.id); diff --git a/src/backend/stores/permission/PermissionStore.ts b/src/backend/stores/permission/PermissionStore.ts index a6408a661..c8f05f9d4 100644 --- a/src/backend/stores/permission/PermissionStore.ts +++ b/src/backend/stores/permission/PermissionStore.ts @@ -37,7 +37,16 @@ import type { UserRow } from '../user/UserStore'; // Short TTLs: FK CASCADE on user/app delete + PermissionService rewriters // can touch rows this store's mutators never see. const U2A_CACHE_TTL_SECONDS = 5 * 60; +const D2A_CACHE_TTL_SECONDS = 5 * 60; const U2U_CACHE_TTL_SECONDS = 5 * 60; +/** + * Grants a holder may have and still be cached as one row set. Past it, each + * check reads only the permissions it names, so a check costs the same however + * many grants the holder has. + */ +export const U2U_CACHE_MAX_ROWS = 200; +/** Cached in place of the row set for a holder past {@link U2U_CACHE_MAX_ROWS}. */ +const U2U_TOO_MANY = 'too-many'; const TOKEN_CACHE_TTL_SECONDS = 10 * 60; // Rows per INSERT; five parameters each stays under SQLite's 999 limit. const ACCESS_TOKEN_PERM_INSERT_CHUNK = 100; @@ -78,6 +87,11 @@ export interface FlatPermValue { [k: string]: unknown; } +type DbRead = ( + query: string, + params: unknown[], +) => Promise[]>; + export interface LinkedUserUserPermRow { holder_user_id: number; issuer_user_id: number; @@ -392,9 +406,16 @@ export class PermissionStore extends PuterStore { permissions: string[], ): Promise { if (permissions.length === 0) return []; - const all = await this.#readAllUserUserPermsForHolder(holderUserId); - const wanted = new Set(permissions); - return all.filter((row) => wanted.has(row.permission)); + const { tombstoned, raw } = await this.readCacheUnlessDeleted( + this.#u2uCacheKey(holderUserId), + ); + const read: DbRead = tombstoned + ? this.clients.db.pread.bind(this.clients.db) + : this.clients.db.read.bind(this.clients.db); + const rows = + this.#parseCachedUserUserPerms(raw) ?? + (await this.#loadUserUserPerms(holderUserId, read)); + return this.#pickUserUserPerms(holderUserId, rows, permissions, read); } /** @@ -409,30 +430,15 @@ export class PermissionStore extends PuterStore { permissions: string[], ): Promise { if (permissions.length === 0) return []; - const rows = await this.clients.db.pread( - 'SELECT * FROM `user_to_user_permissions` WHERE `holder_user_id` = ?', - [holderUserId], - ); - const decoded = rows.map((row) => - this.#decodeExtra(row), - ); - this.clients.redis - .set( - this.#u2uCacheKey(holderUserId), - JSON.stringify(decoded), - 'EX', - U2U_CACHE_TTL_SECONDS, - ) - .catch(() => {}); - const wanted = new Set(permissions); - return decoded.filter((row) => wanted.has(row.permission)); + const pread = this.clients.db.pread.bind(this.clients.db); + const rows = await this.#loadUserUserPerms(holderUserId, pread); + return this.#pickUserUserPerms(holderUserId, rows, permissions, pread); } /** * Batched {@link readLinkedUserUserPerms} across holders: an indexed `holder * IN … AND permission IN …` read instead of one row-set per holder, which - * on a cold cache fans a listing page out into that many queries. Chunked - * to stay under the dialects' placeholder limits. + * on a cold cache fans a listing page out into that many queries. */ async readLinkedUserUserPermsForHolders( holderUserIds: number[], @@ -444,23 +450,16 @@ export class PermissionStore extends PuterStore { const perms = [...new Set(permissions)]; if (holders.length === 0 || perms.length === 0) return []; - const rows: LinkedUserUserPermRow[] = []; - for (let h = 0; h < holders.length; h += 200) { - const holderChunk = holders.slice(h, h + 200); - for (let p = 0; p < perms.length; p += 700) { - const permChunk = perms.slice(p, p + 700); - const found = await this.clients.db.read( - 'SELECT * FROM `user_to_user_permissions` WHERE ' + - `\`holder_user_id\` IN (${holderChunk.map(() => '?').join(', ')}) ` + - `AND \`permission\` IN (${permChunk.map(() => '?').join(', ')})`, - [...holderChunk, ...permChunk], - ); - for (const row of found) { - rows.push(this.#decodeExtra(row)); - } - } - } - return rows; + const changed = await this.tombstonedCacheKeys( + holders.map((holder) => this.#u2uCacheKey(holder)), + ); + return this.#selectUserUserPermsIn( + changed.size > 0 + ? this.clients.db.pread.bind(this.clients.db) + : this.clients.db.read.bind(this.clients.db), + holders, + perms, + ); } /** @@ -512,10 +511,7 @@ export class PermissionStore extends PuterStore { JSON.stringify(extra), ], ); - await this.publishCacheKeys({ - keys: [this.#u2uCacheKey(holderUserId)], - broadcast: true, - }); + await this.tombstoneCacheKeys([this.#u2uCacheKey(holderUserId)]); } /** @@ -536,10 +532,7 @@ export class PermissionStore extends PuterStore { [holderUserId, permission, issuerUserId], ); if (!result.anyRowsAffected) return false; - await this.publishCacheKeys({ - keys: [this.#u2uCacheKey(holderUserId)], - broadcast: true, - }); + await this.tombstoneCacheKeys([this.#u2uCacheKey(holderUserId)]); return true; } @@ -568,10 +561,7 @@ export class PermissionStore extends PuterStore { `DELETE FROM \`user_to_user_permissions\` WHERE ${scope}`, params, ); - await this.publishCacheKeys({ - keys: [this.#u2uCacheKey(holderUserId)], - broadcast: true, - }); + await this.tombstoneCacheKeys([this.#u2uCacheKey(holderUserId)]); return found; } @@ -701,8 +691,7 @@ export class PermissionStore extends PuterStore { const keys = [ ...new Set(rows.map((r) => this.#u2uCacheKey(r.holder_user_id))), ]; - if (keys.length > 0) - await this.publishCacheKeys({ keys, broadcast: true }); + if (keys.length > 0) await this.tombstoneCacheKeys(keys); return rows; } @@ -741,10 +730,7 @@ export class PermissionStore extends PuterStore { `DELETE FROM \`user_to_user_permissions\` WHERE ${scope} AND (${where})`, scoped, ); - await this.publishCacheKeys({ - keys: [this.#u2uCacheKey(holderUserId)], - broadcast: true, - }); + await this.tombstoneCacheKeys([this.#u2uCacheKey(holderUserId)]); } // The whole flat subtree, not only what SQL returned: an entry can @@ -1000,25 +986,6 @@ export class PermissionStore extends PuterStore { return rows.map((row) => this.#decodeExtra(row)); } - /** Re-fill from the primary; a miss on a replica re-caches a stale row. */ - async warmUserAppPermsFromPrimary( - userId: number, - appId: number, - ): Promise { - try { - const rows = await this.listUserAppPermsFromPrimary(userId, appId); - await this.clients.redis.set( - this.#u2aCacheKey(userId, appId), - JSON.stringify(rows), - 'EX', - U2A_CACHE_TTL_SECONDS, - ); - } catch (e) { - // A key left absent is the safe miss: the next read goes to the DB. - console.warn('[permission] u2a cache re-fill failed:', e); - } - } - /** * The apps a user has granted anything to, each with when it first was * (`installed_at`), one page at a time. @@ -1131,10 +1098,7 @@ export class PermissionStore extends PuterStore { JSON.stringify(extra), ], ); - await this.publishCacheKeys({ - keys: [this.#u2aCacheKey(userId, appId)], - broadcast: true, - }); + await this.tombstoneCacheKeys([this.#u2aCacheKey(userId, appId)]); } async deleteUserAppPerm( @@ -1146,11 +1110,7 @@ export class PermissionStore extends PuterStore { 'DELETE FROM `user_to_app_permissions` WHERE `user_id` = ? AND `app_id` = ? AND `permission` = ?', [userId, appId, permission], ); - await this.publishCacheKeys({ - keys: [this.#u2aCacheKey(userId, appId)], - broadcast: true, - }); - await this.warmUserAppPermsFromPrimary(userId, appId); + await this.tombstoneCacheKeys([this.#u2aCacheKey(userId, appId)]); } async deleteUserAppAll(userId: number, appId: number): Promise { @@ -1158,11 +1118,7 @@ export class PermissionStore extends PuterStore { 'DELETE FROM `user_to_app_permissions` WHERE `user_id` = ? AND `app_id` = ?', [userId, appId], ); - await this.publishCacheKeys({ - keys: [this.#u2aCacheKey(userId, appId)], - broadcast: true, - }); - await this.warmUserAppPermsFromPrimary(userId, appId); + await this.tombstoneCacheKeys([this.#u2aCacheKey(userId, appId)]); } /** @@ -1217,13 +1173,14 @@ export class PermissionStore extends PuterStore { const keys = [ ...new Set( - removed - .filter((r) => r.table === 'user_to_app_permissions') - .map((r) => this.#u2aCacheKey(r.user_id, r.app_id)), + removed.map((r) => + r.table === 'user_to_app_permissions' + ? this.#u2aCacheKey(r.user_id, r.app_id) + : this.#d2aCacheKey(r.app_id), + ), ), ]; - if (keys.length > 0) - await this.publishCacheKeys({ keys, broadcast: true }); + if (keys.length > 0) await this.tombstoneCacheKeys(keys); return removed; } @@ -1258,14 +1215,12 @@ export class PermissionStore extends PuterStore { permissions: string[], ): Promise { if (permissions.length === 0) return []; - let permClause = permissions.map(() => '`permission` = ?').join(' OR '); - if (permissions.length > 1) permClause = `(${permClause})`; - const rows = await this.clients.db.read( - 'SELECT * FROM `dev_to_app_permissions` ' + - `WHERE \`app_id\` = ? AND ${permClause}`, - [appId, ...permissions], - ); - return rows.map((row) => this.#decodeExtra(row)); + const all = await this.#readAllDevAppPerms(appId); + // Same match the column's case-insensitive, pad-space collation gave. + const fold = (permission: string) => + permission.toLowerCase().replace(/ +$/, ''); + const wanted = new Set(permissions.map(fold)); + return all.filter((row) => wanted.has(fold(row.permission))); } async upsertDevAppPerm( @@ -1289,6 +1244,7 @@ export class PermissionStore extends PuterStore { JSON.stringify(extra), ], ); + await this.tombstoneCacheKeys([this.#d2aCacheKey(appId)]); } async deleteDevAppPerm( @@ -1300,6 +1256,7 @@ export class PermissionStore extends PuterStore { 'DELETE FROM `dev_to_app_permissions` WHERE `user_id` = ? AND `app_id` = ? AND `permission` = ?', [userId, appId, permission], ); + await this.tombstoneCacheKeys([this.#d2aCacheKey(appId)]); } async deleteDevAppAll(userId: number, appId: number): Promise { @@ -1307,6 +1264,7 @@ export class PermissionStore extends PuterStore { 'DELETE FROM `dev_to_app_permissions` WHERE `user_id` = ? AND `app_id` = ?', [userId, appId], ); + await this.tombstoneCacheKeys([this.#d2aCacheKey(appId)]); } async auditDevAppPerm( @@ -1893,36 +1851,113 @@ export class PermissionStore extends PuterStore { return `perms:u2a:${userId}:${appId}`; } + #d2aCacheKey(appId: number): string { + return `perms:d2a:${appId}`; + } + #tokenCacheKey(tokenUid: string): string { return `perms:token:${tokenUid}`; } - async #readAllUserUserPermsForHolder( - holderUserId: number, - ): Promise { - const cacheKey = this.#u2uCacheKey(holderUserId); + #parseCachedUserUserPerms( + raw: string | null, + ): LinkedUserUserPermRow[] | typeof U2U_TOO_MANY | null { + if (!raw) return null; try { - const raw = await this.clients.redis.get(cacheKey); - if (raw) { - const parsed = JSON.parse(raw); - if (Array.isArray(parsed)) return parsed; - } + const parsed = JSON.parse(raw); + if (Array.isArray(parsed) || parsed === U2U_TOO_MANY) return parsed; } catch { // Fall through to DB. } + return null; + } - const rows = await this.clients.db.read( - 'SELECT * FROM `user_to_user_permissions` WHERE `holder_user_id` = ?', - [holderUserId], - ); - const decoded = rows.map((row) => - this.#decodeExtra(row), - ); + /** Off the request path; the tombstone re-check runs either way. */ + #fillCache(cacheKey: string, value: unknown, ttlSeconds: number): void { + void (async () => { + try { + await this.writeCacheUnlessDeleted([cacheKey], async () => { + await this.clients.redis.set( + cacheKey, + JSON.stringify(value), + 'EX', + ttlSeconds, + ); + }); + } catch { + // A missed fill is just a later miss. + } + })(); + } - this.clients.redis - .set(cacheKey, JSON.stringify(decoded), 'EX', U2U_CACHE_TTL_SECONDS) - .catch(() => {}); - return decoded; + /** The holder's whole row set, or the marker once it is too big to cache. */ + async #loadUserUserPerms( + holderUserId: number, + read: DbRead, + ): Promise { + const rows = await read( + 'SELECT * FROM `user_to_user_permissions` WHERE `holder_user_id` = ? LIMIT ?', + [holderUserId, U2U_CACHE_MAX_ROWS + 1], + ); + const value = + rows.length > U2U_CACHE_MAX_ROWS + ? U2U_TOO_MANY + : rows.map((row) => + this.#decodeExtra(row), + ); + this.#fillCache( + this.#u2uCacheKey(holderUserId), + value, + U2U_CACHE_TTL_SECONDS, + ); + return value; + } + + /** + * The rows naming exactly one of `permissions`. Exact in JS too: MySQL + * compares `permission` case-insensitively. + */ + async #pickUserUserPerms( + holderUserId: number, + rows: LinkedUserUserPermRow[] | typeof U2U_TOO_MANY, + permissions: string[], + read: DbRead, + ): Promise { + const wanted = new Set(permissions); + const candidates = + rows === U2U_TOO_MANY + ? await this.#selectUserUserPermsIn( + read, + [holderUserId], + [...wanted], + ) + : rows; + return candidates.filter((row) => wanted.has(row.permission)); + } + + /** Chunked to stay under the dialects' placeholder limits. */ + async #selectUserUserPermsIn( + read: DbRead, + holders: number[], + perms: string[], + ): Promise { + const rows: LinkedUserUserPermRow[] = []; + for (let h = 0; h < holders.length; h += 200) { + const holderChunk = holders.slice(h, h + 200); + for (let p = 0; p < perms.length; p += 700) { + const permChunk = perms.slice(p, p + 700); + const found = await read( + 'SELECT * FROM `user_to_user_permissions` WHERE ' + + `\`holder_user_id\` IN (${holderChunk.map(() => '?').join(', ')}) ` + + `AND \`permission\` IN (${permChunk.map(() => '?').join(', ')})`, + [...holderChunk, ...permChunk], + ); + for (const row of found) { + rows.push(this.#decodeExtra(row)); + } + } + } + return rows; } async #readAllUserAppPerms( @@ -1930,17 +1965,20 @@ export class PermissionStore extends PuterStore { appId: number, ): Promise { const cacheKey = this.#u2aCacheKey(userId, appId); - try { - const raw = await this.clients.redis.get(cacheKey); - if (raw) { + const { tombstoned, raw } = await this.readCacheUnlessDeleted(cacheKey); + if (raw) { + try { const parsed = JSON.parse(raw); if (Array.isArray(parsed)) return parsed; + } catch { + // Fall through to DB. } - } catch { - // Fall through to DB. } - const rows = await this.clients.db.read( + const read: DbRead = tombstoned + ? this.clients.db.pread.bind(this.clients.db) + : this.clients.db.read.bind(this.clients.db); + const rows = await read( 'SELECT * FROM `user_to_app_permissions` WHERE `user_id` = ? AND `app_id` = ?', [userId, appId], ); @@ -1948,9 +1986,34 @@ export class PermissionStore extends PuterStore { this.#decodeExtra(row), ); - this.clients.redis - .set(cacheKey, JSON.stringify(decoded), 'EX', U2A_CACHE_TTL_SECONDS) - .catch(() => {}); + this.#fillCache(cacheKey, decoded, U2A_CACHE_TTL_SECONDS); + return decoded; + } + + async #readAllDevAppPerms(appId: number): Promise { + const cacheKey = this.#d2aCacheKey(appId); + const { tombstoned, raw } = await this.readCacheUnlessDeleted(cacheKey); + if (raw) { + try { + const parsed = JSON.parse(raw); + if (Array.isArray(parsed)) return parsed; + } catch { + // Fall through to DB. + } + } + + const read: DbRead = tombstoned + ? this.clients.db.pread.bind(this.clients.db) + : this.clients.db.read.bind(this.clients.db); + const rows = await read( + 'SELECT * FROM `dev_to_app_permissions` WHERE `app_id` = ?', + [appId], + ); + const decoded = rows.map((row) => + this.#decodeExtra(row), + ); + + this.#fillCache(cacheKey, decoded, D2A_CACHE_TTL_SECONDS); return decoded; } diff --git a/src/backend/stores/session/SessionStore.js b/src/backend/stores/session/SessionStore.js index fec8a160b..35457d3de 100644 --- a/src/backend/stores/session/SessionStore.js +++ b/src/backend/stores/session/SessionStore.js @@ -38,10 +38,11 @@ import { PuterStore } from '../types'; const CACHE_KEY_PREFIX = 'sessions:v2'; const CACHE_TTL_SECONDS = 15 * 60; // Min interval between successive activity flushes per session/user. -// In-memory throttle keeps DB writes bounded; multi-node duplicates -// are harmless because the SQL guard `last_activity < ?` makes the -// UPDATE idempotent. +// The in-memory throttle is per process; a Redis `SET NX` gate behind it +// keeps the other nodes from writing the same rows in the same window. const TOUCH_THROTTLE_MS = 60 * 1000; +const TOUCH_GATE_TTL_SECONDS = TOUCH_THROTTLE_MS / 1000; +const TOUCH_GATE_PREFIX = 'sessions:touch'; // Hard cap to keep the throttle map from growing unbounded for // long-lived processes. Clearing only loses throttling — at worst a // brief burst of redundant UPDATEs. @@ -1085,25 +1086,61 @@ export class SessionStore extends PuterStore { this.#lastUserTouchMs.set(userId, nowMs); } + const [sessionClaimed, userClaimed] = await Promise.all([ + sessionDue && + this.#claimTouchGate(`${TOUCH_GATE_PREFIX}:s:${uuid}`), + userDue && this.#claimTouchGate(`${TOUCH_GATE_PREFIX}:u:${userId}`), + ]); + const tasks = []; - if (sessionDue) { + if (sessionClaimed) { tasks.push( - this.updateActivity(uuid, Math.floor(nowMs / 1000), { - ip, - userAgent, - }).catch(() => {}), + (async () => { + try { + await this.updateActivity( + uuid, + Math.floor(nowMs / 1000), + { ip, userAgent }, + ); + } catch { + // best-effort + } + })(), ); } - if (userDue) { + if (userClaimed) { tasks.push( - this.updateUserActivity(userId, sqlTimestamp(nowMs)).catch( - () => {}, - ), + (async () => { + try { + await this.updateUserActivity( + userId, + sqlTimestamp(nowMs), + ); + } catch { + // best-effort + } + })(), ); } await Promise.all(tasks); } + /** True when this node won the touch window; fails open on Redis errors. */ + async #claimTouchGate(key) { + try { + const result = await this.clients.redis.set( + key, + '1', + 'EX', + TOUCH_GATE_TTL_SECONDS, + 'NX', + ); + return result === 'OK'; + } catch { + return true; + } + } + // -- Internals --------------------------------------------------- #cacheKey(uuid) { diff --git a/src/backend/stores/session/SessionStore.test.ts b/src/backend/stores/session/SessionStore.test.ts index 265a2c4b5..17e025f28 100644 --- a/src/backend/stores/session/SessionStore.test.ts +++ b/src/backend/stores/session/SessionStore.test.ts @@ -1372,6 +1372,68 @@ describe('SessionStore', () => { }); }); + describe('touch shares its throttle across nodes', () => { + // A second store instance over the same DB and Redis stands in for + // another node, whose in-process throttle hasn't seen the session. + const peerStore = () => + new target.constructor( + target.config, + target.clients, + target.stores, + ); + + const countTouchWrites = (spy: ReturnType) => { + const sqls = spy.mock.calls.map((c) => String(c[0])); + return { + session: sqls.filter((q) => + q.startsWith('UPDATE `sessions` SET `last_activity`'), + ).length, + user: sqls.filter((q) => + q.startsWith('UPDATE `user` SET `last_activity_ts`'), + ).length, + }; + }; + + it('writes once per window when two nodes touch the same session', async () => { + const user = await makeUser(); + const session = await target.create(user.id, { kind: 'web' }); + const spy = vi.spyOn(server.clients.db, 'write'); + try { + await target.touch({ uuid: session.uuid, userId: user.id }); + await peerStore().touch({ + uuid: session.uuid, + userId: user.id, + }); + expect(countTouchWrites(spy)).toEqual({ session: 1, user: 1 }); + } finally { + spy.mockRestore(); + } + }); + + it('writes anyway when the Redis gate errors', async () => { + const user = await makeUser(); + const session = await target.create(user.id, { kind: 'web' }); + const writeSpy = vi.spyOn(server.clients.db, 'write'); + const redisSpy = vi + .spyOn(server.clients.redis, 'set') + .mockRejectedValue(new Error('redis down')); + try { + await target.touch({ uuid: session.uuid, userId: user.id }); + await peerStore().touch({ + uuid: session.uuid, + userId: user.id, + }); + expect(countTouchWrites(writeSpy)).toEqual({ + session: 2, + user: 2, + }); + } finally { + redisSpy.mockRestore(); + writeSpy.mockRestore(); + } + }); + }); + describe('touch slides expires_at per kind', () => { it('extends expires_at on web sessions', async () => { const user = await makeUser(); diff --git a/src/backend/stores/share/ShareStore.js b/src/backend/stores/share/ShareStore.js index c42334ba2..33e37628f 100644 --- a/src/backend/stores/share/ShareStore.js +++ b/src/backend/stores/share/ShareStore.js @@ -79,12 +79,10 @@ export class ShareStore extends PuterStore { // Same keyset page: `ORDER BY id` holds whatever the holder is. A // per-sender block deliberately does not apply here — a team share is // the team's, not one colleague's to withhold from another. - const holderClause = groups.length - ? `(\`holder_user_id\` = ? OR \`holder_group_id\` IN (${groups - .map(() => '?') - .join(', ')}))` - : '`holder_user_id` = ?'; - const holderParams = [holderUserId, ...groups]; + const { holderClause, holderParams } = this.#holderClause( + holderUserId, + groups, + ); // One extra row tells us whether another page exists. const rows = await this.clients.db.read( @@ -467,18 +465,49 @@ export class ShareStore extends PuterStore { const groups = [...new Set(groupIds)].filter(Boolean); // Same union as `listByHolder`, blocks included, or the total and the // page disagree. - const holderClause = groups.length - ? `(\`holder_user_id\` = ? OR \`holder_group_id\` IN (${groups - .map(() => '?') - .join(', ')}))` - : '`holder_user_id` = ?'; + const { holderClause, holderParams } = this.#holderClause( + holderUserId, + groups, + ); const rows = await this.clients.db.read( `SELECT COUNT(*) AS \`count\` FROM \`share\` WHERE ${holderClause}`, - [holderUserId, ...groups], + holderParams, ); return Number(rows[0]?.count ?? 0); } + /** + * The holder's own rows, plus their teams' rows on items that aren't + * theirs: what they shared with their own team, or a teammate shared of + * something they own, was not shared with them. Rows predating + * `entry_owner_user_id` are matched through their entry. + */ + #holderClause(holderUserId, groups) { + if (groups.length === 0) { + return { + holderClause: '`holder_user_id` = ?', + holderParams: [holderUserId], + }; + } + return { + holderClause: + '(`holder_user_id` = ? OR (`holder_group_id` IN (' + + groups.map(() => '?').join(', ') + + ') AND `issuer_user_id` <> ? AND ' + + '(`entry_owner_user_id` <> ? OR (`entry_owner_user_id` IS NULL ' + + 'AND NOT EXISTS (SELECT 1 FROM `fsentries` WHERE ' + + '`fsentries`.`id` = `share`.`fsentry_id` AND ' + + '`fsentries`.`user_id` = ?)))))', + holderParams: [ + holderUserId, + ...groups, + holderUserId, + holderUserId, + holderUserId, + ], + }; + } + /** * Pending invites for one address: a share aimed at someone who had no * account when it was made. `fsentry_id` distinguishes these from the @@ -1024,23 +1053,30 @@ export class ShareStore extends PuterStore { // every other row here it is not an index over a permission: nothing in // the permission tables stands behind it, and ACLService reads it directly. - /** - * The link share on one node, if any. - * - * @param {number} fsentryId - */ - /** How long after a link is withdrawn these reads go to the primary. */ + /** How long after a link is withdrawn reads of its node go to the primary. */ static REVOKED_LINK_PRIMARY_WINDOW_SECONDS = 30; - #revokedLinkKey() { - return 'share:anyone:revoked-recently'; + /** + * Per node, under both its row id and its uuid since lookups come by + * either. One hash tag, so a lookup's keys MGET from one cluster slot. + * + * @param {'id' | 'uuid'} kind + * @param {number | string} value + */ + static revokedLinkKey(kind, value) { + const part = kind === 'id' ? Number(value) : String(value); + return `share:anyone:revoked:{links}:${kind}:${part}`; } - /** Past the replica while a link has just been withdrawn. */ - async #readAnyone(sql, params) { + /** Past the replica while a link on one of these nodes was just withdrawn. */ + async #readAnyone(sql, params, markerKeys) { let recent = false; try { - recent = !!(await this.clients.redis.get(this.#revokedLinkKey())); + const keys = [...new Set(markerKeys)]; + if (keys.length > 0) { + const marks = await this.clients.redis.mget(...keys); + recent = marks.some(Boolean); + } } catch { // Unreadable marker reads as no revoke; the row is still checked. } @@ -1049,12 +1085,21 @@ export class ShareStore extends PuterStore { : this.clients.db.read(sql, params); } + /** + * The link share on one node, if any. + * + * @param {number} fsentryId + */ async getAnyone(fsentryId, { primary = false } = {}) { const sql = 'SELECT * FROM `share` WHERE `fsentry_id` = ? AND `anyone` = 1 LIMIT 1'; const rows = primary ? await this.clients.db.pread(sql, [fsentryId]) - : await this.#readAnyone(sql, [fsentryId]); + : await this.#readAnyone( + sql, + [fsentryId], + [ShareStore.revokedLinkKey('id', fsentryId)], + ); return this.#normalizeRow(rows[0]) ?? null; } @@ -1070,6 +1115,7 @@ export class ShareStore extends PuterStore { `SELECT * FROM \`share\` WHERE \`fsentry_id\` IN (${placeholders}) ` + 'AND `anyone` = 1 ORDER BY `id`', fsentryIds, + fsentryIds.map((id) => ShareStore.revokedLinkKey('id', id)), ); return rows.map((r) => this.#normalizeRow(r)); } @@ -1094,6 +1140,7 @@ export class ShareStore extends PuterStore { `WHERE \`fsentries\`.\`uuid\` IN (${placeholders}) ` + 'AND `share`.`anyone` = 1', uuids, + uuids.map((uuid) => ShareStore.revokedLinkKey('uuid', uuid)), ); return rows.map((row) => ({ mode: String(row.mode), @@ -1161,25 +1208,40 @@ export class ShareStore extends PuterStore { * Drop the link share on one node. * * @param {number} fsentryId + * @param {string} [entryUuid] The node's uuid; looked up when omitted. */ - async deleteAnyone(fsentryId) { + async deleteAnyone(fsentryId, entryUuid) { const result = await this.clients.db.write( 'DELETE FROM `share` WHERE `fsentry_id` = ? AND `anyone` = 1', [fsentryId], ); const removed = result.anyRowsAffected; - if (removed) await this.#markLinkRevoked(); + if (removed) await this.#markLinkRevoked(fsentryId, entryUuid); return removed; } - /** Open the window in which link reads go to the primary. */ - async #markLinkRevoked() { + /** Open the window in which reads of this node's link go to the primary. */ + async #markLinkRevoked(fsentryId, entryUuid) { try { - await this.clients.redis.set( - this.#revokedLinkKey(), - '1', - 'EX', - ShareStore.REVOKED_LINK_PRIMARY_WINDOW_SECONDS, + let uuid = entryUuid; + if (!uuid) { + const rows = await this.clients.db.pread( + 'SELECT `uuid` FROM `fsentries` WHERE `id` = ? LIMIT 1', + [fsentryId], + ); + uuid = rows[0]?.uuid; + } + const keys = [ShareStore.revokedLinkKey('id', fsentryId)]; + if (uuid) keys.push(ShareStore.revokedLinkKey('uuid', uuid)); + await Promise.all( + keys.map((key) => + this.clients.redis.set( + key, + '1', + 'EX', + ShareStore.REVOKED_LINK_PRIMARY_WINDOW_SECONDS, + ), + ), ); } catch (e) { console.warn('[share] link-revoke marker not set:', e); diff --git a/src/backend/stores/share/ShareStore.test.js b/src/backend/stores/share/ShareStore.test.js index 9d876778e..64f5b2073 100644 --- a/src/backend/stores/share/ShareStore.test.js +++ b/src/backend/stores/share/ShareStore.test.js @@ -20,8 +20,10 @@ import { v4 as uuidv4 } from 'uuid'; import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import clusterKeySlot from 'cluster-key-slot'; import { setupTestServer } from '../../testUtil.ts'; import { decodeCursor } from '../../util/pagination.ts'; +import { ShareStore } from './ShareStore.js'; describe('ShareStore', () => { let server; @@ -268,6 +270,73 @@ describe('ShareStore', () => { expect(page.items.map((r) => r.uid)).toContain(created.uid); }); + it('sends only lookups naming the withdrawn node to the primary', async () => { + const withdrawn = await makeEntry(issuer); + const other = await makeEntry(issuer); + for (const entry of [withdrawn, other]) { + await store.upsertAnyone({ + issuerUserId: issuer.id, + fsentryId: entry.id, + mode: 'read', + }); + } + await store.deleteAnyone(withdrawn.id, withdrawn.uuid); + + const pread = vi.spyOn(server.clients.db, 'pread'); + try { + await store.getAnyone(other.id); + await store.listAnyoneOnFsentries([other.id]); + await store.listAnyoneReaching([other.uuid]); + expect(pread).not.toHaveBeenCalled(); + + // Every lookup the withdrawn row could appear in, by id or by + // uuid, alone or among ancestors. + await store.getAnyone(withdrawn.id); + await store.listAnyoneOnFsentries([other.id, withdrawn.id]); + expect( + await store.listAnyoneReaching([ + other.uuid, + withdrawn.uuid, + ]), + ).toHaveLength(1); + expect(pread).toHaveBeenCalledTimes(3); + } finally { + pread.mockRestore(); + } + + const ttl = await server.clients.redis.ttl( + ShareStore.revokedLinkKey('uuid', withdrawn.uuid), + ); + expect(ttl).toBeGreaterThan(25); + }); + + it('marks the uuid too when the caller passes only the row id', async () => { + const entry = await makeEntry(issuer); + await store.upsertAnyone({ + issuerUserId: issuer.id, + fsentryId: entry.id, + mode: 'read', + }); + await store.deleteAnyone(entry.id); + expect( + await server.clients.redis.get( + ShareStore.revokedLinkKey('uuid', entry.uuid), + ), + ).toBe('1'); + }); + + it('keeps every revoke marker in one cluster slot', () => { + const keys = [ + ShareStore.revokedLinkKey('id', 1), + ShareStore.revokedLinkKey('id', 987654), + ShareStore.revokedLinkKey('uuid', uuidv4()), + ShareStore.revokedLinkKey('uuid', uuidv4()), + ]; + expect(new Set(keys.map((key) => clusterKeySlot(key))).size).toBe( + 1, + ); + }); + it('reads a link past the replica once one has been withdrawn', async () => { const entry = await makeEntry(issuer); await store.upsertAnyone({ @@ -595,6 +664,58 @@ describe('ShareStore', () => { expect(await store.countByHolder(pageHolder.id)).toBe(5); }); + it("leaves a member's own items out of their team's rows, page and count", async () => { + const member = await makeUser(); + const mate = await makeUser(); + const groupUid = uuidv4(); + await server.clients.db.write( + 'INSERT INTO `group` (`uid`, `owner_user_id`, `kind`, ' + + '`name`, `extra`, `metadata`) VALUES (?, ?, ?, ?, ?, ?)', + [groupUid, member.id, 'team', 'Own items', '{}', '{}'], + ); + const [{ id: groupId }] = await server.clients.db.read( + 'SELECT `id` FROM `group` WHERE `uid` = ?', + [groupUid], + ); + const groupRow = async (issuerUserId, entry) => + store.upsertActiveGroup({ + issuerUserId, + holderGroupId: Number(groupId), + fsentryId: entry.id, + mode: 'read', + }); + + // Issued by the member. + await groupRow(member.id, await makeEntry(member)); + // A teammate's share of the member's item. + await groupRow(mate.id, await makeEntry(member)); + // As that, from before `entry_owner_user_id` was recorded. + const legacy = await groupRow(mate.id, await makeEntry(member)); + await server.clients.db.write( + 'UPDATE `share` SET `entry_owner_user_id` = NULL WHERE `uid` = ?', + [legacy.uid], + ); + const theirs = await groupRow(mate.id, await makeEntry(mate)); + const direct = await store.upsertActive({ + issuerUserId: mate.id, + holderUserId: member.id, + fsentryId: (await makeEntry(mate)).id, + mode: 'read', + }); + + const opts = { groupIds: [Number(groupId)] }; + const page = await store.listByHolder(member.id, opts); + expect(page.items.map((r) => r.uid).sort()).toEqual( + [theirs.uid, direct.uid].sort(), + ); + expect(await store.countByHolder(member.id, opts)).toBe(2); + + // The teammate gets the member's items, and not their own. + const matePage = await store.listByHolder(mate.id, opts); + expect(matePage.items).toHaveLength(1); + expect(await store.countByHolder(mate.id, opts)).toBe(1); + }); + it('lists what a user issued alongside what was issued on their node', async () => { const owner = await makeUser(); const delegate = await makeUser(); diff --git a/src/backend/stores/subdomain/SubdomainStore.test.ts b/src/backend/stores/subdomain/SubdomainStore.test.ts index af5dd10a5..3ed51f92c 100644 --- a/src/backend/stores/subdomain/SubdomainStore.test.ts +++ b/src/backend/stores/subdomain/SubdomainStore.test.ts @@ -69,8 +69,10 @@ describe('SubdomainStore cache coherency', () => { const row = await store.create({ userId, subdomain, rootDirId: 111 }); // Force the next lookup down the cache-miss path, which is where the - // read populates the cache. + // read populates the cache. Past the create's change window, so the + // read takes the replica path. await server.clients.redis.del(cacheKey(subdomain)); + await server.clients.redis.del(`${cacheKey(subdomain)}:deleted`); // Stall just the read path's own `set`. The update path writes // through `pipeline()`, so this leaves it untouched and lets the @@ -323,6 +325,27 @@ describe('SubdomainStore in a peer region behind a lagging replica', () => { expect(found?.root_dir_id).toBe(2); }); + it('finds a just-created site, and caches no miss, behind a lagging replica', async () => { + const userId = await makeUser(); + const name = newName('create'); + const other = newName('create-batch'); + + await applyOnPeer(name, null, async () => { + await store.create({ userId, subdomain: name, rootDirId: 1 }); + await store.create({ userId, subdomain: other, rootDirId: 2 }); + }); + await server.clients.redis.del(cacheKey(other)); + + const [found, batch] = await withLaggingReplica([], async () => [ + await store.getBySubdomain(name), + await store.getBySubdomains([other]), + ]); + expect(found?.root_dir_id).toBe(1); + expect(batch.get(other)?.root_dir_id).toBe(2); + expect(await settledCache(name)).not.toBe('__none__'); + expect(await settledCache(other)).not.toBe('__none__'); + }); + it('sends a recently changed name in a batch to the primary', async () => { const userId = await makeUser(); const name = newName('batch'); @@ -1038,6 +1061,37 @@ describe('SubdomainStore.getBySubdomains', () => { } }); + it('reports negative-cache hits, and only those, in `cachedMisses`', async () => { + const userId = await makeUser(); + const cachedGone = `gbs-cm-${Math.random().toString(36).slice(2, 8)}`; + const uncachedGone = `gbs-cm-${Math.random().toString(36).slice(2, 8)}`; + const live = `gbs-cm-${Math.random().toString(36).slice(2, 8)}`; + await store.create({ userId, subdomain: live }); + await server.clients.redis.set(cacheKey(cachedGone), '__none__'); + + const batchMisses = new Set(); + const found = await store.getBySubdomains( + [cachedGone, uncachedGone, live], + { cachedMisses: batchMisses }, + ); + expect([...found.keys()]).toEqual([live]); + expect([...batchMisses]).toEqual([cachedGone]); + + const singleMisses = new Set(); + expect( + await store.getBySubdomain(cachedGone, { + cachedMisses: singleMisses, + }), + ).toBeNull(); + await server.clients.redis.del(cacheKey(uncachedGone)); + expect( + await store.getBySubdomain(uncachedGone, { + cachedMisses: singleMisses, + }), + ).toBeNull(); + expect([...singleMisses]).toEqual([cachedGone]); + }); + it('treats a cached JSON null as a miss and reads the DB, like getBySubdomain', async () => { const owner = await makeUser(); const name = `gbs-null-${Math.random().toString(36).slice(2, 8)}`; @@ -1071,3 +1125,27 @@ describe('SubdomainStore.getBySubdomains', () => { expect(healedFound.get(name)?.subdomain).toBe(name); }); }); + +describe('SubdomainStore.trashedRootDirIds', () => { + it('names the root dirs that sit in their owner Trash', async () => { + const fsEntry = server.stores.fsEntry; + const spy = vi.spyOn(fsEntry, 'getEntriesByIds').mockResolvedValue( + new Map([ + [1, { id: 1, path: '/alice/Trash/uid-1' }], + [2, { id: 2, path: '/alice/Trash' }], + [3, { id: 3, path: '/alice/Documents/Trash/site' }], + [4, { id: 4, path: '/alice/Trashy' }], + [5, { id: 5, path: '/alice/Public' }], + ]) as never, + ); + try { + expect([ + ...(await store.trashedRootDirIds([1, 2, 3, 4, 5])), + ]).toEqual([1, 2]); + expect(await store.trashedRootDirIds([])).toEqual(new Set()); + expect(spy).toHaveBeenCalledTimes(1); + } finally { + spy.mockRestore(); + } + }); +}); diff --git a/src/backend/stores/subdomain/SubdomainStore.ts b/src/backend/stores/subdomain/SubdomainStore.ts index e75173fcd..ce5018aa5 100644 --- a/src/backend/stores/subdomain/SubdomainStore.ts +++ b/src/backend/stores/subdomain/SubdomainStore.ts @@ -19,6 +19,7 @@ import { v4 as uuidv4 } from 'uuid'; import { escapeLike } from '../../util/sqlLike'; +import { isTrashedPath } from '../../util/hostedAppBacking'; import { runWithConcurrencyLimit } from '../../util/concurrency'; import { CACHE_TOMBSTONE_TTL_SECONDS, PuterStore } from '../types'; @@ -155,10 +156,16 @@ export class SubdomainStore extends PuterStore { * which may hold a stale negative marker — and reads the primary instead of * a possibly-lagging replica. The result still refreshes the cache, healing * any stale marker. + * + * `cachedMisses` collects the name when the null came from a negative cache + * entry rather than a database read. */ async getBySubdomain( subdomain: string, - { primary = false }: { primary?: boolean } = {}, + { + primary = false, + cachedMisses, + }: { primary?: boolean; cachedMisses?: Set } = {}, ): Promise { if (!subdomain) return null; @@ -166,7 +173,10 @@ export class SubdomainStore extends PuterStore { if (!primary) { try { const raw = await this.clients.redis.get(cacheKey); - if (raw === NEGATIVE_CACHE_MARKER) return null; + if (raw === NEGATIVE_CACHE_MARKER) { + cachedMisses?.add(subdomain); + return null; + } if (raw) { const parsed = JSON.parse(raw) as SubdomainRow | null; if (parsed) return parsed; @@ -205,11 +215,15 @@ export class SubdomainStore extends PuterStore { * Batched sibling of {@link getBySubdomain} — one cache pipeline plus one * chunked `IN (…)` query for the misses, instead of one lookup per name. * Keyed by the requested names; a name with no row (negative-cached or - * genuinely absent) is simply missing from the returned map. + * genuinely absent) is simply missing from the returned map. `cachedMisses` + * collects the names that were negative-cached. */ async getBySubdomains( names: string[], - { primary = false }: { primary?: boolean } = {}, + { + primary = false, + cachedMisses, + }: { primary?: boolean; cachedMisses?: Set } = {}, ): Promise> { const result = new Map(); const uniqueNames = [ @@ -233,7 +247,10 @@ export class SubdomainStore extends PuterStore { for (let i = 0; i < uniqueNames.length; i++) { const name = uniqueNames[i]!; const raw = cacheResults[i]?.[1]; - if (raw === NEGATIVE_CACHE_MARKER) continue; // cached miss + if (raw === NEGATIVE_CACHE_MARKER) { + cachedMisses?.add(name); + continue; + } if (typeof raw === 'string') { try { const parsed = JSON.parse( @@ -324,6 +341,23 @@ export class SubdomainStore extends PuterStore { return rowsByName; } + /** + * Which of `rootDirIds` sit in their owner's Trash, where sites don't + * serve. + */ + async trashedRootDirIds(rootDirIds: number[]): Promise> { + const trashed = new Set(); + const fsEntry = this.stores?.fsEntry; + if (rootDirIds.length === 0 || !fsEntry?.getEntriesByIds) { + return trashed; + } + const entries = await fsEntry.getEntriesByIds(rootDirIds); + for (const [id, entry] of entries) { + if (entry?.path && isTrashedPath(entry.path)) trashed.add(id); + } + return trashed; + } + async listByUserId( userId: number, { @@ -599,7 +633,9 @@ export class SubdomainStore extends PuterStore { preamble_version: preambleVersion, protected: isProtected ? 1 : 0, }; - await this.#refreshCache(row); + // Tombstoned so a peer whose replica lacks the row yet can't cache a + // negative marker for the new name. + await this.#publishChanged([row as unknown as SubdomainRow]); await this.#invalidateRootDirEntry(row.root_dir_id); return row; @@ -924,16 +960,6 @@ export class SubdomainStore extends PuterStore { } } - async #refreshCache(row: { subdomain?: string }) { - if (!row?.subdomain) return; - await this.publishCacheKeys({ - keys: [this.#cacheKey(row.subdomain)], - serializedData: JSON.stringify(row), - ttlSeconds: CACHE_TTL_SECONDS, - broadcast: true, - }); - } - // FSEntryStore caches each row with an embedded `subdomains_agg` JSON // (uuid + subdomain) keyed on `root_dir_id`. Without this, a deleted or // renamed subdomain keeps showing up under its old folder in the GUI diff --git a/src/backend/stores/types.ts b/src/backend/stores/types.ts index 49ea2bad8..80827917a 100644 --- a/src/backend/stores/types.ts +++ b/src/backend/stores/types.ts @@ -50,8 +50,9 @@ export type IPuterStore = new ( const DEFAULT_BROADCAST_REFRESH_TTL_SECONDS = 15 * 60; -// Tombstone lifetime; must outlast replica lag. -export const CACHE_TOMBSTONE_TTL_SECONDS = 60; +// Tombstone lifetime, counted from each node's own receipt; must outlast +// replica lag at the receiving node. +export const CACHE_TOMBSTONE_TTL_SECONDS = 5; export const CACHE_TOMBSTONE_SUFFIX = ':deleted'; /** A tombstone marker rather than a cached row; peers must set it, not drop it. */ @@ -464,6 +465,31 @@ export const PuterStore = class PuterStore implements WithLifecycle { return (await this.tombstonedCacheKeys(keys)).size > 0; } + /** + * `key`'s cached value and whether it is tombstoned, in one round trip. + * `raw` is null under a tombstone. Fails open. + */ + protected async readCacheUnlessDeleted( + key: string, + ): Promise<{ tombstoned: boolean; raw: string | null }> { + try { + const results = + (await this.clients.redis + .pipeline() + .exists(key + CACHE_TOMBSTONE_SUFFIX) + .get(key) + .exec()) ?? []; + const tombstoned = Number(results[0]?.[1]) > 0; + const raw = results[1]?.[1]; + return { + tombstoned, + raw: !tombstoned && typeof raw === 'string' ? raw : null, + }; + } catch { + return { tombstoned: false, raw: null }; + } + } + /** Which of `keys` were deleted within the tombstone window. Fails open. */ protected async tombstonedCacheKeys(keys: string[]): Promise> { const deleted = new Set(); diff --git a/src/backend/stores/user/UserStore.ts b/src/backend/stores/user/UserStore.ts index 10af3f990..9c13d00ba 100644 --- a/src/backend/stores/user/UserStore.ts +++ b/src/backend/stores/user/UserStore.ts @@ -316,7 +316,9 @@ export class UserStore extends PuterStore { const rows = ( primary ? await this.clients.db.pread(sql, chunk) - : await this.clients.db.tryHardRead(sql, chunk) + : await this.clients.db.tryHardRead(sql, chunk, { + race: false, + }) ) as Array>; for (const row of rows) users.push(this.#normalizeRow(row)); } @@ -430,8 +432,8 @@ export class UserStore extends PuterStore { // Replication-aware read: on `force` (or a tombstoned key), go // straight to the primary (`pread`) to bypass replica lag for hot - // reads (e.g., immediately after a signup). Otherwise `tryHardRead` - // parallels primary + replica and prefers whichever returns rows. + // reads (e.g., immediately after a signup). Otherwise the primary is + // only read when the replica returns no match or fails. // `id`, `uuid` and `username` are UNIQUE, so at most one row matches and // the optimizer drops the ordering. `email` is not — multiple rows may // legitimately hold the same address while unconfirmed, so without an @@ -447,7 +449,9 @@ export class UserStore extends PuterStore { async (primary) => { const rows = primary ? await this.clients.db.pread(sql, [value]) - : await this.clients.db.tryHardRead(sql, [value]); + : await this.clients.db.tryHardRead(sql, [value], { + race: false, + }); const row = rows[0]; if (!row) return null; if ( diff --git a/src/backend/util/hostedAppBacking.test.ts b/src/backend/util/hostedAppBacking.test.ts index fc8bb1258..391c6d4d2 100644 --- a/src/backend/util/hostedAppBacking.test.ts +++ b/src/backend/util/hostedAppBacking.test.ts @@ -21,6 +21,7 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; import { v4 as uuidv4 } from 'uuid'; import { PuterServer } from '../server.js'; import { setupTestServer } from '../testUtil.js'; +import { generateDefaultFsentries } from './userProvisioning.js'; import { buildHostedBackingDenial, buildHostedSubdomainIndexUrlCandidates, @@ -272,6 +273,71 @@ describe('hostedIndexUrlBackingIsUnavailable', () => { }); }); + it('trusts a negative-cache hit without asking the primary', async () => { + const getBySubdomain = vi.fn( + async (sub: string, opts?: { cachedMisses?: Set }) => { + opts?.cachedMisses?.add(sub); + return null; + }, + ); + expect( + await hostedIndexUrlBackingIsUnavailable({ + app: { + index_url: 'https://gone.puter.site/', + owner_user_id: 7, + }, + subdomainStore: { getBySubdomain } as never, + config: CONFIG, + }), + ).toBe(true); + expect(getBySubdomain).toHaveBeenCalledTimes(1); + }); + + it('is true when the site root is in Trash, and false once it is not', async () => { + const trashed = new Set([5]); + const subdomainStore = { + getBySubdomain: async () => ({ user_id: 7, root_dir_id: 5 }), + trashedRootDirIds: vi.fn(async () => trashed), + } as never; + const app = { index_url: 'https://mine.puter.site/', owner_user_id: 7 }; + expect( + await hostedIndexUrlBackingIsUnavailable({ + app, + subdomainStore, + config: CONFIG, + }), + ).toBe(true); + trashed.clear(); + expect( + await hostedIndexUrlBackingIsUnavailable({ + app, + subdomainStore, + config: CONFIG, + }), + ).toBe(false); + }); + + it('counts the site as served when the Trash lookup fails', async () => { + expect( + await hostedIndexUrlBackingIsUnavailable({ + app: { + index_url: 'https://mine.puter.site/', + owner_user_id: 7, + }, + subdomainStore: { + getBySubdomain: async () => ({ + user_id: 7, + root_dir_id: 5, + }), + trashedRootDirIds: async () => { + throw new Error('fs down'); + }, + } as never, + config: CONFIG, + }), + ).toBe(false); + }); + it('is true when either owner id is not a usable integer', async () => { // `undefined` and non-numeric strings coerce to NaN, so neither // side can be compared — withhold rather than guess. @@ -513,4 +579,98 @@ describe('hostedIndexUrlBackingsAreUnavailable (batched, against a real store)', getBySubdomains.mockRestore(); } }); + + it('skips the primary for a name already negative-cached', async () => { + const owner = await makeUser(); + const dangling = { + index_url: hostedUrl( + `gone-c-${Math.random().toString(36).slice(2, 6)}`, + ), + owner_user_id: owner.id, + }; + // The first call reads the primary and caches the miss. + expect( + await hostedIndexUrlBackingsAreUnavailable({ + apps: [dangling], + subdomainStore: server.stores.subdomain, + config: HOSTING_CONFIG, + }), + ).toEqual([true]); + + const getBySubdomains = vi.spyOn( + server.stores.subdomain, + 'getBySubdomains', + ); + const pread = vi.spyOn(server.clients.db, 'pread'); + try { + await vi.waitFor(async () => + expect( + await hostedIndexUrlBackingsAreUnavailable({ + apps: [dangling], + subdomainStore: server.stores.subdomain, + config: HOSTING_CONFIG, + }), + ).toEqual([true]), + ); + getBySubdomains.mockClear(); + pread.mockClear(); + expect( + await hostedIndexUrlBackingsAreUnavailable({ + apps: [dangling], + subdomainStore: server.stores.subdomain, + config: HOSTING_CONFIG, + }), + ).toEqual([true]); + expect(getBySubdomains).toHaveBeenCalledTimes(1); + expect( + pread.mock.calls.filter(([sql]) => + String(sql).includes('FROM `subdomains`'), + ), + ).toEqual([]); + } finally { + getBySubdomains.mockRestore(); + pread.mockRestore(); + } + }); + + it('reports a site rooted in Trash as unavailable', async () => { + const owner = await makeUser(); + await generateDefaultFsentries( + server.clients.db, + server.stores.user, + owner, + ); + const trash = await server.stores.fsEntry.getEntryByPath( + `/${owner.username}/Trash`, + ); + const publicDir = await server.stores.fsEntry.getEntryByPath( + `/${owner.username}/Public`, + ); + const trashedSub = `trashed-${Math.random().toString(36).slice(2, 8)}`; + const liveSub = `served-${Math.random().toString(36).slice(2, 8)}`; + await server.stores.subdomain.create({ + userId: owner.id, + subdomain: trashedSub, + rootDirId: trash!.id, + }); + await server.stores.subdomain.create({ + userId: owner.id, + subdomain: liveSub, + rootDirId: publicDir!.id, + }); + + expect( + await hostedIndexUrlBackingsAreUnavailable({ + apps: [ + { + index_url: hostedUrl(trashedSub), + owner_user_id: owner.id, + }, + { index_url: hostedUrl(liveSub), owner_user_id: owner.id }, + ], + subdomainStore: server.stores.subdomain, + config: HOSTING_CONFIG, + }), + ).toEqual([true, false]); + }); }); diff --git a/src/backend/util/hostedAppBacking.ts b/src/backend/util/hostedAppBacking.ts index d7b966c25..1f9f43e6b 100644 --- a/src/backend/util/hostedAppBacking.ts +++ b/src/backend/util/hostedAppBacking.ts @@ -45,6 +45,10 @@ import type { PrivateLaunchDecision } from './privateLaunchAccess'; export const HOSTED_BACKING_UNAVAILABLE_REASON = 'hosted_backing_unavailable'; +/** Inside some owner's top-level Trash, which is where Delete puts things. */ +export const isTrashedPath = (path: string): boolean => + /^\/[^/]+\/Trash(\/|$)/u.test(path); + interface HostedDomainConfig { static_hosting_domain?: unknown; static_hosting_domain_alt?: unknown; @@ -58,6 +62,13 @@ interface AppBackingRow { owner_user_id?: unknown; } +interface BackingRow { + user_id?: unknown; + root_dir_id?: unknown; +} + +type TrashLookup = Partial>; + function normalizeHostedDomainValue(domainValue: unknown): string | null { if (typeof domainValue !== 'string') return null; const normalizedDomain = domainValue @@ -187,7 +198,7 @@ export function extractPuterHostedSubdomain( */ function backingRowIsUnavailable( app: AppBackingRow, - row: { user_id?: unknown } | null, + row: BackingRow | null, ): boolean { if (!row) return true; // subdomain no longer exists → dangling @@ -200,9 +211,37 @@ function backingRowIsUnavailable( } /** - * True when the app's puter-hosted subdomain is missing, or is currently owned - * by a different user than the app's owner (it was reclaimed — launching would - * leak the token to the new owner). Non-hosted index_urls return false. + * Root dir ids of `rows` that are in Trash, where the site 404s. Best-effort: + * the owner's own site is no token risk, so a failed lookup counts as served. + */ +async function trashedRootsOf( + rows: Array, + subdomainStore: TrashLookup, +): Promise> { + if (!subdomainStore.trashedRootDirIds) return new Set(); + const ids = [ + ...new Set( + rows + .map((row) => Number(row?.root_dir_id ?? NaN)) + .filter((id) => Number.isInteger(id)), + ), + ]; + if (ids.length === 0) return new Set(); + try { + return await subdomainStore.trashedRootDirIds(ids); + } catch { + return new Set(); + } +} + +const rootIsTrashed = (row: BackingRow | null, trashed: Set) => + row?.root_dir_id != null && trashed.has(Number(row.root_dir_id)); + +/** + * True when the app's puter-hosted subdomain is missing, is currently owned by + * a different user than the app's owner (it was reclaimed — launching would + * leak the token to the new owner), or is rooted in Trash. Non-hosted + * index_urls return false. */ export async function hostedIndexUrlBackingIsUnavailable({ app, @@ -210,30 +249,34 @@ export async function hostedIndexUrlBackingIsUnavailable({ config, }: { app: AppBackingRow; - subdomainStore: Pick; + subdomainStore: Pick & TrashLookup; config: HostedDomainConfig | undefined | null; }): Promise { const subdomain = extractPuterHostedSubdomain(app.index_url, config); if (!subdomain) return false; - let row = await subdomainStore.getBySubdomain(subdomain); - if (!row) { + const cachedMisses = new Set(); + let row = await subdomainStore.getBySubdomain(subdomain, { cachedMisses }); + if (!row && !cachedMisses.has(subdomain)) { // A freshly-created subdomain may not have reached a replica or the // local cache yet; confirm against the primary before treating the // backing as gone (mirrors the create/update ownership check in - // `AppDriver#ensurePuterSiteSubdomainIsOwned`). + // `AppDriver#ensurePuterSiteSubdomainIsOwned`). A negative-cache hit + // needs no second look: creating a site clears and tombstones it. row = await subdomainStore.getBySubdomain(subdomain, { primary: true, }); } - return backingRowIsUnavailable(app, row); + if (backingRowIsUnavailable(app, row)) return true; + return rootIsTrashed(row, await trashedRootsOf([row], subdomainStore)); } /** * Batched sibling of {@link hostedIndexUrlBackingIsUnavailable} — one * `getBySubdomains` call (plus a single primary follow-up for whatever's still - * missing) instead of per-app round trips. Returns a boolean per `apps` entry, - * in input order, with the same semantics as the single-app function above. + * missing and not negative-cached) instead of per-app round trips. Returns a + * boolean per `apps` entry, in input order, with the same semantics as the + * single-app function above. */ export async function hostedIndexUrlBackingsAreUnavailable({ apps, @@ -241,7 +284,7 @@ export async function hostedIndexUrlBackingsAreUnavailable({ config, }: { apps: AppBackingRow[]; - subdomainStore: Pick; + subdomainStore: Pick & TrashLookup; config: HostedDomainConfig | undefined | null; }): Promise { const subdomains = apps.map((app) => @@ -252,8 +295,13 @@ export async function hostedIndexUrlBackingsAreUnavailable({ ]; if (names.length === 0) return apps.map(() => false); - let rowsByName = await subdomainStore.getBySubdomains(names); - const missing = names.filter((name) => !rowsByName.has(name)); + const cachedMisses = new Set(); + let rowsByName = await subdomainStore.getBySubdomains(names, { + cachedMisses, + }); + const missing = names.filter( + (name) => !rowsByName.has(name) && !cachedMisses.has(name), + ); if (missing.length > 0) { const primaryRows = await subdomainStore.getBySubdomains(missing, { primary: true, @@ -261,10 +309,18 @@ export async function hostedIndexUrlBackingsAreUnavailable({ rowsByName = new Map([...rowsByName, ...primaryRows]); } - return apps.map((app, i) => { + const rows = apps.map((app, i) => { const subdomain = subdomains[i]; + if (!subdomain) return null; + const row = rowsByName.get(subdomain) ?? null; + return backingRowIsUnavailable(app, row) ? null : row; + }); + const trashed = await trashedRootsOf(rows, subdomainStore); + + return subdomains.map((subdomain, i) => { if (!subdomain) return false; - return backingRowIsUnavailable(app, rowsByName.get(subdomain) ?? null); + const row = rows[i] ?? null; + return !row || rootIsTrashed(row, trashed); }); } diff --git a/src/docs/src/AI/chat.md b/src/docs/src/AI/chat.md index 417349069..2d186ab11 100755 --- a/src/docs/src/AI/chat.md +++ b/src/docs/src/AI/chat.md @@ -32,9 +32,9 @@ An object containing the following properties: - `signal` (AbortSignal) (Optional) - Abort the request, including while waiting for the next streamed chunk. Cancellation rejects with the signal reason (normally an `AbortError`). - `stream` (Boolean) - A boolean indicating whether you want to stream the completion. Defaults to `false`. - `max_tokens` (Number) - The maximum number of tokens to generate in the completion. By default, the specific model's maximum is used. -- `temperature` (Number) - A number between 0 and 2 indicating the randomness of the completion. Lower values make the output more focused and deterministic, while higher values make it more random. By default, the model's own temperature is used. Some OpenAI reasoning models ignore `temperature` and `top_p`. GPT-5.6 and GPT-6 Sol/Luna accept them only with `reasoning_effort: 'none'`, and GPT-6 Astra, GPT-6.1 Sol, and GPT-5.3 Codex always ignore them. `gemini-4-argon` ignores `temperature` too. `gemini-3.1-flash-lite` and `gemini-3-flash-preview` use it. +- `temperature` (Number) - A number between 0 and 2 indicating the randomness of the completion. Lower values make the output more focused and deterministic, while higher values make it more random. By default, the model's own temperature is used. Some OpenAI reasoning models ignore `temperature` and `top_p`. GPT-5.6, GPT-6 Sol/Luna, and GPT-6.1 Terra accept them only with `reasoning_effort: 'none'`, and GPT-6 Astra, GPT-6.1 Sol, and GPT-5.3 Codex always ignore them. `gemini-4-argon` ignores `temperature` too. `gemini-3.1-flash-lite` and `gemini-3-flash-preview` use it. - `tools` (Array) (Optional) - Function definitions the AI can call. See [Function Calling](#function-calling) for details. -- `reasoning_effort` / `reasoning.effort` (String) (Optional) - Controls how much effort reasoning models spend thinking. Supported values: `none`, `minimal`, `low`, `medium`, `high`, `xhigh`, and `max`. Lower values give faster responses with less reasoning. OpenAI models, Moonshot's Kimi K3.1 Pro models, Meta's Muse Spark models, `gemini-4-argon`, `gemini-3.1-flash-lite` and `gemini-3-flash-preview` only; Muse Spark always reasons, so `none` is ignored for it. Kimi K3.1 Pro accepts `low`, `high` and `max`, uses `max` when you leave it unset, and fails with `400 bad_request` on any other value. `gemini-4-argon` accepts `low`, `medium` and `high`, and uses `medium` when you leave it unset. `gemini-3.1-flash-lite` and `gemini-3-flash-preview` accept `minimal`, `low`, `medium` and `high`, and use Google's default level when you leave it unset. For these three Gemini models, any other value fails with `400 bad_request`, and messages can contain text and images only. +- `reasoning_effort` / `reasoning.effort` (String) (Optional) - Controls how much effort reasoning models spend thinking. Supported values: `none`, `minimal`, `low`, `medium`, `high`, `xhigh`, and `max`. GPT-6.1 Terra accepts `none`, `low`, `medium` (default), `high`, `xhigh`, and `max`. Lower values give faster responses with less reasoning. OpenAI models, Moonshot's Kimi K3.1 Pro models, Meta's Muse Spark models, `gemini-4-argon`, `gemini-3.1-flash-lite` and `gemini-3-flash-preview` only; Muse Spark always reasons, so `none` is ignored for it. Kimi K3.1 Pro accepts `low`, `high` and `max`, uses `max` when you leave it unset, and fails with `400 bad_request` on any other value. `gemini-4-argon` accepts `low`, `medium` and `high`, and uses `medium` when you leave it unset. `gemini-3.1-flash-lite` and `gemini-3-flash-preview` accept `minimal`, `low`, `medium` and `high`, and use Google's default level when you leave it unset. For these three Gemini models, any other value fails with `400 bad_request`, and messages can contain text and images only. - `verbosity` / `text.verbosity` (String) (Optional) - Controls how long or short responses are. Supported values: `low`, `medium`, and `high`. Lower values give shorter responses. OpenAI models only. - `normalize` (Boolean) (Optional) - Controls the format of the non-streaming response. When `true`, the response is normalized to the OpenAI format regardless of the model's vendor: `message.content` is a string, tool calls appear as `message.tool_calls`, and `finish_reason` is one of `stop`, `length`, `tool_calls`, or `content_filter` — or the vendor's own stop reason, passed through unchanged when it has no OpenAI equivalent. When `false`, the response keeps the vendor's native format (for Anthropic models, an array of content blocks). When unset, the SDK-wide `puter.ai.normalize` applies — itself tri-state: set it to `true` to normalize every call regardless of release date, `false` to disable normalization for every call, and when it is left unset too (the default) the release-date policy applies: **models released on or after September 1, 2026 return normalized (OpenAI-format) responses by default**, and for older models the default is unchanged — `message.content` keeps its vendor-native shape. (A handful of reasoning fields were made consistent across all models independently of this option; see [Reasoning fields on existing models](#reasoning-fields-on-existing-models).) Streaming responses are unaffected — chunks already share one format across vendors. See [Response normalization](#response-normalization). - `compaction` (Boolean | Object) (Optional) - Opt into inline context compaction for long conversations. Pass `true` to enable it with provider defaults, or `{ trigger_tokens: number }` to set the token threshold at which earlier context is summarized. When the model compacts, you receive a `compaction` chunk while streaming (or a `compaction` field on the result when not streaming) containing an opaque `encrypted_content` summary. Resend that item in `messages` on the next turn in place of the summarized history. The compaction chunk shape is identical across providers, so the same code works whether `model` is an OpenAI or Anthropic model. See [Compaction](#compaction). diff --git a/src/docs/src/Apps/update.md b/src/docs/src/Apps/update.md index 9c574c47a..c8abf84f1 100755 --- a/src/docs/src/Apps/update.md +++ b/src/docs/src/Apps/update.md @@ -28,6 +28,8 @@ An object containing the attributes to update. The object can contain the follow - `filetypeAssociations` (optional): An array of strings representing the filetypes that the app can open. Defaults to `[]`. File extentions and MIME types are supported; For example, `[".txt", ".md", "application/pdf"]` would allow the app to open `.txt`, `.md`, and PDF files. At most 200 entries, each at most 60 characters. - `metadata` (optional): An object containing custom metadata for the app. This can be used to store arbitrary key-value pairs associated with the app. At most 16 KiB when serialized as JSON. +Changing `indexURL` can merge the app into an existing app for that hosted origin. The returned app can have a different UID. The deleted source app's KV data and KV shares are removed. Data already belonging to the surviving app is retained. + ## Return value A `Promise` that will resolve to the [`App`](/Objects/app/) object that was updated. diff --git a/src/docs/src/Email/sendTransactional.md b/src/docs/src/Email/sendTransactional.md index 62021ef05..5bf51aba9 100644 --- a/src/docs/src/Email/sendTransactional.md +++ b/src/docs/src/Email/sendTransactional.md @@ -37,7 +37,7 @@ A plain-text body. Shorthand for `text`; use the options form for anything else. - `html` (String) - HTML body. An inline image is shown with `` — see `cid` under `attachments`. - `cc` (String | Array) - Carbon-copy recipient(s). - `bcc` (String | Array) - Blind-copy recipient(s). At most **10** recipients across `to`, `cc` and `bcc`. -- `replyTo` (String) - Where replies go. Defaults to the confirmed account email of the worker's owner when the worker has no app binding or its owner owns the bound app. Otherwise, or while the address is unconfirmed, the message goes out without a Reply-To. +- `replyTo` (String) - Where replies go. Defaults to the confirmed account email of the worker's owner when the worker has no app binding, or its owner owns the bound app and that app wasn't created by another app. Otherwise, or while the address is unconfirmed, the message goes out without a Reply-To. - `emailAccessToken` (String) - A worker's auth token — inside a worker, `me.puter.authToken`. Required when the caller is not itself a worker. The caller stays the billed and rate-limited identity. - `attachments` (Array) - Up to **10** attachments, **25 MB** in total. Each is an object: - `filename` (String) - Required with `content`; defaults to the file's name for a `path`/`uid` attachment. diff --git a/src/docs/src/FS/listShared.md b/src/docs/src/FS/listShared.md index 90efa9c8e..ae58c2e4d 100644 --- a/src/docs/src/FS/listShared.md +++ b/src/docs/src/FS/listShared.md @@ -37,7 +37,7 @@ An object with the following properties: A `Promise` that resolves to an object with: -- `items` (Array) - The shares on this page. Each has `uid`, `mode`, `path`, `entryUid`, `isDir`, `name`, `type`, `thumbnail`, `owner`, `issuer`, `holder`, `modified` and `size`. A share row has no directory listing behind it, so `name`, `type` and `thumbnail` are carried on the row itself for rendering. +- `items` (Array) - The shares on this page. Each has `uid`, `mode`, `path`, `entryUid`, `isDir`, `name`, `type`, `thumbnail`, `owner`, `issuer`, `holder`, `modified` and `size`. A share row has no directory listing behind it, so `name`, `type` and `thumbnail` are carried on the row itself for rendering. An item shared with a team you belong to also has `holderTeam`, `{ uid, name, handle }`, and a `holder` of `null`; you can't leave it with [`unshare()`](/FS/unshare/), since the access is the team's. Items you shared with your own team, and team shares of items you own, are not listed. An item shared with you both directly and through a team appears once for each. - `cursor` (String) - Pass to the next call to get the following page. **Present only while more pages remain.** - `total` (Number) - Present only when `includeTotal` was set, and only when you are acting as the account: an app reads the page it was handed, not the size of everything else shared with you. An approximation even then: it counts the shares recorded for you, before the filtering described below, so it can be higher than the number of items paging actually yields. Treat it as a headline figure, not a count to reconcile against. diff --git a/src/docs/src/FS/readdir.md b/src/docs/src/FS/readdir.md index 7335906cf..b0b8a83de 100755 --- a/src/docs/src/FS/readdir.md +++ b/src/docs/src/FS/readdir.md @@ -28,7 +28,7 @@ An object with the following properties: - `path` (String) - The path to the directory to read. Required when passing options as the only argument. - `uid` (String) (optional) - The UID of the directory to read. - `limit` (Number) (optional) - Maximum number of entries to return. -- `offset` (Number) (optional) - Skips the given number of entries. Prefer `cursor` for paging through large directories. +- `offset` (Number) (optional) - Skips the given number of entries. Prefer `cursor` for paging through large directories. On a `recursive` listing, `offset` is ignored when a `cursor` is passed; start with `offset`, then follow the returned `cursor`. - `sortBy` (String) (optional) - Sort field: `name`, `modified`, `type`, or `size`. Default is `name`. With `recursive`, sorting by `name` orders by full path, so each directory's contents stay together; the other fields sort across the whole subtree. - `sortOrder` (String) (optional) - `asc` or `desc`. Default is `asc`. - `recursive` (Boolean) (optional) - If `true`, the contents of subdirectories are listed too. Defaults to `false`. diff --git a/src/docs/src/FS/unshare.md b/src/docs/src/FS/unshare.md index e15798da3..952ef936e 100644 --- a/src/docs/src/FS/unshare.md +++ b/src/docs/src/FS/unshare.md @@ -47,12 +47,20 @@ An object with the following properties: A `Promise` that resolves to `{ revoked }`, where `revoked` is how many grants were actually removed. It is `0` when there was nothing to withdraw, which is not an error. +## Errors + +A rejection carries `{ message, code }`. + +| `code` | Meaning | +| --- | --- | +| `team_share` | You tried to leave an item you hold only through a team. That access is the team's grant, not yours to drop; ask whoever shared it with the team, or leave the team. Returned with status `409`. If you also hold a share of your own, leaving drops that one and succeeds. | + ## Who can withdraw what - The item's **owner** can withdraw any share of it, whoever granted it. - Anyone else can withdraw the shares **they** granted. - An **app** acting for you withdraws only the shares that app issued, whatever authority you have over the rest: your own shares, another app's and a delegate's all report `revoked: 0` to it. Leaving a share yourself still works through an app, since that is your own access to drop. -- **Anyone** can withdraw their own access, whoever granted it. +- **Anyone** can withdraw their own access, whoever granted it — except access held only through a team, which is the team's (see `team_share` above). An item's owner cannot be removed from their own item. diff --git a/src/docs/src/Perms/request.md b/src/docs/src/Perms/request.md index 6e5e717a0..d63eb955f 100644 --- a/src/docs/src/Perms/request.md +++ b/src/docs/src/Perms/request.md @@ -48,7 +48,7 @@ The fields the resource takes: - **`path`** (string): for `'fs'`, the file or folder. Pass an absolute path, or `~` or a path that starts with `~/` for the user's home folder. Access to a folder covers everything inside it. - **`uid`** (string): for `'fs'`, the file or folder's uid, in place of `path`. Pass one or the other. -- **`access`** (string): `'read'` (the default) or `'write'`, also available as `puter.perms.Access.READ` and `puter.perms.Access.WRITE`. Write access includes read access. For `'apps'`, write access lets the app see who uses the user's apps, including email addresses those users shared with them. For `'subdomains'`, write access covers creating, changing and deleting them. For `'kvShare'`, `access` is the highest level of share the app may create, and is `'watch'` (the default), `'read'`, or `'write'`. A `'watch'` share sends change events for the region, a `'read'` share also lets the holder read it, and a `'write'` share also lets them change and delete keys in it. Each level includes the ones below it, so an app allowed `'write'` can also create `'read'` and `'watch'` shares. See [`puter.kv.share()`](/KV/share/). +- **`access`** (string): `'read'` (the default) or `'write'`, also available as `puter.perms.Access.READ` and `puter.perms.Access.WRITE`. Write access includes read access. For `'apps'`, write access lets the app see who uses the user's apps, including email addresses those users shared with them. For `'subdomains'`, read access lets the app list and read all of the user's websites, not only the ones it created, and write access also lets it change and delete them. For `'kvShare'`, `access` is the highest level of share the app may create, and is `'watch'` (the default), `'read'`, or `'write'`. A `'watch'` share sends change events for the region, a `'read'` share also lets the holder read it, and a `'write'` share also lets them change and delete keys in it. Each level includes the ones below it, so an app allowed `'write'` can also create `'read'` and `'watch'` shares. See [`puter.kv.share()`](/KV/share/). - **`create`** (boolean | `'dir'` | `'file'`): for `'fs'`, whether to create a missing `path` once the user allows it, and as what. Defaults to `true`. See [Creating a path on request](#creating-a-path-on-request) below. - **`name`** (string): for `'folder'`, one of `'Desktop'`, `'Documents'`, `'Pictures'`, or `'Videos'`. These are also constants on `puter.perms.Folder`. - **`app`** (string | object): for `'appData'`, the target app, by uid or by registered name. For `'appRootDir'`, the app's uid, or an object with one. diff --git a/src/docs/src/UI/launchApp.md b/src/docs/src/UI/launchApp.md index dd4a6c5e8..c16bec33a 100755 --- a/src/docs/src/UI/launchApp.md +++ b/src/docs/src/UI/launchApp.md @@ -60,6 +60,8 @@ running on its own. ## Return value A `Promise` that will resolve to an [`AppConnection`](/Objects/AppConnection) once the app is launched. +If the desktop cannot complete the launch, the promise rejects with `code: 'ipc_handler_failed'` and a `message`. + ## Examples ```html diff --git a/src/docs/src/rate-limits-and-quotas.md b/src/docs/src/rate-limits-and-quotas.md index f73221e38..c2bfcbb93 100644 --- a/src/docs/src/rate-limits-and-quotas.md +++ b/src/docs/src/rate-limits-and-quotas.md @@ -77,6 +77,8 @@ An input over its limit is rejected before it reaches the model, as is an image The standard chat timeout is 10 minutes for a response to start, or to finish if it isn't streamed. Stream durations depend on the provider. Native Gemini applies the 10-minute timeout to the whole response, including streaming. Groq uses 60 seconds to start a response. Mistral streams have no timeout of their own. If the caller disconnects, a stream that's still running gets 10 more minutes to finish, and is then stopped. See the [AI model directory](https://developer.puter.com/ai/models/) for model specifications. +GPT-6.1 Terra has a 1,050,000-token context window and a maximum output of 128,000 tokens. Requests with more than 272,000 input tokens are charged at twice the input and cache rates and 1.5 times the output rate for the whole request. + A `gemini-4-argon` response that isn't streamed is capped at 32,768 output tokens, and a larger `max_tokens` is lowered to that cap. Streaming uses the model-directory limit. Text-to-speech calls to ElevenLabs, xAI, Speechify, Kling and Gemini time out after 2 minutes; xAI speech-to-text and voice-changer calls after 10 minutes, upload included. A call that times out before any audio or transcript comes back fails with `504 upstream_timeout`. For Gemini, the 2 minutes cover the whole reply, audio included. @@ -262,6 +264,7 @@ Per minute unless stated: | Signed-URL reads | 3,000/min | Per network | | Signed-URL writes | 600/min | Per network | | Signed-URL concurrent requests | 60 | Per network | +| Thumbnail read URLs | Valid for at least 6 days and 23 hours, refreshed hourly | Per URL | | `getReadURL()` (shared with all access-token creation) | 20/hour | | | `revokeReadURL()` | 60/min | | diff --git a/src/gui/src/IPC.errors.test.js b/src/gui/src/IPC.errors.test.js new file mode 100644 index 000000000..bd8bfe738 --- /dev/null +++ b/src/gui/src/IPC.errors.test.js @@ -0,0 +1,141 @@ +// @vitest-environment jsdom +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import jQuery from './lib/jquery-3.6.1/jquery-3.6.1.min.js'; + +globalThis.def = globalThis.def ?? ((cls) => cls); +globalThis.use = globalThis.use ?? (() => ({})); +globalThis.$ = globalThis.jQuery = jQuery; + +const { stub } = vi.hoisted(() => ({ stub: () => ({ default: () => {} }) })); +vi.mock('./helpers/download.js', stub); +vi.mock('./helpers/itemIcon.js', stub); +vi.mock('./helpers/socialLink.js', stub); +vi.mock('./helpers/updateMousePosition.js', stub); +vi.mock('./UI/UIAlert.js', stub); +vi.mock('./UI/UIContextMenu.js', stub); +vi.mock('./UI/UIItem.js', stub); +vi.mock('./UI/UIPopover.js', stub); +vi.mock('./UI/UIPrompt.js', stub); +vi.mock('./UI/UIWindow.js', stub); +vi.mock('./UI/UIWindowAppFeedback.js', stub); +vi.mock('./UI/UIWindowColorPicker.js', stub); +vi.mock('./UI/UIWindowEmailConfirmationRequired.js', stub); +vi.mock('./UI/UIWindowFontPicker.js', stub); +vi.mock('./UI/UIPermissionDialog.js', stub); +vi.mock('./UI/UIWindowSaveAccount.js', stub); +vi.mock('./UI/UIWindowSignup.js', stub); +vi.mock('./UI/UINotification.js', stub); +vi.mock('./helpers/godmodeTokens.js', () => ({ renewGodmodeToken: vi.fn() })); +vi.mock('./helpers/watchItems.js', () => ({ addItemWatcher: vi.fn() })); +vi.mock('./helpers/verification_gates.js', () => ({ + openVerificationGateWindow: vi.fn(), +})); + +let iframe; +let send; + +beforeAll(async () => { + window.when_puter_happens = []; + window.appCallbackFunctions = {}; + await import('./IPC.js'); + globalThis.puter = { + services: undefined, + util: { rpc: { send: (...a) => send(...a) } }, + }; + for (const fn of window.when_puter_happens) await fn(); +}); + +beforeEach(() => { + send = vi.fn(); + document.body.innerHTML = ''; + iframe = document.createElement('iframe'); + document.body.append(iframe); + window.app_instance_ids = new Set(['inst-1']); + window.iframe_for_app_instance = (id) => (id === 'inst-1' ? iframe : null); + window.window_for_app_instance = () => document.body; + globalThis.services = { + get: () => ({ + get_by_uuid: () => ({ name: 'caller', uuid: 'inst-1' }), + }), + }; +}); + +const post = (msg) => + window.dispatchEvent( + new MessageEvent('message', { + data: { + $: 'puter-ipc', + v: 2, + appInstanceID: 'inst-1', + env: 'app', + msg, + parameters: {}, + uuid: 'cb-1', + }, + source: iframe.contentWindow, + origin: 'https://app.example', + }), + ); + +const settle = () => new Promise((r) => setTimeout(r, 20)); + +describe('registered IPC handlers', () => { + it('answers the app when the handler returns', async () => { + window.ipc_handlers.okHandler = { handler: async () => ({ ok: true }) }; + post('okHandler'); + await settle(); + expect(send).toHaveBeenCalledTimes(1); + }); + + it('replies with a stable error when a handler throws', async () => { + const unhandled = []; + const onUnhandled = (e) => unhandled.push(e); + process.on('unhandledRejection', onUnhandled); + const logged = vi.spyOn(console, 'error').mockImplementation(() => {}); + const failure = new Error('Launching this app is not allowed.'); + window.ipc_handlers.throwingHandler = { + handler: async () => { + throw failure; + }, + }; + post('throwingHandler'); + await settle(); + process.off('unhandledRejection', onUnhandled); + expect(send).toHaveBeenCalledExactlyOnceWith( + iframe.contentWindow, + 'cb-1', + { + $: 'puter-ipc-error', + error: { + code: 'ipc_handler_failed', + message: 'The request could not be completed.', + }, + }, + ); + expect(logged).toHaveBeenCalledWith( + expect.stringContaining('throwingHandler'), + failure, + ); + expect(unhandled).toEqual([]); + logged.mockRestore(); + }); + + it('does not send an error reply when sending the result fails', async () => { + const unhandled = []; + const onUnhandled = (e) => unhandled.push(e); + process.on('unhandledRejection', onUnhandled); + send = vi.fn(() => { + throw new Error('frame gone'); + }); + window.ipc_handlers.okHandler = { handler: async () => ({ ok: true }) }; + post('okHandler'); + await settle(); + process.off('unhandledRejection', onUnhandled); + expect(send).toHaveBeenCalledExactlyOnceWith( + iframe.contentWindow, + 'cb-1', + { ok: true }, + ); + expect(unhandled.map((e) => e.message)).toEqual(['frame gone']); + }); +}); diff --git a/src/gui/src/IPC.js b/src/gui/src/IPC.js index dc88a4f28..e944e3875 100644 --- a/src/gui/src/IPC.js +++ b/src/gui/src/IPC.js @@ -160,8 +160,17 @@ const ipc_listener = async (event, handled) => { // Registered IPC handlers are an object with a `handle()` // method. We call it "spec" here, meaning specification. const spec = window.ipc_handlers[event.data.msg]; - let retval = await spec.handler(event.data.parameters, { msg_id, ipc_context }); - + let retval; + try { + retval = await spec.handler(event.data.parameters, { msg_id, ipc_context }); + } catch ( e ) { + console.error(`IPC handler ${event.data.msg} failed`, e); + // Generic on purpose: the error text stays out of the app. + retval = { + $: 'puter-ipc-error', + error: { code: 'ipc_handler_failed', message: 'The request could not be completed.' }, + }; + } puter.util.rpc.send(iframe.contentWindow, msg_id, retval); return; diff --git a/src/gui/src/UI/Dashboard/TabApps.js b/src/gui/src/UI/Dashboard/TabApps.js index 141c74d30..804b3ac0c 100644 --- a/src/gui/src/UI/Dashboard/TabApps.js +++ b/src/gui/src/UI/Dashboard/TabApps.js @@ -923,6 +923,8 @@ const TabApps = { _justDragged: false, _reduceMotionMQL: undefined, _loadPromise: null, + // Set by init: the open's first load joins Home's /get-launch-apps. + _joinHomeLaunchApps: false, _pendingLoad: null, _savedOrderNames: null, _orderSavedAtSeq: 0, @@ -982,6 +984,7 @@ const TabApps = { // contrast, is data and survives re-init). this._arriving = null; + this._joinHomeLaunchApps = true; this.loadApps($el_window); const self = this; @@ -3284,6 +3287,34 @@ const TabApps = { return p; }, + // Only `recommended` is used here. At dashboard open this rides Home's + // in-flight request; later refetches ask the server to skip `recent`. + async _fetchLaunchApps () { + const homeRequest = this._joinHomeLaunchApps ? window.launch_apps_request : null; + this._joinHomeLaunchApps = false; + if ( homeRequest ) { + try { + const launchData = await homeRequest; + if ( launchData ) return launchData; + } catch { + // Home reports its own failure; fetch ours below. + } + } + try { + const res = await fetch( + `${window.api_origin}/get-launch-apps?icon_size=128&recent=0`, + { + headers: { 'Authorization': `Bearer ${window.auth_token}` }, + method: 'GET', + }, + ); + return res.ok ? await res.json() : null; + } catch ( err ) { + console.error('Failed to fetch launch apps:', err); + return null; + } + }, + async _fetchAndRenderApps ($el_window) { // Give each load a monotonically increasing id. An older/slower // response must not clobber a newer one that already applied — or a @@ -3344,16 +3375,7 @@ const TabApps = { // The recommended list only adds tiles: if it fails (network, // a proxy's HTML error page), the installed apps still show, // with the last list that did load. null marks the failure. - fetch( - `${window.api_origin}/get-launch-apps?icon_size=128`, - { - headers: { 'Authorization': `Bearer ${window.auth_token}` }, - method: 'GET', - }, - ).then(res => res.ok ? res.json() : null).catch(err => { - console.error('Failed to fetch launch apps:', err); - return null; - }), + this._fetchLaunchApps(), puter.kv.get(APPS_ORDER_KV_KEY).catch(() => KV_READ_FAILED), puter.kv.get(REMOVED_APPS_KV_KEY).catch(() => KV_READ_FAILED), puter.kv.get(APP_GROUPS_KV_KEY).catch(() => KV_READ_FAILED), diff --git a/src/gui/src/UI/Dashboard/TabApps.test.js b/src/gui/src/UI/Dashboard/TabApps.test.js index fa6c41e0c..81f83fa12 100644 --- a/src/gui/src/UI/Dashboard/TabApps.test.js +++ b/src/gui/src/UI/Dashboard/TabApps.test.js @@ -95,3 +95,50 @@ describe('TabApps cancelling a drag out of a folder', () => { vi.restoreAllMocks(); }); }); + +describe('TabApps launch apps', () => { + const okJson = (body) => ({ ok: true, json: async () => body }); + + afterEach(() => { + window.launch_apps_request = null; + TabApps._joinHomeLaunchApps = false; + vi.unstubAllGlobals(); + }); + + it("joins Home's in-flight request at open, then refetches without recents", async () => { + window.api_origin = 'https://api.test'; + const homeData = { + recommended: [{ name: 'editor' }], + recent: [{ name: 'old' }], + }; + window.launch_apps_request = Promise.resolve(homeData); + const fetchMock = vi.fn(async () => + okJson({ recommended: [{ name: 'editor' }], recent: [] }), + ); + vi.stubGlobal('fetch', fetchMock); + + TabApps._joinHomeLaunchApps = true; + expect(await TabApps._fetchLaunchApps()).toBe(homeData); + expect(fetchMock).not.toHaveBeenCalled(); + + await TabApps._fetchLaunchApps(); + expect(fetchMock).toHaveBeenCalledTimes(1); + const url = new URL(fetchMock.mock.calls[0][0]); + expect(url.pathname).toBe('/get-launch-apps'); + expect(url.searchParams.get('recent')).toBe('0'); + }); + + it("fetches its own list when Home's request fails", async () => { + window.api_origin = 'https://api.test'; + window.launch_apps_request = Promise.reject(new Error('offline')); + const own = { recommended: [{ name: 'editor' }], recent: [] }; + const fetchMock = vi.fn(async () => okJson(own)); + vi.stubGlobal('fetch', fetchMock); + + TabApps._joinHomeLaunchApps = true; + expect(await TabApps._fetchLaunchApps()).toEqual(own); + expect( + new URL(fetchMock.mock.calls[0][0]).searchParams.get('recent'), + ).toBe('0'); + }); +}); diff --git a/src/gui/src/UI/Dashboard/TabFiles.js b/src/gui/src/UI/Dashboard/TabFiles.js index 124aac028..285f4108e 100644 --- a/src/gui/src/UI/Dashboard/TabFiles.js +++ b/src/gui/src/UI/Dashboard/TabFiles.js @@ -234,6 +234,8 @@ const TabFiles = { // drift — e.g. trashed items must show metadata.original_name, not // the UID that is their raw name (matching renderItem). window.UIDashboardFileItemUpdate = function ($row, file) { + const oldName = $row.attr('data-name'); + const oldPath = $row.attr('data-path'); // Minimal update payloads may omit fields — only write what the // event actually carries, so it can't blank a correct name/path // (or zero a size) that is already on screen. A metadata-only @@ -289,6 +291,7 @@ const TabFiles = { ) { $row.find('.item-icon img').attr('src', file.thumbnail); } + if ( displayName && displayName !== oldName && fsentry ) _this.followRename($row, fsentry, oldPath); // The footer's item-count/total-size line is computed from the // rows' data-size attributes — keep it in step with the cell. _this.updateFooterStats(); @@ -856,11 +859,11 @@ const TabFiles = { // Only handle if Dashboard Files tab is active if ( ! _this.isDashboardFilesActive() ) return; - // A Files-tab modal (share, item properties) owns the keyboard - // while open: Enter/Space must reach its buttons, arrows its - // selects, and typing must not retarget row selection — nor may - // Enter/Delete open or trash the rows behind the overlay. - if ( $('.share-modal-overlay, .item-props-overlay, .dashboard-notifications-panel.open, .context-menu-modal-backdrop').length > 0 ) return; + // A Files-tab modal (share, item properties, publish) owns the + // keyboard while open: Enter/Space must reach its buttons, arrows + // its selects, and typing must not retarget row selection — nor + // may Enter/Delete open or trash the rows behind the overlay. + if ( $('.share-modal-overlay, .item-props-overlay, .dashboard-dialog-overlay, .dashboard-notifications-panel.open, .context-menu-modal-backdrop').length > 0 ) return; const focused_el = document.activeElement; @@ -1227,8 +1230,9 @@ const TabFiles = { return false; } - // Space - Toggle image preview - if ( e.which === 32 ) { + // Space - Toggle image preview. Typed into a name being + // type-selected ("my notes"), it is part of the name. + if ( e.which === 32 && _this.typeSearchTerm === '' ) { e.preventDefault(); e.stopPropagation(); @@ -1363,10 +1367,10 @@ const TabFiles = { $('body').append(previewHtml); const $popover = $('.image-preview-popover'); - // Position centered over the files container + // Centered over the files container, and inside it $popover.css({ maxWidth: `${containerWidth - 40}px`, - width: '100%', + maxHeight: `${$filesContainer.height() - 40}px`, left: `${containerOffset.left + (containerWidth / 2)}px`, top: `${containerOffset.top + ($filesContainer.height() / 2)}px`, transform: 'translate(-50%, -50%)', @@ -1778,6 +1782,26 @@ const TabFiles = { return list.filter((_row, i) => answers[i]); }, + /** + * Puts a row renamed elsewhere where its new name sorts, with the icon its + * new type takes, and Back/Forward on a renamed folder's new path, as a + * rename made here does. + * + * @param {jQuery} $row + * @param {Object} fsentry - The row's entry, renamed + * @param {string} oldPath - The row's path before the rename + * @returns {void} + */ + followRename ($row, fsentry, oldPath) { + this.insertAtSortedPosition($row, fsentry); + item_icon(fsentry).then(({ image }) => { + $row.find('.item-icon > img').first().attr('src', image); + }); + if ( fsentry.is_dir && oldPath && fsentry.path && fsentry.path !== oldPath ) { + this.folderMoved(oldPath, fsentry.path); + } + }, + /** * Points Back/Forward history entries in or under `oldPath` at `newPath`, * after the folder was renamed or the home folder moved. @@ -2546,19 +2570,7 @@ const TabFiles = { // in here too, and treating that as "already showing something" would // leave the pane blank once the spinner is taken away. if ( $files.find('.files-empty-notice').length > 0 ) return; - $files.append(`
- ${i18n('folder_empty')} -
`); + $files.append(`
${i18n('folder_empty')}
`); }, /** @@ -3109,19 +3121,7 @@ const TabFiles = { // stale rows during a same-directory refresh) with a message // rather than leaving it unexplained. clearListing(); - $files.html(`
${i18n('folder_open_failed')}
`); + $files.html(`
${i18n('folder_open_failed')}
`); // The list was emptied above; without this the footer keeps the // previous directory's item count over a pane with zero rows. this.updateFooterStats(); @@ -4409,9 +4409,10 @@ const TabFiles = { // then overwrite without asking again. let overwrite_all = false; - for ( const item of window.clipboard ) { - // Handle both object format { path, uid } and legacy string format - const source = item.uid || item.path || item; + for ( const entry of window.clipboard ) { + // A row menu's Cut puts the bare path here; the rest put { path, uid }. + const item = typeof entry === 'string' ? { path: entry } : entry; + const source = item.uid || item.path; let overwrite = overwrite_all; let keep_both = false; let retry; @@ -5375,8 +5376,10 @@ const TabFiles = { items.push({ html: i18n('publish_as_website'), // The right sharing takes, as on the folder's own menu: the - // folder is yours, or shared with you to manage. - disabled: ! is_owned_by_me(targetPath) && shared_root_for(targetPath)?.mode !== 'manage', + // folder is yours, or shared with you to manage. Nor, as + // there, a second site for a folder that has one. + disabled: (! is_owned_by_me(targetPath) && shared_root_for(targetPath)?.mode !== 'manage') + || (window.sites ?? []).some(site => site.root_dir?.path === targetPath), onClick: function () { openPublishWebsiteDialog({ name: targetName, path: targetPath, $container: _this.$el_window }); }, @@ -5761,6 +5764,8 @@ const TabFiles = { abort: async function (_operation_id) { delete window.active_uploads[opid]; }, + }).catch(() => { + // A failure is reported by the error callback above; a cancel needs no word. }); }, @@ -5804,6 +5809,7 @@ const TabFiles = { share_mode: share.mode, shared_by: share.issuer, owner: share.owner, + holder_team: share.holderTeam, })); const linked = this.pendingSharedLinks ?? []; diff --git a/src/gui/src/UI/Dashboard/TabFiles.test.js b/src/gui/src/UI/Dashboard/TabFiles.test.js index 17953e3bc..6eb084c3b 100644 --- a/src/gui/src/UI/Dashboard/TabFiles.test.js +++ b/src/gui/src/UI/Dashboard/TabFiles.test.js @@ -435,6 +435,16 @@ describe('TabFiles.moveClipboardItems', () => { options: expect.objectContaining({ uid: 'a1', path: '/alice/Pictures/a.txt' }), })]); }); + + it("is what Undo takes back after a row menu's Cut too", async () => { + window.clipboard = ['/alice/Documents/a.txt']; + await TabFiles.moveClipboardItems('/alice/Pictures'); + + expect(window.puter.fs.move).toHaveBeenCalledWith(expect.objectContaining({ source: '/alice/Documents/a.txt' })); + const last = window.actions_history.at(-1); + expect(last.operation).toBe('move'); + expect(last.data).toEqual([expect.objectContaining({ original_path: '/alice/Documents/a.txt' })]); + }); }); describe('TabFiles refreshing a folder during a refresh of it', () => { @@ -1099,6 +1109,27 @@ describe('TabFiles uploads', () => { expect($('.files .row.selected')).toHaveLength(2); }); + it('that fail or are cancelled leave no rejection unhandled', async () => { + mountListing(['a.txt'], '/alice/Documents'); + // Not a vi.fn(), which handles the promises it returns. + let uploads = 0; + window.puter.fs.upload = async (items, dest, callbacks) => { + uploads++; + await callbacks.init('op1', { abort: vi.fn() }); + await callbacks.abort('op1'); + throw { code: 'upload_aborted', message: 'Upload aborted.' }; + }; + const unhandled = vi.fn(); + process.on('unhandledRejection', unhandled); + + TabFiles.uploadFiles([new File(['x'], 'new.txt')], '/alice/Documents'); + await new Promise(resolve => setTimeout(resolve, 50)); + process.off('unhandledRejection', unhandled); + + expect(uploads).toBe(1); + expect(unhandled).not.toHaveBeenCalled(); + }); + it('into the folder on screen list it again', async () => { mountListing(['a.txt'], '/alice/Documents'); @@ -1255,3 +1286,143 @@ describe('TabFiles.folderMoved', () => { expect(window.dashboard_nav_history).toEqual(['/alice', '/alice/A']); }); }); + +describe('TabFiles keyboard', () => { + /** Files listing `names`, its shortcuts bound as on the dashboard. */ + const mountKeyboard = (names) => { + mountListing(names, '/alice/Documents'); + $('.files .row').each((i, row) => row.setAttribute('data-name', names[i])); + TabFiles.typeSearchTerm = ''; + Element.prototype.scrollIntoView ??= () => {}; + vi.spyOn(TabFiles, 'isDashboardFilesActive').mockReturnValue(true); + TabFiles.setupKeyboardShortcuts(); + }; + const press = (el, key, init = {}) => el.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, ...init })); + const selected = () => $('.files .row.selected').toArray().map(r => r.getAttribute('data-path')); + + afterEach(() => { + $(document).off('keydown.tabfiles'); + clearTimeout(TabFiles.typeSearchTimeout); + vi.restoreAllMocks(); + }); + + it('leaves the rows alone while a dashboard dialog is open over them', () => { + mountKeyboard(['a.txt', 'b.txt']); + $('.files .row').first().addClass('selected'); + $('.window').append('
'); + const button = $('.dashboard-dialog-overlay .ok')[0]; + button.focus(); + + press(button, 'ArrowDown', { which: 40, keyCode: 40 }); + press(button, 'a', { which: 65, keyCode: 65, metaKey: true }); + + expect(selected()).toEqual(['/alice/Documents/a.txt']); + }); + + it('takes a space typed into a name as part of it', () => { + mountKeyboard(['my notes.txt', 'my zebra.txt']); + for ( const key of ['m', 'y', ' ', 'z'] ) press(document.body, key, { which: key.toUpperCase().charCodeAt(0) }); + expect(selected()).toEqual(['/alice/Documents/my zebra.txt']); + }); +}); + +describe('TabFiles folder menu', () => { + const publishItem = (folderPath) => TabFiles.generateFolderContextMenu(folderPath) + .find(item => item?.html === i18n('publish_as_website')); + + beforeEach(() => { + mountListing([], '/alice/Documents'); + window.user = { username: 'alice' }; + window.trash_path = TRASH; + window.shared_path = '/alice/Shared'; + window.file_templates = []; + window.sites = []; + TabFiles.trashedNames = new Map(); + }); + + it('offers no second website for a folder that has one', () => { + expect(publishItem('/alice/Documents/site').disabled).toBe(false); + window.sites = [{ subdomain: 'mine', root_dir: { path: '/alice/Documents/site' } }]; + expect(publishItem('/alice/Documents/site').disabled).toBe(true); + expect(publishItem('/alice/Documents/other').disabled).toBe(false); + }); +}); + +describe('TabFiles.followRename', () => { + beforeEach(() => { + mountListing(['a.txt', 'b.txt', 'c.txt'], '/alice/Documents'); + $('.files .row').each((_, row) => { + row.setAttribute('data-name', row.getAttribute('data-path').split('/').pop()); + row.setAttribute('data-is_dir', '0'); + }); + TabFiles.sortColumn = 'name'; + TabFiles.sortDirection = 'asc'; + }); + + it('puts a row renamed elsewhere where its new name sorts', () => { + const $row = $('.files .row').first(); + $row.attr({ 'data-name': 'z.txt', 'data-path': '/alice/Documents/z.txt' }); + TabFiles.followRename($row, { name: 'z.txt', path: '/alice/Documents/z.txt', is_dir: false }, '/alice/Documents/a.txt'); + expect($('.files .row').toArray().map(row => row.getAttribute('data-name'))).toEqual(['b.txt', 'c.txt', 'z.txt']); + }); + + it('points Back/Forward at a folder renamed elsewhere', () => { + window.dashboard_nav_history = ['/alice/Documents', '/alice/Documents/old/sub']; + window.dashboard_nav_history_current_position = 0; + const $row = $('.files .row').first(); + TabFiles.followRename($row, { name: 'new', path: '/alice/Documents/new', is_dir: true }, '/alice/Documents/old'); + expect(window.dashboard_nav_history).toEqual(['/alice/Documents', '/alice/Documents/new/sub']); + }); +}); + +describe('TabFiles listSharedView', () => { + beforeEach(() => { + window.user = { username: 'alice', directories: {} }; + TabFiles.pendingSharedLinks = null; + }); + + afterEach(() => vi.restoreAllMocks()); + + it('marks a row shared through a team, and only that one', async () => { + const team = { uid: 't-1', name: 'Acme', handle: 'acme' }; + window.puter = { + fs: { + listShared: vi.fn(async () => ({ + items: [ + { + entryUid: 'u-1', + name: 'a.txt', + path: '/bob/u-1/a.txt', + isDir: false, + mode: 'read', + issuer: 'bob', + owner: 'bob', + }, + { + entryUid: 'u-2', + name: 'b.txt', + path: '/bob/u-2/b.txt', + isDir: false, + mode: 'read', + issuer: 'bob', + owner: 'bob', + holderTeam: team, + }, + ], + })), + }, + }; + + const rows = await TabFiles.listSharedView(); + + expect(rows[0].holder_team).toBeUndefined(); + expect(rows[0]).toMatchObject({ + uid: 'u-1', + shared_with_me: true, + share_mode: 'read', + shared_by: 'bob', + owner: 'bob', + }); + expect(rows[1].holder_team).toEqual(team); + }); +}); diff --git a/src/gui/src/UI/Dashboard/TabHome.js b/src/gui/src/UI/Dashboard/TabHome.js index ce63a37b7..dcc1adde0 100644 --- a/src/gui/src/UI/Dashboard/TabHome.js +++ b/src/gui/src/UI/Dashboard/TabHome.js @@ -450,16 +450,19 @@ const TabHome = { // Only the newest request may write: an older one answering last // would put a stale list back. const seq = (this._recentAppsSeq = (this._recentAppsSeq ?? 0) + 1); + const request = $.ajax({ + url: `${window.api_origin}/get-launch-apps?icon_size=64`, + type: 'GET', + async: true, + contentType: 'application/json', + headers: { + Authorization: `Bearer ${window.auth_token}`, + }, + }); + // The Apps tab joins this at dashboard open instead of sending its own. + window.launch_apps_request = request; try { - const launchApps = await $.ajax({ - url: `${window.api_origin}/get-launch-apps?icon_size=64`, - type: 'GET', - async: true, - contentType: 'application/json', - headers: { - Authorization: `Bearer ${window.auth_token}`, - }, - }); + const launchApps = await request; if (seq !== this._recentAppsSeq) return; window.launch_apps = launchApps; recentAppsState = 'loaded'; @@ -467,6 +470,9 @@ const TabHome = { console.error('Failed to load launch apps:', e); // A list already shown stays; it is only out of date. if (recentAppsState === 'loading') recentAppsState = 'failed'; + } finally { + if (window.launch_apps_request === request) + window.launch_apps_request = null; } if (seq !== this._recentAppsSeq) return; $el_window diff --git a/src/gui/src/UI/Dashboard/TabHome.test.js b/src/gui/src/UI/Dashboard/TabHome.test.js index 8f62a300e..97dbda8e6 100644 --- a/src/gui/src/UI/Dashboard/TabHome.test.js +++ b/src/gui/src/UI/Dashboard/TabHome.test.js @@ -53,3 +53,21 @@ describe('TabHome recently used', () => { expect($el_window.text()).toContain('no_recent_apps'); }); }); + +describe('TabHome launch apps request', () => { + it('shares the in-flight request until it settles', async () => { + document.body.innerHTML = + '
'; + let answer; + const request = new Promise((resolve) => { + answer = resolve; + }); + $.ajax = vi.fn(() => request); + + const loading = TabHome.loadRecentApps($('.window')); + expect(window.launch_apps_request).toBe(request); + answer({ recommended: [], recent: [] }); + await loading; + expect(window.launch_apps_request).toBeNull(); + }); +}); diff --git a/src/gui/src/UI/Dashboard/UIItemPropertiesModal.js b/src/gui/src/UI/Dashboard/UIItemPropertiesModal.js index a135761cd..f40e6c313 100644 --- a/src/gui/src/UI/Dashboard/UIItemPropertiesModal.js +++ b/src/gui/src/UI/Dashboard/UIItemPropertiesModal.js @@ -19,6 +19,7 @@ import item_icon from '../../helpers/itemIcon.js'; import UIAlert from '../UIAlert.js'; +import forget_parent_listing from '../../helpers/forgetParentListing.js'; import { isOverlayOnTop } from './dashboardOverlay.js'; import { formatRelativeTime } from '../../i18n/formatTime.js'; import { owner_of_path } from '../../helpers/pathOwner.js'; @@ -298,10 +299,17 @@ export default function UIItemPropertiesModal ({ name, path: item_path, uid, $co el_modal.focus({ preventScroll: true }); if ( $row.find('.item-props-website-entry').length > 0 ) return; $row.remove(); - // The item serves no website any more: drop its badge from every row. + // The item serves no website any more: drop its badge from + // every row, and from the entry its menu and the next + // listing read, which would offer no Publish and put it back. $('.item').filter(function () { return $(this).attr('data-uid') === item_uid; + }).each(function () { + const row_fsentry = $(this).data('fsentry'); + if ( row_fsentry ) row_fsentry.has_website = false; }).attr('data-has_website', '0').find('.item-has-website-badge').fadeOut(200); + forget_parent_listing(item_path); + window.update_sites_cache?.(); }).catch((err) => { $link.prop('disabled', false); UIAlert(err?.message || i18n('error_unknown_cause', [], false)); diff --git a/src/gui/src/UI/Dashboard/UIItemPropertiesModal.test.js b/src/gui/src/UI/Dashboard/UIItemPropertiesModal.test.js new file mode 100644 index 000000000..1f9b2a86b --- /dev/null +++ b/src/gui/src/UI/Dashboard/UIItemPropertiesModal.test.js @@ -0,0 +1,71 @@ +// @vitest-environment jsdom +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import jQuery from '../../lib/jquery-3.6.1/jquery-3.6.1.min.js'; + +vi.mock('../UIAlert.js', () => ({ default: vi.fn() })); +vi.mock('../../helpers/itemIcon.js', () => ({ default: vi.fn(async () => ({ image: 'icon.svg' })) })); + +globalThis.$ = jQuery; +globalThis.jQuery = jQuery; +globalThis.i18n = (key) => key; +globalThis.html_encode = (value) => String(value ?? '') + .replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); +window.html_encode = globalThis.html_encode; +window.requestAnimationFrame = (fn) => setTimeout(fn, 0); + +const { default: UIItemPropertiesModal } = await import('./UIItemPropertiesModal.js'); + +const tick = () => new Promise(resolve => setTimeout(resolve, 0)); + +afterEach(() => { + document.body.innerHTML = ''; + delete globalThis.puter; +}); + +describe('UIItemPropertiesModal', () => { + it('leaves a folder whose website is disassociated publishable again', async () => { + const fsentry = { uid: 'u1', name: 'site', path: '/alice/site', is_dir: true, has_website: true }; + const $row = $('
') + .data('fsentry', fsentry).appendTo('body'); + window.extractSubdomain = () => 'mysite'; + window.update_sites_cache = vi.fn(); + globalThis.puter = { + fs: { stat: vi.fn(({ success }) => { + success({ ...fsentry, id: 'u1', modified: 1700000000, created: 1700000000, subdomains: [{ uuid: 's1', address: 'https://mysite.puter.site' }] }); + return Promise.resolve(); + }) }, + hosting: { update: vi.fn(async () => ({})) }, + fsCacheKey: (kind, path) => `${kind}:${path}`, + _cache: { del: vi.fn() }, + }; + + UIItemPropertiesModal({ name: 'site', path: '/alice/site', uid: 'u1' }); + await tick(); + $('.item-props-disassociate').trigger('click'); + await tick(); + + expect(puter.hosting.update).toHaveBeenCalledWith('mysite', null); + expect($row.attr('data-has_website')).toBe('0'); + expect(fsentry.has_website).toBe(false); + expect(window.update_sites_cache).toHaveBeenCalled(); + expect(puter._cache.del).toHaveBeenCalledWith('readdir:/alice'); + }); +}); diff --git a/src/gui/src/UI/Dashboard/UIPublishDialogs.js b/src/gui/src/UI/Dashboard/UIPublishDialogs.js index 85200a27d..d80aaf95b 100644 --- a/src/gui/src/UI/Dashboard/UIPublishDialogs.js +++ b/src/gui/src/UI/Dashboard/UIPublishDialogs.js @@ -21,6 +21,7 @@ // worker: dashboard form dialogs rather than desktop windows. import ensure_can_publish from '../../helpers/ensureCanPublish.js'; +import forget_parent_listing from '../../helpers/forgetParentListing.js'; import { SVG } from './UIDashboardDialog.js'; import UIDashboardFormDialog, { copyRowHtml } from './UIDashboardFormDialog.js'; import { finalizePublishName, normalizePublishName, PUBLISH_NAME_RULES } from './publishName.js'; @@ -71,6 +72,8 @@ const KINDS = { .find('.item-has-website-url-badge').show(); } }); + // The folder's row, when it isn't on screen, is drawn from there next. + forget_parent_listing(path); window.update_sites_cache?.(); }, }, @@ -98,6 +101,9 @@ const KINDS = { if ( item_path !== path ) return; $item.attr({ 'data-is_worker': '1', 'data-worker_url': url }) .find('.item-is-worker').show(); + // The listing draws the badge, and the menu its state, from here. + const fsentry = $item.data('fsentry'); + if ( fsentry ) fsentry.workers = [{ address: url }]; }); }, }, diff --git a/src/gui/src/UI/Dashboard/UIPublishDialogs.test.js b/src/gui/src/UI/Dashboard/UIPublishDialogs.test.js index ee7831c2a..26db0dca8 100644 --- a/src/gui/src/UI/Dashboard/UIPublishDialogs.test.js +++ b/src/gui/src/UI/Dashboard/UIPublishDialogs.test.js @@ -60,6 +60,8 @@ beforeEach(() => { window.update_sites_cache = vi.fn(); window.require_email_verification_to_publish_website = false; globalThis.puter = { + fsCacheKey: (kind, path) => `${kind}:${path}`, + _cache: { del: vi.fn() }, hosting: { create: vi.fn(async () => ({})) }, workers: { create: vi.fn(async (name) => ({ success: true, url: `https://${name}.puter.work` })) }, }; @@ -94,6 +96,8 @@ describe('publish website', () => { expect($folder.find('.item-has-website-badge').css('display')).not.toBe('none'); expect($child.attr('data-website_url')).toBe('https://my-site.puter.site/index.html'); expect(window.update_sites_cache).toHaveBeenCalled(); + // Its row in /alice, when not on screen, is listed afresh next time. + expect(puter._cache.del).toHaveBeenCalledWith('readdir:/alice'); }); it("links to the server's address for the site when it sends one", async () => { @@ -168,6 +172,8 @@ describe('publish worker', () => { it('deploys sandboxed by default and badges the file', async () => { const $file = addRow('/alice/api.js'); + const fsentry = { path: '/alice/api.js', workers: [] }; + $file.data('fsentry', fsentry); await openPublishWorkerDialog({ name: 'api.js', path: '/alice/api.js' }); expect($('.dashboard-form-suffix').text()).toBe('.puter.work'); @@ -177,6 +183,8 @@ describe('publish worker', () => { expect($('.dashboard-copy-value').val()).toBe('https://my_api.puter.work'); expect($file.attr('data-is_worker')).toBe('1'); expect($file.attr('data-worker_url')).toBe('https://my_api.puter.work'); + // What the next listing of the folder draws the row from. + expect(fsentry.workers).toEqual([{ address: 'https://my_api.puter.work' }]); }); it('deploys unsandboxed when the switch is off', async () => { diff --git a/src/gui/src/UI/Dashboard/UIShareModal.js b/src/gui/src/UI/Dashboard/UIShareModal.js index c1658c1df..2a111e65b 100644 --- a/src/gui/src/UI/Dashboard/UIShareModal.js +++ b/src/gui/src/UI/Dashboard/UIShareModal.js @@ -200,6 +200,7 @@ export default function UIShareModal ({ items, path: item_path, name, owner, fse + @@ -230,8 +231,11 @@ export default function UIShareModal ({ items, path: item_path, name, owner, fse const $link_note = $overlay.find('.share-modal-general-note'); const $copy_link = $overlay.find('.share-modal-copy-link'); - /** Where the selection stands on "anyone with the link", as last listed. */ - let link_state = { access: 'restricted', mode: null }; + /** + * Where the selection stands on "anyone with the link", as last listed. + * A selection too large to list (see refresh) is unchecked until set here. + */ + let link_state = { access: total > MAX_ITEMS_PER_REQUEST ? 'unchecked' : 'restricted', mode: null }; /** The one item's uid, which its link is built on. Single item only. */ let single_uid = is_multi ? null : (targets[0].fsentry?.uid ?? null); @@ -267,6 +271,10 @@ export default function UIShareModal ({ items, path: item_path, name, owner, fse // The last successfully aggregated access list, so canceling an inline // revoke confirmation can restore the row without another round-trip. let last_groups = []; + // The items whose access that list was read from. An item whose listing + // failed may well be shared already: "add to all" granting it the group's + // mode could take a stronger grant down. + let listed_paths = target_paths; const group_for = (key) => last_groups.find((group) => group.key === key); // Re-rendering the list replaces its nodes wholesale, and disabling a @@ -357,7 +365,7 @@ export default function UIShareModal ({ items, path: item_path, name, owner, fse || (is_self && group.directPaths.length > 0); // Extending someone needs a mode to extend; a person whose grants // disagree levels them with the select first. - const missing = missingPathsFor(target_paths, group).length; + const missing = missingPathsFor(listed_paths, group).length; const can_add_to_all = can_change && missing > 0 && group.mode !== null; // The clauses read as one muted line; the action keeps its own box on @@ -447,6 +455,7 @@ export default function UIShareModal ({ items, path: item_path, name, owner, fse last_groups = []; $list.attr('aria-busy', 'false') .html(``); + render_link_access(); return; } @@ -480,6 +489,7 @@ export default function UIShareModal ({ items, path: item_path, name, owner, fse show_error(failure?.message ? html_encode(failure.message) : i18n('share_load_failed')); return; } + listed_paths = target_paths.filter((target) => by_path.has(target)); render(aggregateShares(target_paths, by_path)); link_state = linkShareState(target_paths, by_path); render_link_access(); @@ -497,7 +507,9 @@ export default function UIShareModal ({ items, path: item_path, name, owner, fse // `null` rests the mode on the "Mixed" placeholder, as the rows do. if ( access === 'anyone' ) $link_mode.html(options_for(mode, { allow_manage: false })); let note; - if ( access === 'mixed' ) { + if ( access === 'unchecked' ) { + note = i18n('share_link_unchecked_note', { total }); + } else if ( access === 'mixed' ) { note = i18n('share_link_mixed_note'); } else if ( access === 'anyone' ) { note = i18n(`share_link_anyone_note_${mode === 'write' ? 'write' : 'read'}${is_multi ? '_items' : ''}`); @@ -523,8 +535,10 @@ export default function UIShareModal ({ items, path: item_path, name, owner, fse $(document).on(`keydown.share-modal-${modal_id}`, function (e) { // A gate or upgrade window opened over the modal takes its own Escape. if ( e.key !== 'Escape' || ! isOverlayOnTop($overlay[0]) ) return; - // An open revoke confirmation swallows the first Escape. + // An open revoke confirmation swallows the first Escape. Once Remove + // is under way it can't be taken back, and the row must not look it. const $confirm = $list.find('.share-modal-row-confirm'); + if ( $confirm.find('button:disabled').length ) return; if ( $confirm.length ) { const key = $confirm.attr('data-key'); render(last_groups); @@ -655,7 +669,7 @@ export default function UIShareModal ({ items, path: item_path, name, owner, fse $overlay.on('change', '.share-modal-link-access', function () { const value = $(this).val(); - if ( value === 'mixed' ) return; + if ( value === 'mixed' || value === 'unchecked' ) return; // Opening up starts at view. The mode control is hidden unless every // item is already open, so its value may be left over from before. set_link_access(value === 'anyone' ? 'read' : null, this); @@ -689,6 +703,9 @@ export default function UIShareModal ({ items, path: item_path, name, owner, fse /** A team is named by uid; a person by the name the row already shows. */ const recipient_of = (group) => (group.teamUid ? { team: group.teamUid } : group.name); + // A share on its way keeps the button down, though the field is edited. + let sharing = false; + // One field for every kind of recipient: a colleague, a whole team, someone // shared with before, or an address typed from scratch. const picker = shareRecipientPicker({ @@ -698,7 +715,7 @@ export default function UIShareModal ({ items, path: item_path, name, owner, fse // re-grant what their row already shows. excluded: () => last_groups.map((group) => group.key), onChange: () => { - $submit.prop('disabled', ! picker.recipient()); + $submit.prop('disabled', sharing || ! picker.recipient()); // Choosing again retires a stale success/error message. clear_status(); }, @@ -726,9 +743,10 @@ export default function UIShareModal ({ items, path: item_path, name, owner, fse // A team is named by uid; anything typed goes as-is, since a bare // string is what the backend reads as an email or a username. const chosen = picker.recipient(); - if ( ! chosen ) return; + if ( ! chosen || sharing ) return; const recipient = chosen.label; + sharing = true; $submit.prop('disabled', true).addClass('share-modal-btn-busy'); try { const created = await grant_access( @@ -736,6 +754,7 @@ export default function UIShareModal ({ items, path: item_path, name, owner, fse $overlay.find('.share-modal-mode').val(), target_paths, ); + sharing = false; // Clear only what we sent; a name typed mid-flight shouldn't vanish. if ( $recipient.val().trim() === recipient ) picker.clear(); $submit.prop('disabled', ! picker.recipient()); @@ -767,8 +786,9 @@ export default function UIShareModal ({ items, path: item_path, name, owner, fse await refresh(); if ( ! focus_moved_on() ) $recipient.get(0)?.focus({ preventScroll: true }); } catch (err) { + sharing = false; show_error(error_html(err)); - $submit.prop('disabled', false); + $submit.prop('disabled', ! picker.recipient()); // Disabling the clicked button dropped focus to ; put it // where the correction happens. $recipient.get(0)?.focus({ preventScroll: true }); @@ -802,7 +822,7 @@ export default function UIShareModal ({ items, path: item_path, name, owner, fse const key = $(this).attr('data-key'); const group = group_for(key); if ( ! group || group.mode === null ) return; - const missing = missingPathsFor(target_paths, group); + const missing = missingPathsFor(listed_paths, group); if ( ! missing.length ) return; $(this).prop('disabled', true); try { diff --git a/src/gui/src/UI/Dashboard/UIShareModal.test.js b/src/gui/src/UI/Dashboard/UIShareModal.test.js new file mode 100644 index 000000000..a8f4c1c81 --- /dev/null +++ b/src/gui/src/UI/Dashboard/UIShareModal.test.js @@ -0,0 +1,161 @@ +// @vitest-environment jsdom +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import jQuery from '../../lib/jquery-3.6.1/jquery-3.6.1.min.js'; + +vi.mock('./ContextMenu/ContextMenu.js', () => ({ isTouchPrimaryDevice: () => false })); +vi.mock('../../helpers/itemIcon.js', () => ({ default: vi.fn(async () => ({ image: 'icon.svg' })) })); +vi.mock('../../helpers/shareTeams.js', () => ({ teams_for_sharing: vi.fn(async () => []) })); +// The field's choice, and the dialog's hook into it, for tests to drive. +const picked = { recipient: null, onChange: null }; +vi.mock('../../helpers/shareRecipientPicker.js', () => ({ + default: ({ onChange }) => { + picked.onChange = onChange; + return { destroy () {}, recipient: () => picked.recipient, setTeams () {}, clear () {}, remember () {} }; + }, +})); + +globalThis.$ = jQuery; +globalThis.jQuery = jQuery; +globalThis.i18n = (key, args) => (args && ! Array.isArray(args) ? `${key}:${JSON.stringify(args)}` : key); +globalThis.html_encode = (value) => String(value ?? '') + .replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); +window.html_encode = globalThis.html_encode; +window.requestAnimationFrame = (fn) => setTimeout(fn, 0); +globalThis.def = globalThis.def ?? (value => value); +globalThis.use = globalThis.use ?? (() => class {}); + +const { default: UIShareModal } = await import('./UIShareModal.js'); + +const settle = async () => { + for ( let i = 0; i < 5; i++ ) await new Promise(resolve => setTimeout(resolve, 0)); +}; + +beforeEach(() => { + picked.recipient = null; + window.user = { username: 'alice' }; + globalThis.puter = { + fs: { + getShares: vi.fn(async () => []), + share: vi.fn(async ({ paths }) => paths.map(p => ({ path: p }))), + unshare: vi.fn(async () => ({})), + stat: vi.fn(async () => ({ uid: 'u' })), + }, + }; +}); + +afterEach(() => { + document.body.innerHTML = ''; +}); + +describe('UIShareModal general access', () => { + const items = (count) => Array.from({ length: count }, (_, i) => ({ path: `/alice/f${i}.txt` })); + + it("doesn't call a selection too large to list restricted, and lets either choice apply", async () => { + UIShareModal({ items: items(51) }); + await settle(); + + expect($('.share-modal-link-access')[0].value).toBe('unchecked'); + expect($('.share-modal-general-note').html()).toBe(i18n('share_link_unchecked_note', { total: 51 })); + expect(puter.fs.getShares).not.toHaveBeenCalled(); + + $('.share-modal-link-access').val('restricted').trigger('change'); + await settle(); + expect(puter.fs.unshare).toHaveBeenCalledTimes(2); + expect($('.share-modal-link-access').val()).toBe('restricted'); + }); + + it('shows a listed selection as the listings say', async () => { + UIShareModal({ items: items(2) }); + await settle(); + expect($('.share-modal-link-access').val()).toBe('restricted'); + expect(puter.fs.getShares).toHaveBeenCalledTimes(2); + }); +}); + +describe('UIShareModal extending a grant', () => { + const bob = (mode) => ({ holder: 'bob', mode, issuer: 'alice' }); + + it('offers it on the items listed, not on one whose listing failed', async () => { + puter.fs.getShares = vi.fn(async (target) => { + if ( target === '/alice/b.txt' ) throw new Error('Network hiccup'); + return [bob('read')]; + }); + UIShareModal({ items: [{ path: '/alice/a.txt' }, { path: '/alice/b.txt' }] }); + await settle(); + expect($('.share-modal-row-mode').length).toBe(1); + expect($('.share-modal-row-link').length).toBe(0); + }); + + it('offers it on the items bob is missing', async () => { + puter.fs.getShares = vi.fn(async (target) => (target === '/alice/a.txt' ? [bob('read')] : [])); + UIShareModal({ items: [{ path: '/alice/a.txt' }, { path: '/alice/b.txt' }] }); + await settle(); + $('.share-modal-row-link').trigger('click'); + await settle(); + expect(puter.fs.share).toHaveBeenCalledWith({ paths: ['/alice/b.txt'], recipient: 'bob', mode: 'read' }); + }); +}); + +describe('UIShareModal removing access', () => { + it("doesn't look cancelled by an Escape while the removal is under way", async () => { + puter.fs.getShares = vi.fn(async () => [{ holder: 'bob', mode: 'read', issuer: 'alice' }]); + let finish; + puter.fs.unshare = vi.fn(() => new Promise((resolve) => { + finish = resolve; + })); + UIShareModal({ path: '/alice/a.txt' }); + await settle(); + + $('.share-modal-revoke').trigger('click'); + $('.share-modal-confirm-remove').trigger('click'); + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + + expect($('.share-modal-row-confirm').length).toBe(1); + expect($('.share-modal-overlay').length).toBe(1); + finish(); + await settle(); + }); +}); + +describe('UIShareModal adding people', () => { + it('keeps Share down while a share is on its way, though the name is edited', async () => { + let finish; + puter.fs.share = vi.fn(() => new Promise((resolve) => { + finish = resolve; + })); + UIShareModal({ path: '/alice/a.txt' }); + await settle(); + picked.recipient = { value: 'bob', label: 'bob' }; + picked.onChange(); + expect($('.share-modal-submit').prop('disabled')).toBe(false); + + $('.share-modal-add').trigger('submit'); + picked.recipient = { value: 'bobby', label: 'bobby' }; + picked.onChange(); + expect($('.share-modal-submit').prop('disabled')).toBe(true); + $('.share-modal-add').trigger('submit'); + expect(puter.fs.share).toHaveBeenCalledTimes(1); + + finish([{ path: '/alice/a.txt' }]); + await settle(); + expect($('.share-modal-submit').prop('disabled')).toBe(false); + }); +}); diff --git a/src/gui/src/UI/Settings/UIWindowDisable2FA.js b/src/gui/src/UI/Settings/UIWindowDisable2FA.js index 6d33a4a7a..d945c79e5 100644 --- a/src/gui/src/UI/Settings/UIWindowDisable2FA.js +++ b/src/gui/src/UI/Settings/UIWindowDisable2FA.js @@ -53,8 +53,8 @@ async function UIWindowDisable2FA (options) { h += ''; h += ''; h += '
'; - h += ``; - h += ``; + h += ``; + h += ``; h += '
'; h += ``; h += ''; @@ -204,7 +204,8 @@ async function UIWindowDisable2FA (options) { credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - code: $(el_window).find('.disable-2fa-code').val() || undefined, + // Normalized like the login window's recovery input. + code: $(el_window).find('.disable-2fa-code').val()?.trim().toUpperCase() || undefined, password: password !== undefined && password !== '' ? password : undefined, }), }); diff --git a/src/gui/src/UI/Settings/UIWindowDisable2FA.test.js b/src/gui/src/UI/Settings/UIWindowDisable2FA.test.js index 3576a2587..48db5193b 100644 --- a/src/gui/src/UI/Settings/UIWindowDisable2FA.test.js +++ b/src/gui/src/UI/Settings/UIWindowDisable2FA.test.js @@ -59,6 +59,14 @@ describe('disabling 2FA', () => { await expect(Promise.resolve(state.result.promise)).resolves.toBe(true); }); + it('trims and uppercases a pasted recovery code', async () => { + await open({ otp: true }); + jQuery(state.el).find('.disable-2fa-code').val(' abcd2345\n'); + fetch.mockResolvedValue(reply(200)); + await submit(); + expect(bodyOf(0)).toEqual({ password: 'password', code: 'ABCD2345' }); + }); + it.each([400, 401])('leaves 2FA enabled and permits retry after a code rejection (%s)', async status => { await open({ otp: true }); jQuery(state.el).find('.disable-2fa-code').val('000123'); diff --git a/src/gui/src/UI/UIItem.js b/src/gui/src/UI/UIItem.js index 440d584d4..df8ddb377 100644 --- a/src/gui/src/UI/UIItem.js +++ b/src/gui/src/UI/UIItem.js @@ -64,6 +64,7 @@ async function UIItem (options) { options.is_shared = options.is_shared === true; options.share_mode = options.share_mode ?? ''; options.shared_by = options.shared_by ?? ''; + options.is_team_share = options.is_team_share ?? false; options.owner = options.owner ?? ''; options.metadata = options.metadata ?? ''; options.multiselectable = (options.multiselectable === undefined || options.multiselectable === true) ? true : false; @@ -101,6 +102,7 @@ async function UIItem (options) { data-is_shared="${options.is_shared ? 1 : 0}" data-share_mode="${html_encode(options.share_mode)}" data-shared_by="${html_encode(options.shared_by)}" + data-is_team_share="${options.is_team_share ? 1 : 0}" data-owner="${html_encode(options.owner)}" data-has_website="${show_website_badge ? 1 : 0 }" data-website_url = "${website_url ? html_encode(website_url) : ''}" @@ -1531,7 +1533,8 @@ async function UIItem (options) { // Someone else owns this, so deleting it would move their file into // our trash — which the backend refuses. Give up our own access // instead, which is what "remove it from my view" actually means. - if ( is_shared_root ) { + // A team share isn't the member's to leave. + if ( is_shared_root && $(el_item).attr('data-is_team_share') !== '1' ) { menu_items.push({ html: i18n('share_remove_from_shared'), onClick: async function () { diff --git a/src/gui/src/UI/UIItem.test.js b/src/gui/src/UI/UIItem.test.js new file mode 100644 index 000000000..70ce77c85 --- /dev/null +++ b/src/gui/src/UI/UIItem.test.js @@ -0,0 +1,112 @@ +// @vitest-environment jsdom +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import jQuery from '../lib/jquery-3.6.1/jquery-3.6.1.min.js'; + +vi.mock('./UIWindowItemProperties.js', () => ({ default: vi.fn() })); +vi.mock('./UIWindowSaveAccount.js', () => ({ default: vi.fn() })); +vi.mock('./UIPopover.js', () => ({ default: vi.fn() })); +vi.mock('./UIWindowEmailConfirmationRequired.js', () => ({ default: vi.fn() })); +vi.mock('./UIContextMenu.js', () => ({ default: vi.fn() })); +vi.mock('./UIAlert.js', () => ({ default: vi.fn() })); +vi.mock('./UIWindowPublishWorker.js', () => ({ default: vi.fn() })); +vi.mock('./UIWindowShare.js', () => ({ default: vi.fn() })); +vi.mock('../helpers/launchApp.js', () => ({ default: vi.fn() })); +vi.mock('../helpers/openItem.js', () => ({ default: vi.fn() })); +vi.mock('../helpers/publishAsWebsite.js', () => ({ default: vi.fn() })); +vi.mock('../helpers/openWithAI.js', () => ({ default: vi.fn() })); + +globalThis.$ = jQuery; +globalThis.jQuery = jQuery; +globalThis.def = globalThis.def ?? ((value) => value); +globalThis.use = globalThis.use ?? (() => class {}); +globalThis.html_encode = (value) => + jQuery('
') + .text(value ?? '') + .html(); +globalThis.i18n = (key) => key; +globalThis.isMobile = { phone: false, tablet: false }; +globalThis.timeago = { format: () => '' }; +for (const plugin of ['draggable', 'droppable', 'dragster']) { + jQuery.fn[plugin] = function () { + return this; + }; +} +jQuery.fn.removeItems = function () { + return this.remove(); +}; +window.icons = {}; +Object.assign(window, { + determine_website_url: () => null, + check_fsentry_against_allowed_file_types_string: () => true, + toggle_empty_folder_message: () => {}, + update_explorer_footer_item_count: () => {}, + byte_format: (bytes) => `${bytes} B`, + suggest_apps_for_fsentry: async () => [], + feature_flags: {}, + clipboard: [], +}); + +const { default: UIItem } = await import('./UIItem.js'); +const { default: UIContextMenu } = await import('./UIContextMenu.js'); + +const menuLabels = async (options) => { + document.body.innerHTML = + '
'; + await UIItem({ + appendTo: $('.item-container'), + uid: 'u-1', + path: '/bob/u-1/Photos', + name: 'Photos', + is_dir: true, + icon: { image: '', type: 'icon' }, + shared_with_me: true, + share_mode: 'read', + shared_by: 'bob', + owner: 'bob', + ...options, + }); + UIContextMenu.mockClear(); + $('.item').trigger('contextmenu'); + await vi.waitFor(() => expect(UIContextMenu).toHaveBeenCalled()); + return UIContextMenu.mock.calls[0][0].items.map((item) => item?.html); +}; + +describe('UIItem Remove from Shared', () => { + beforeEach(() => { + window.user = { username: 'alice' }; + window.trash_path = '/alice/Trash'; + window.puter = { + fs: { listShared: vi.fn(async () => ({ items: [] })) }, + }; + globalThis.puter = window.puter; + }); + + it('is offered on an item shared with the user directly', async () => { + expect(await menuLabels({})).toContain('share_remove_from_shared'); + }); + + it('is not offered on an item shared through a team', async () => { + const labels = await menuLabels({ is_team_share: true }); + expect($('.item').attr('data-is_team_share')).toBe('1'); + expect(labels).not.toContain('share_remove_from_shared'); + }); +}); diff --git a/src/gui/src/UI/UIWindowLogin.js b/src/gui/src/UI/UIWindowLogin.js index 2ca338cc0..179c95acd 100644 --- a/src/gui/src/UI/UIWindowLogin.js +++ b/src/gui/src/UI/UIWindowLogin.js @@ -191,8 +191,11 @@ async function UIWindowLogin (options) { // returns it as a plain sign-in popup, losing whatever the popup // was opened to do — and, for a permission prompt, handing the // opener a token instead of a decision. Don't offer it there. + // A cross-origin-isolated opener can only get the token through + // the session relay, which the provider's return doesn't keep. if ( window.embedded_in_popup - && ! offersFederatedSignInInPopup(window.gui_action) ) { + && ( ! offersFederatedSignInInPopup(window.gui_action) + || window.url_query_params?.get('cross_origin_isolated') === 'true' ) ) { return; } const res = await fetch(`${window.api_origin}/auth/oidc/providers`); diff --git a/src/gui/src/UI/UIWindowLogin.oidcPopup.test.js b/src/gui/src/UI/UIWindowLogin.oidcPopup.test.js new file mode 100644 index 000000000..a7c0d4b71 --- /dev/null +++ b/src/gui/src/UI/UIWindowLogin.oidcPopup.test.js @@ -0,0 +1,118 @@ +// @vitest-environment jsdom +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import jQuery from '../lib/jquery-3.6.1/jquery-3.6.1.min.js'; + +const state = vi.hoisted(() => ({ el: null })); +vi.mock('./UIWindowRecoverPassword.js', () => ({ default: vi.fn() })); +vi.mock('./UIWindowEmailConfirmationRequired.js', () => ({ default: vi.fn() })); +vi.mock('./UIWindowPhoneVerificationRequired.js', () => ({ default: vi.fn() })); +vi.mock('./UIWindowCardVerificationRequired.js', () => ({ default: vi.fn() })); +vi.mock('../helpers/authLogoHeader.js', () => ({ + authLogoHeader: () => '', + wireAuthLogoHeader: vi.fn(), +})); +vi.mock('./UIWindow.js', () => ({ + default: async (options) => { + state.el = document.createElement('div'); + state.el.innerHTML = options.body_content; + document.body.append(state.el); + options.onAppend?.(state.el); + return state.el; + }, +})); +globalThis.def = globalThis.def ?? ((value) => value); +const { default: UIWindowLogin } = await import('./UIWindowLogin.js'); +const { default: UIWindowSignup } = await import('./UIWindowSignup.js'); + +const PROVIDERS_URL = 'https://api.puter.test/auth/oidc/providers'; +const RELAY_POPUP = + 'embedded_in_popup=true&msg_id=1&cross_origin_isolated=true&signin_session=s'; +const PLAIN_POPUP = 'embedded_in_popup=true&msg_id=1'; + +const asPopup = (query) => { + window.embedded_in_popup = true; + window.gui_action = 'sign-in'; + window.url_query_params = new URLSearchParams(query); +}; + +const askedForProviders = () => + fetch.mock.calls.some(([url]) => url === PROVIDERS_URL); + +beforeEach(() => { + document.body.innerHTML = ''; + globalThis.$ = jQuery; + jQuery.fn.close = vi.fn(); + globalThis.i18n = (key) => key; + globalThis.html_encode = (value) => jQuery('
').text(value).html(); + globalThis.fetch = vi.fn( + async () => new Response(JSON.stringify({ providers: ['google'] })), + ); + window.gui_origin = 'https://puter.test'; + window.api_origin = 'https://api.puter.test'; + window.gui_params = { turnstileSiteKey: 'site-key' }; + window.turnstile = { render: vi.fn() }; + window.icons = {}; + window.uuidv4 = () => 'oidc-popup-test'; + window.embedded_in_popup = false; + window.gui_action = undefined; + window.url_query_params = new URLSearchParams(''); + state.el = null; +}); +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe.each([ + [ + 'login', + () => UIWindowLogin({ reload_on_success: false, redirect_url: '/' }), + '.login-btn', + ], + [ + 'signup', + () => UIWindowSignup({ reload_on_success: false, redirect_url: '/' }), + '.signup-btn', + ], +])('OIDC buttons in the %s window', (_name, open, formButton) => { + const rendered = async () => { + void open(); + await vi.waitFor(() => + expect(jQuery(state.el).find(formButton)).toHaveLength(1), + ); + }; + + // The provider's return drops the relay that hands the token to an + // isolated opener, so only password sign-in can finish there. + it('hides them in a cross-origin-isolated relay popup', async () => { + asPopup(RELAY_POPUP); + await rendered(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(askedForProviders()).toBe(false); + expect(jQuery(state.el).find('.oidc-google-btn').css('display')).toBe( + 'none', + ); + }); + + it('keeps them in a regular sign-in popup', async () => { + asPopup(PLAIN_POPUP); + await rendered(); + + await vi.waitFor(() => + expect( + jQuery(state.el).find('.oidc-google-btn').css('display'), + ).toBe('flex'), + ); + }); + + it('keeps them outside a popup', async () => { + await rendered(); + + await vi.waitFor(() => + expect( + jQuery(state.el).find('.oidc-google-btn').css('display'), + ).toBe('flex'), + ); + expect(askedForProviders()).toBe(true); + }); +}); diff --git a/src/gui/src/UI/UIWindowSignup.js b/src/gui/src/UI/UIWindowSignup.js index 671c9cea4..660393b4b 100644 --- a/src/gui/src/UI/UIWindowSignup.js +++ b/src/gui/src/UI/UIWindowSignup.js @@ -285,10 +285,16 @@ function UIWindowSignup(options) { // provider returns it as a plain sign-in popup, losing // whatever the popup was opened to do — and, for a // permission prompt, handing the opener a token instead - // of a decision. Don't offer it there. + // of a decision. Don't offer it there. A + // cross-origin-isolated opener can only get the token + // through the session relay, which the provider's + // return doesn't keep. if ( window.embedded_in_popup && - !offersFederatedSignInInPopup(window.gui_action) + (!offersFederatedSignInInPopup(window.gui_action) || + window.url_query_params?.get( + 'cross_origin_isolated', + ) === 'true') ) { return; } diff --git a/src/gui/src/css/dashboard.css b/src/gui/src/css/dashboard.css index e769d2180..a9572dc26 100644 --- a/src/gui/src/css/dashboard.css +++ b/src/gui/src/css/dashboard.css @@ -2229,9 +2229,12 @@ p.myapps-add-error { opacity: 0; transition: opacity 200ms ease; z-index: 1000; + /* Fading out after a close, it must not take the next click. */ + pointer-events: none; } .item-props-overlay.item-props-show { opacity: 1; + pointer-events: auto; } .item-props-modal { @@ -2556,9 +2559,12 @@ p.myapps-add-error { opacity: 0; transition: opacity 200ms ease; z-index: 1000; + /* Fading out after a close, it must not take the next click. */ + pointer-events: none; } .share-modal-overlay.share-modal-show { opacity: 1; + pointer-events: auto; } .share-modal { @@ -4940,6 +4946,25 @@ body.myapps-reordering .myapps-tile { color: var(--dashboard-text-tertiary); } +/* On a touch screen the '⋯' is a 20x23 target beside a row whose tap opens + the item, so a near miss opened the item instead of the menu. It takes the + row's height and a finger's width. (Two-row rows get a 44px square below.) */ +@media (pointer: coarse) { + .dashboard-section-files .files-tab .files.files-list-view .row .item-more { + position: relative; + } + + .dashboard-section-files .files-tab .files.files-list-view .row .item-more::before { + content: ''; + position: absolute; + left: 50%; + top: 50%; + width: 44px; + height: 32px; + transform: translate(-50%, -50%); + } +} + .dashboard-section-files .files-tab .files.files-list-view .row.selected .item-size, .dashboard-section-files .files-tab .files.files-list-view .row.selected .item-modified { color: inherit; @@ -5155,6 +5180,13 @@ body.myapps-reordering .myapps-tile { filter: none; } +@media (prefers-color-scheme: dark) { + /* A black glyph, lost on the dark row: drawn light instead. */ + .dashboard-section-files .files-tab .files.files-list-view .row img.item-has-website-url-badge { + filter: invert(1); + } +} + /* Badges live top-LEFT in grid view; the top-right corner belongs to the hover '⋯' button (.item-more) and they must not overlap. */ .dashboard-section-files .files-tab .files.files-grid-view .row .item-badges { @@ -5288,6 +5320,9 @@ body.myapps-reordering .myapps-tile { .dashboard-section-files .files-tab .files.files-grid-view .row .item-name-wrapper { width: 100%; display: block; + /* Above the tile-sized badge layer, which would take the hover that + shows a shortened name in full. */ + position: relative; } @media (hover: hover) { @@ -5787,6 +5822,10 @@ body.myapps-reordering .myapps-tile { position: fixed; /* On body, above the dashboard window (see the sidebar tooltip). */ z-index: 999999999; + /* Sized to the image, within the bounds showImagePreview sets */ + box-sizing: border-box; + width: max-content; + min-width: 160px; background: var(--dashboard-background); border: 1px solid var(--dashboard-border); border-radius: 8px; @@ -5801,11 +5840,14 @@ body.myapps-reordering .myapps-tile { .image-preview-popover img { max-width: 100%; max-height: 70vh; + /* Shrinks to leave the name room in a short listing */ + min-height: 0; object-fit: contain; border-radius: 4px; } .image-preview-name { + flex-shrink: 0; font-size: 14px; color: var(--dashboard-text); text-align: center; @@ -6031,9 +6073,20 @@ body.myapps-reordering .myapps-tile { margin-bottom: 12px; } - /* Clear the fixed menu button beside the crumbs */ + /* Clear the fixed menu button beside the crumbs, and match its height: + a shorter row put the arrows below under the button's tap area, so + the top of Back opened the menu. */ .dashboard-section-files .header .path-breadcrumbs { margin-left: 50px; + box-sizing: content-box; + min-height: 40px; + } + + /* Finger-sized crumbs, in that row */ + @media (pointer: coarse) { + .dashboard-section-files .header .path .dirname { + padding: 8px 6px; + } } /* Adjust footer position - full width since sidebar is hidden. It sits @@ -6049,6 +6102,13 @@ body.myapps-reordering .myapps-tile { padding-bottom: calc(30px + env(safe-area-inset-bottom, 0px)); } + /* The grids keep their own padding and clear the footer with a margin, + which has to grow with the footer as well. (Select mode makes room + for its bar, safe area included, below.) */ + .dashboard-section-files .files-tab:not(.select-mode-active) .files.files-grid-view { + margin-bottom: calc(30px + env(safe-area-inset-bottom, 0px)); + } + /* Mobile: Floating selection actions - icon-only, full width */ .dashboard-section-files .files-selection-actions { left: 0; @@ -7629,6 +7689,19 @@ body.myapps-reordering .myapps-tile { } } +/* "No files in this directory." and "couldn't be opened", in place of rows */ +.files-notice { + position: absolute; + inset: 0; + display: flex; + justify-content: center; + align-items: center; + padding: 0 16px; + text-align: center; + color: var(--dashboard-text-tertiary); + pointer-events: none; +} + /* ====================================== Files Loading Spinner ====================================== */ @@ -8888,10 +8961,13 @@ body.dashboard-mode .notifications-close-all { opacity: 0; transition: opacity 200ms ease; z-index: 1000; + /* Fading out after a close, it must not take the next click. */ + pointer-events: none; } .dashboard-dialog-overlay.dashboard-dialog-show { opacity: 1; + pointer-events: auto; } .dashboard-dialog { diff --git a/src/gui/src/helpers.determineWebsiteUrl.test.js b/src/gui/src/helpers.determineWebsiteUrl.test.js new file mode 100644 index 000000000..53888e407 --- /dev/null +++ b/src/gui/src/helpers.determineWebsiteUrl.test.js @@ -0,0 +1,62 @@ +// @vitest-environment jsdom +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { describe, expect, it, vi } from 'vitest'; +import jQuery from './lib/jquery-3.6.1/jquery-3.6.1.min.js'; + +vi.mock('./UI/UIAlert.js', () => ({ default: vi.fn() })); +vi.mock('./UI/UIItem.js', () => ({ default: vi.fn() })); +vi.mock('./UI/UIWindowLogin.js', () => ({ default: vi.fn() })); +vi.mock('./UI/UIWindowProgress.js', () => ({ default: vi.fn() })); +vi.mock('./UI/UIWindowSaveAccount.js', () => ({ default: vi.fn() })); + +globalThis.$ = jQuery; +globalThis.jQuery = jQuery; +globalThis.i18n = key => key; +globalThis.def = globalThis.def ?? (value => value); +globalThis.use = globalThis.use ?? (() => class {}); +globalThis.html_encode = value => jQuery('
').text(value).html(); + +await import('./helpers.js'); + +describe('determine_website_url', () => { + // As puter.hosting.list() returns a site. + const site = { + subdomain: 'mysite', + root_dir: { + path: '/alice/Site', + subdomains: [{ subdomain: 'mysite', address: 'https://mysite.puter.site' }], + }, + }; + + it('gives an item under a published folder its address there', () => { + window.sites = [site]; + expect(window.determine_website_url('/alice/Site/index.html')).toBe('https://mysite.puter.site/index.html'); + expect(window.determine_website_url('/alice/Site/css/a b.css')).toBe('https://mysite.puter.site/css/a b.css'); + }); + + it('gives none to the folder itself or to anything outside it', () => { + window.sites = [site]; + expect(window.determine_website_url('/alice/Site')).toBeNull(); + expect(window.determine_website_url('/alice/Sites/index.html')).toBeNull(); + window.sites = []; + expect(window.determine_website_url('/alice/Site/index.html')).toBeNull(); + }); +}); diff --git a/src/gui/src/helpers.js b/src/gui/src/helpers.js index dd6126ed7..f8d38a1fd 100644 --- a/src/gui/src/helpers.js +++ b/src/gui/src/helpers.js @@ -1971,11 +1971,8 @@ window.move_items = async function (el_items, dest_path, is_undo = false) { if ( is_trashing ) { // if trashing dir... if ( $(el_item).attr('data-is_dir') === '1' ) { - // disassociate all its websites - // todo, some client-side check to see if this dir has at least one associated website before sending ajax request - // FIXME: dir_uuid is not defined, is this the same as the data-uid attribute? - // puter.hosting.delete(dir_uuid) - + // Its websites stay registered: the server doesn't + // serve a root in Trash, and restoring brings them back. $(`.mywebsites-dir-path[data-uuid="${$(el_item).attr('data-uid')}"]`).remove(); // remove the website badge from all instances of the dir $(`.item[data-uid="${$(el_item).attr('data-uid')}"]`).find('.item-has-website-badge').fadeOut(300); @@ -2166,10 +2163,13 @@ window.refresh_desktop_background = function () { }; window.determine_website_url = function (fsentry_path) { - // search window.sites and if any site has `dir_path` set and the fsentry_path starts with that dir_path + '/', return the site's url + path - for ( let i = 0; i < window.sites.length; i++ ) { - if ( window.sites[i].dir_path && fsentry_path.startsWith(`${window.sites[i].dir_path }/`) ) { - return window.sites[i].address + fsentry_path.replace(window.sites[i].dir_path, ''); + // The address an item has under a site serving a folder above it. The + // site's folder and address come with its root_dir. + for ( const site of window.sites ) { + const dir_path = site.root_dir?.path; + const address = site.root_dir?.subdomains?.find((entry) => entry.subdomain === site.subdomain)?.address; + if ( dir_path && address && fsentry_path.startsWith(`${dir_path}/`) ) { + return address + fsentry_path.slice(dir_path.length); } } @@ -3309,11 +3309,12 @@ window.rename_file = async (options, new_name, old_name, old_path, el_item, el_i onDone?.(null); - //show error - if ( err.message ) { - UIAlert(err.message); - } + // A network failure rejects with no message, and the name going + // back on its own would look like the rename was ignored. + UIAlert(err?.message || i18n('error_unknown_cause', [], false)); }, + }).catch(() => { + // Handled by the error callback above. }); }; diff --git a/src/gui/src/helpers.renameFile.test.js b/src/gui/src/helpers.renameFile.test.js new file mode 100644 index 000000000..81ac70c9f --- /dev/null +++ b/src/gui/src/helpers.renameFile.test.js @@ -0,0 +1,61 @@ +// @vitest-environment jsdom +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { describe, expect, it, vi } from 'vitest'; +import jQuery from './lib/jquery-3.6.1/jquery-3.6.1.min.js'; + +vi.mock('./UI/UIAlert.js', () => ({ default: vi.fn() })); +vi.mock('./UI/UIItem.js', () => ({ default: vi.fn() })); +vi.mock('./UI/UIWindowLogin.js', () => ({ default: vi.fn() })); +vi.mock('./UI/UIWindowProgress.js', () => ({ default: vi.fn() })); +vi.mock('./UI/UIWindowSaveAccount.js', () => ({ default: vi.fn() })); + +globalThis.$ = jQuery; +globalThis.jQuery = jQuery; +globalThis.i18n = key => key; +globalThis.def = globalThis.def ?? (value => value); +globalThis.use = globalThis.use ?? (() => class {}); +globalThis.html_encode = value => jQuery('
').text(value).html(); + +await import('./helpers.js'); +const { default: UIAlert } = await import('./UI/UIAlert.js'); + +describe('rename_file', () => { + it('says so when the rename never reaches the server, and leaves no rejection behind', async () => { + const failure = new XMLHttpRequest(); + // Not a vi.fn(), which handles the promises it returns. + globalThis.puter = { fs: { rename: ({ error }) => { + error(failure); + return Promise.reject(failure); + } } }; + const onDone = vi.fn(); + const unhandled = vi.fn(); + process.on('unhandledRejection', unhandled); + + await window.rename_file({ uid: 'u1', name: 'a.txt', path: '/alice/a.txt' }, 'b.txt', 'a.txt', '/alice/a.txt', + document.createElement('div'), null, null, null, '', false, onDone); + await new Promise(resolve => setTimeout(resolve, 0)); + process.off('unhandledRejection', unhandled); + + expect(onDone).toHaveBeenCalledWith(null); + expect(UIAlert).toHaveBeenCalledWith('error_unknown_cause'); + expect(unhandled).not.toHaveBeenCalled(); + }); +}); diff --git a/src/gui/src/helpers/forgetParentListing.js b/src/gui/src/helpers/forgetParentListing.js new file mode 100644 index 000000000..ddb138d68 --- /dev/null +++ b/src/gui/src/helpers/forgetParentListing.js @@ -0,0 +1,34 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import path from '../lib/path.js'; + +/** + * Drops puter.js's cached listing of the folder that holds `item_path`, so + * the next listing of it is read fresh. For changes to an item the server + * sends no fs event about (a website added to or removed from a folder), + * which the cached listing would go on showing the old way. + * + * @param {string} item_path + * @returns {void} + */ +export default function forget_parent_listing (item_path) { + if ( typeof puter.fsCacheKey !== 'function' || ! puter._cache ) return; + puter._cache.del(puter.fsCacheKey('readdir', path.dirname(item_path))); +} diff --git a/src/gui/src/helpers/generateFileContextMenu.js b/src/gui/src/helpers/generateFileContextMenu.js index a5b060f42..ea528faa4 100644 --- a/src/gui/src/helpers/generateFileContextMenu.js +++ b/src/gui/src/helpers/generateFileContextMenu.js @@ -371,8 +371,10 @@ const generate_file_context_menu = async function (options) { // Remove from Shared // ------------------------------------------- // Can't trash someone else's file, so give up our own access instead. Only - // for an item shared directly — access to a child is held on the folder. - if ( is_shared_root ) { + // for an item shared directly — access to a child is held on the folder — + // and not one shared with a team, which isn't the member's to leave. + const is_team_share = Boolean(fsentry.holder_team ?? fsentry.holderTeam); + if ( is_shared_root && ! is_team_share ) { menu_items.push({ html: i18n('share_remove_from_shared'), onClick: async function () { diff --git a/src/gui/src/helpers/generateFileContextMenu.test.js b/src/gui/src/helpers/generateFileContextMenu.test.js new file mode 100644 index 000000000..8dc9c0674 --- /dev/null +++ b/src/gui/src/helpers/generateFileContextMenu.test.js @@ -0,0 +1,86 @@ +// @vitest-environment jsdom +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { expect, it, vi } from 'vitest'; +import jQuery from '../lib/jquery-3.6.1/jquery-3.6.1.min.js'; + +vi.mock('../UI/UIAlert.js', () => ({ default: vi.fn() })); +vi.mock('../UI/UIWindowItemProperties.js', () => ({ default: vi.fn() })); +vi.mock('../UI/UIWindowPublishWorker.js', () => ({ default: vi.fn() })); +vi.mock('../UI/UIWindowShare.js', () => ({ default: vi.fn() })); +vi.mock('./publishAsWebsite.js', () => ({ default: vi.fn() })); +vi.mock('./ensureCanPublish.js', () => ({ default: vi.fn() })); +vi.mock('./openItem.js', () => ({ default: vi.fn() })); +vi.mock('./openWithAI.js', () => ({ default: vi.fn() })); +vi.mock('./launchApp.js', () => ({ default: vi.fn() })); +vi.mock('./pathOwner.js', () => ({ is_owned_by_me: () => false })); +vi.mock('./sharedAccess.js', () => ({ + can_rename: async () => false, + can_restructure: async () => false, + can_share: async () => false, + invalidate_shared_roots: vi.fn(), +})); + +globalThis.$ = globalThis.jQuery = jQuery; +globalThis.i18n = (key) => key; +globalThis.def = globalThis.def ?? ((value) => value); +globalThis.use = globalThis.use ?? (() => class {}); +window.feature_flags = {}; +window.clipboard = []; +window.trash_path = '/me/Trash'; + +const { default: generate_file_context_menu } = + await import('./generateFileContextMenu.js'); + +const sharedRow = () => { + const row = document.createElement('div'); + row.setAttribute('data-path', '/ann/uid-1/notes.txt'); + row.setAttribute('data-name', 'notes.txt'); + row.setAttribute('data-shared_with_me', '1'); + row.setAttribute('data-share_mode', 'read'); + return row; +}; + +const labels = async (fsentry) => { + const items = await generate_file_context_menu({ + element: sharedRow(), + fsentry: { + name: 'notes.txt', + path: '/ann/uid-1/notes.txt', + ...fsentry, + }, + associated_app_name: 'editor', + }); + return items.filter((item) => item !== '-').map((item) => item.html); +}; + +it('offers Remove from Shared on a share made to the user', async () => { + expect(await labels({})).toContain('share_remove_from_shared'); +}); + +it('does not offer it on a share held through a team', async () => { + const team = { uid: 'team-1', name: 'Acme', handle: null }; + expect(await labels({ holder_team: team })).not.toContain( + 'share_remove_from_shared', + ); + expect(await labels({ holderTeam: team })).not.toContain( + 'share_remove_from_shared', + ); +}); diff --git a/src/gui/src/helpers/refreshItemContainer.js b/src/gui/src/helpers/refreshItemContainer.js index a64dc3710..fe7cc09ab 100644 --- a/src/gui/src/helpers/refreshItemContainer.js +++ b/src/gui/src/helpers/refreshItemContainer.js @@ -150,6 +150,7 @@ const refresh_item_container = function (el_item_container, options) { share_mode: share.mode, shared_by: share.issuer, owner: share.owner, + holder_team: share.holderTeam, metadata: '', }))) : puter.fs.readdir({ path: container_path, consistency: options.consistency ?? 'eventual' }); @@ -247,6 +248,7 @@ const refresh_item_container = function (el_item_container, options) { share_mode: fsentry.share_mode, shared_by: fsentry.shared_by, is_shared: fsentry.is_shared, + is_team_share: !!fsentry.holder_team, owner: fsentry.owner?.username ?? fsentry.owner, }); } diff --git a/src/gui/src/helpers/refreshItemContainer.test.js b/src/gui/src/helpers/refreshItemContainer.test.js new file mode 100644 index 000000000..cdf2370e8 --- /dev/null +++ b/src/gui/src/helpers/refreshItemContainer.test.js @@ -0,0 +1,99 @@ +// @vitest-environment jsdom +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { describe, expect, it, vi } from 'vitest'; +import jQuery from '../lib/jquery-3.6.1/jquery-3.6.1.min.js'; + +vi.mock('../UI/UIItem.js', () => ({ default: vi.fn() })); +vi.mock('./itemIcon.js', () => ({ + default: vi.fn(async () => ({ image: '', type: 'icon' })), +})); +vi.mock('./listAllShared.js', () => ({ default: vi.fn() })); + +globalThis.$ = jQuery; +globalThis.jQuery = jQuery; +globalThis.i18n = (key) => key; +jQuery.fn.removeItems = function () { + return this.remove(); +}; +window.icons = {}; +Object.assign(window, { + Transaction: class { + start() {} + end() {} + }, + shared_path: '/alice/Shared', + trash_path: '/alice/Trash', + user_preferences: {}, + desktop_item_positions: {}, + check_fsentry_against_allowed_file_types_string: () => true, + sort_items: () => {}, + update_explorer_footer_item_count: () => {}, +}); + +const { default: refresh_item_container } = + await import('./refreshItemContainer.js'); +const { default: UIItem } = await import('../UI/UIItem.js'); +const { default: list_all_shared } = await import('./listAllShared.js'); + +describe('refresh_item_container in the Shared view', () => { + it('flags rows shared through a team, and only those', async () => { + list_all_shared.mockResolvedValue([ + { + entryUid: 'u-1', + name: 'a.txt', + path: '/bob/u-1/a.txt', + isDir: false, + mode: 'read', + issuer: 'bob', + owner: 'bob', + }, + { + entryUid: 'u-2', + name: 'b.txt', + path: '/bob/u-2/b.txt', + isDir: false, + mode: 'read', + issuer: 'bob', + owner: 'bob', + holderTeam: { uid: 't-1', name: 'Acme' }, + }, + ]); + document.body.innerHTML = + '
'; + + refresh_item_container($('.item-container')); + + await vi.waitFor(() => expect(UIItem).toHaveBeenCalledTimes(2)); + const [direct, team] = UIItem.mock.calls.map(([options]) => options); + expect(direct).toMatchObject({ + uid: 'u-1', + shared_with_me: true, + share_mode: 'read', + shared_by: 'bob', + is_team_share: false, + }); + expect(team).toMatchObject({ + uid: 'u-2', + shared_with_me: true, + is_team_share: true, + }); + }); +}); diff --git a/src/gui/src/i18n/translations/en.js b/src/gui/src/i18n/translations/en.js index 4aad1d774..be91aedf8 100644 --- a/src/gui/src/i18n/translations/en.js +++ b/src/gui/src/i18n/translations/en.js @@ -177,7 +177,8 @@ const en = { directory_depth_limit_exceeded: 'This folder is nested too deeply. Move it somewhere closer to the top before adding more folders inside it.', disable_2fa: 'Disable 2FA', disable_2fa_confirm: 'Are you sure you want to disable 2FA?', - disable_2fa_instructions: 'Confirm your identity and enter the code from your authenticator app to disable 2FA.', + disable_2fa_instructions: 'Confirm your identity and enter an authenticator code or an unused recovery code to disable 2FA.', + disable_2fa_code_instruction: 'Enter a 6-digit authenticator code or an unused recovery code.', disassociate_dir: 'Disassociate Directory', display_name: 'Display Name', directory: 'Directory', @@ -610,6 +611,9 @@ const en = { share_link_anyone_note_write_items: 'Anyone signed in to Puter who has a link to one of these items can view and edit it.', share_link_mixed_note: 'Some of these items are open to anyone with the link and some are not.', + share_link_unchecked: 'Not checked', + share_link_unchecked_note: + 'Whether this many items are open to anyone with the link isn’t checked. A choice here applies to all {{total}}.', share_link_on_read: 'Anyone with the link can now view this item', share_link_on_write: 'Anyone with the link can now edit this item', share_link_on_read_items: 'Anyone with the link can now view these items', @@ -987,8 +991,8 @@ const en = { 'perm_email_read': 'see your email address', 'perm_apps_read': 'see your apps', 'perm_apps_write': 'see who uses your apps, including email addresses they shared with your apps', - 'perm_subdomains_read': 'see your subdomains', - 'perm_subdomains_write': 'create, change and delete your subdomains', + 'perm_subdomains_read': 'see all your websites', + 'perm_subdomains_write': 'see, change and delete all your websites', 'perm_app_root_dir_read': 'read the website files of your app {{app}}', 'perm_app_root_dir_write': 'read, change and delete the website files of your app {{app}}', 'perm_app_data_subject_data': "{{app}}'s saved data", diff --git a/src/gui/src/keyboard.js b/src/gui/src/keyboard.js index 9b04ffafb..78e6aff7b 100644 --- a/src/gui/src/keyboard.js +++ b/src/gui/src/keyboard.js @@ -911,8 +911,10 @@ $(document).bind('keyup keydown', async function (e) { // ctrl/command + z, will undo last action //----------------------------------------------------------------------------- // Not in a text field, whose own undo is what the user means: the last - // file action could be an upload, which Undo deletes outright. - if ( (e.ctrlKey || e.metaKey) && e.which === 90 && ! $(focused_el).is('input, textarea, [contenteditable]') ) { + // file action could be an upload, which Undo deletes outright. Once per + // press: releasing Z with Ctrl/Cmd still held would undo a second action. + if ( (e.ctrlKey || e.metaKey) && e.which === 90 && e.type === 'keydown' + && ! $(focused_el).is('input, textarea, [contenteditable]') ) { window.undo_last_action(); return false; } diff --git a/src/gui/src/keyboard.undo.test.js b/src/gui/src/keyboard.undo.test.js new file mode 100644 index 000000000..657cbdcef --- /dev/null +++ b/src/gui/src/keyboard.undo.test.js @@ -0,0 +1,46 @@ +// @vitest-environment jsdom +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { describe, expect, it, vi } from 'vitest'; +import jQuery from './lib/jquery-3.6.1/jquery-3.6.1.min.js'; + +vi.mock('./UI/UIAlert.js', () => ({ default: vi.fn() })); +vi.mock('./UI/UIWindowSearch.js', () => ({ default: vi.fn() })); +vi.mock('./helpers/launchApp.js', () => ({ default: vi.fn() })); +vi.mock('./helpers/openItem.js', () => ({ default: vi.fn() })); + +globalThis.$ = jQuery; +globalThis.jQuery = jQuery; +globalThis.i18n = key => key; +globalThis.def = globalThis.def ?? (value => value); +globalThis.use = globalThis.use ?? (() => class {}); + +await import('./keyboard.js'); + +describe('Ctrl/Cmd+Z', () => { + it('undoes one action per press', () => { + window.undo_last_action = vi.fn(); + const z = { key: 'z', keyCode: 90, which: 90, metaKey: true, bubbles: true }; + document.body.dispatchEvent(new KeyboardEvent('keydown', z)); + // Z let go before Cmd. + document.body.dispatchEvent(new KeyboardEvent('keyup', z)); + expect(window.undo_last_action).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/puter-js/src/index.authFlow.test.js b/src/puter-js/src/index.authFlow.test.js new file mode 100644 index 000000000..72ba77840 --- /dev/null +++ b/src/puter-js/src/index.authFlow.test.js @@ -0,0 +1,134 @@ +// @vitest-environment jsdom +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('socket.io-client', () => ({ + io: () => ({ + on: vi.fn(), + off: vi.fn(), + emit: vi.fn(), + disconnect: vi.fn(), + }), +})); +vi.mock('./lib/polyfills/localStorage.js', () => ({ default: {} })); +vi.mock('./lib/polyfills/xhrshim.js', () => ({ default: class {} })); + +const origDefine = customElements.define.bind(customElements); +customElements.define = (name, cls) => + customElements.get(name) || origDefine(name, cls); + +const origParent = Object.getOwnPropertyDescriptor(globalThis, 'parent'); +const origXHR = globalThis.XMLHttpRequest; +const urls = []; +class CountingXHR { + _listeners = {}; + status = 200; + readyState = 0; + responseType = ''; + upload = { addEventListener() {} }; + open(method, url) { + this.url = url; + urls.push(`${method} ${url}`); + } + setRequestHeader() {} + addEventListener(type, fn) { + (this._listeners[type] ??= []).push(fn); + } + getResponseHeader(n) { + return n.toLowerCase() === 'content-type' ? 'application/json' : null; + } + abort() {} + send() { + queueMicrotask(() => { + this.responseText = JSON.stringify({ username: 'u' }); + this.response = this.responseText; + this.readyState = 4; + for (const fn of this._listeners.readystatechange ?? []) + fn.call(this); + for (const fn of this._listeners.load ?? []) + fn.call(this, { target: this }); + }); + } +} + +beforeEach(() => { + localStorage.clear(); + urls.length = 0; + Object.defineProperty(globalThis, 'parent', { + value: globalThis, + configurable: true, + }); + globalThis.XMLHttpRequest = CountingXHR; +}); +afterEach(() => { + if (origParent) Object.defineProperty(globalThis, 'parent', origParent); + globalThis.XMLHttpRequest = origXHR; + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +const flush = async () => { + for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0)); +}; + +describe('implicit sign-in on a web page', () => { + it('runs onAuth twice for one sign-in', async () => { + history.replaceState(null, '', '/site/index.html'); + vi.resetModules(); + // Hide `process` from the SDK's environment probe only, so the + // module's own instance boots as a top-level web page. + const realProcess = globalThis.process; + Object.defineProperty(globalThis, 'process', { + configurable: true, + get() { + const stack = new Error().stack ?? ''; + return /src\/index\.js:\d+/.test(stack.split('\n')[2] ?? '') + ? undefined + : realProcess; + }, + }); + let puter; + try { + ({ puter } = await import('./index.js')); + } finally { + Object.defineProperty(globalThis, 'process', { + value: realProcess, + configurable: true, + writable: true, + }); + } + expect(puter.env).toBe('web'); + expect(puter.authToken).toBeFalsy(); + + const popup = { closed: false }; + vi.spyOn(window, 'open').mockReturnValue(popup); + Object.defineProperty(navigator, 'userActivation', { + value: { hasBeenActive: true, isActive: true }, + configurable: true, + }); + vi.spyOn(crypto, 'randomUUID').mockReturnValue('sess-1'); + + const onAuth = vi.fn(); + puter.onAuth = onAuth; + + const signedIn = puter.ui.authenticateWithPuter(); + window.dispatchEvent( + new MessageEvent('message', { + origin: puter.defaultGUIOrigin, + source: popup, + data: { + msg: 'puter.token', + msg_id: 'sess-1', + success: true, + token: 'tok', + }, + }), + ); + await signedIn; + await flush(); + + expect(puter.authToken).toBe('tok'); + expect(onAuth).toHaveBeenCalledTimes(1); + const whoami = urls.filter((u) => u.includes('/whoami')); + expect(whoami.length).toBe(1); + }); +}); diff --git a/src/puter-js/src/index.js b/src/puter-js/src/index.js index 197f643a4..e9b8932fb 100644 --- a/src/puter-js/src/index.js +++ b/src/puter-js/src/index.js @@ -722,8 +722,6 @@ export class Puter { // When SDK is loaded in GUI the initiation process should start when the DOM is ready. This is because // the SDK needs to show a dialog to the user to ask for permission to access their Puter account. else if (this.env === 'web') { - // initialize submodules - this.initSubmodules(); try { const storedToken = this.normalizeAuthTokenCandidate( localStorage.getItem(STORAGE_KEY_V2), @@ -754,6 +752,8 @@ export class Puter { console.error('Error accessing localStorage:', error); } + this.initSubmodules(); + // Print a CTA for developers to publish their app on the Puter App Store this.printDevCTA(); @@ -2015,9 +2015,14 @@ globalThis.addEventListener && '*', ); } else if (event.data.msg === 'puter.token') { + if (puter.auth?.pendingSignInMessageIds_?.has(event.data.msg_id)) + return; if (!puter.tokenSourceAllowed_(event.source)) { return; } + // Could be a late reply from an old popup; the flow that owns the + // prompt settles its own failures. + if (event.data.success === false) return; // Set the authToken property puter.setAuthToken(event.data.token); // update appID only when token does not include app identity @@ -2038,7 +2043,8 @@ globalThis.addEventListener && // Not awaited: the waiting sign-ins below settle first. (async () => { try { - puter.onAuth(await puter.getUser()); + const user = await puter.whoamiCache_; + puter.onAuth(user ?? (await puter.getUser())); } catch (e) { console.error(e); } diff --git a/src/puter-js/src/index.test.js b/src/puter-js/src/index.test.js index aa1ac1572..678504e91 100644 --- a/src/puter-js/src/index.test.js +++ b/src/puter-js/src/index.test.js @@ -328,6 +328,29 @@ describe('GUI message listener', () => { ).resolves.toBeUndefined(); }); + it('ignores a late failure message without touching a pending sign-in', async () => { + const { puter, handler } = await bootWithListener(); + const previous = puter.authToken; + const waiter = { resolve: vi.fn(), reject: vi.fn() }; + puter.puterAuthState.isPromptOpen = true; + puter.puterAuthState.resolvers.push(waiter); + await handler({ + origin: puter.defaultGUIOrigin, + source: globalThis.parent, + data: { + msg: 'puter.token', + success: false, + token: 'invalid', + error: 'auth_canceled', + }, + }); + expect(puter.authToken).toBe(previous); + expect(waiter.reject).not.toHaveBeenCalled(); + expect(waiter.resolve).not.toHaveBeenCalled(); + expect(puter.puterAuthState.resolvers).toEqual([waiter]); + expect(puter.puterAuthState.isPromptOpen).toBe(true); + }); + it('reports a failed user lookup after sign-in', async () => { const { puter, handler } = await bootWithListener(); const error = vi.spyOn(console, 'error').mockImplementation(() => {}); diff --git a/src/puter-js/src/index.webBoot.test.js b/src/puter-js/src/index.webBoot.test.js new file mode 100644 index 000000000..89c3805e0 --- /dev/null +++ b/src/puter-js/src/index.webBoot.test.js @@ -0,0 +1,87 @@ +// @vitest-environment jsdom +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const ioCalls = []; +vi.mock('socket.io-client', () => ({ + io: (origin, opts) => { + ioCalls.push({ origin, token: opts?.auth?.auth_token ?? null }); + return { + on: vi.fn(), + off: vi.fn(), + emit: vi.fn(), + disconnect: vi.fn(), + }; + }, +})); +vi.mock('./lib/polyfills/localStorage.js', () => ({ default: {} })); +vi.mock('./lib/polyfills/xhrshim.js', () => ({ default: class {} })); + +const STORAGE_KEY = 'puter.auth.token.v2'; +const ORIGIN_KEY = 'puter.auth.token.origin.v2'; +const b64url = (value) => Buffer.from(value).toString('base64url'); +const APP_TOKEN = `${b64url('{"alg":"HS256"}')}.${b64url(JSON.stringify({ t: 'au', v: '2', au: Buffer.alloc(16, 1).toString('base64') }))}.sig`; + +const origDefine = customElements.define.bind(customElements); +customElements.define = (name, cls) => + customElements.get(name) || origDefine(name, cls); + +const origParent = Object.getOwnPropertyDescriptor(globalThis, 'parent'); +const origXHR = globalThis.XMLHttpRequest; +class StubXHR { + open() {} + setRequestHeader() {} + addEventListener() {} + send() {} +} + +beforeEach(() => { + localStorage.clear(); + ioCalls.length = 0; + Object.defineProperty(globalThis, 'parent', { + value: globalThis, + configurable: true, + }); + globalThis.XMLHttpRequest = StubXHR; +}); +afterEach(() => { + if (origParent) Object.defineProperty(globalThis, 'parent', origParent); + globalThis.XMLHttpRequest = origXHR; +}); + +describe('web-env boot with a stored token', () => { + it('opens only one authenticated socket', async () => { + localStorage.setItem(STORAGE_KEY, APP_TOKEN); + localStorage.setItem(ORIGIN_KEY, 'https://api.puter.com'); + history.replaceState(null, '', '/site/index.html'); + vi.resetModules(); + const { Puter } = await import('./index.js'); + ioCalls.length = 0; + + const realProcess = globalThis.process; + Object.defineProperty(globalThis, 'process', { + configurable: true, + get() { + const caller = (new Error().stack ?? '').split('\n')[2] ?? ''; + return /src\/index\.js:\d+/.test(caller) + ? undefined + : realProcess; + }, + }); + let puter; + try { + puter = new Puter(); + } finally { + Object.defineProperty(globalThis, 'process', { + value: realProcess, + configurable: true, + writable: true, + }); + } + + expect(puter.env).toBe('web'); + expect(puter.authToken).toBe(APP_TOKEN); + expect(ioCalls).toEqual([ + { origin: 'https://api.puter.com', token: APP_TOKEN }, + ]); + }); +}); diff --git a/src/puter-js/src/lib/networkUtils.blob.test.js b/src/puter-js/src/lib/networkUtils.blob.test.js new file mode 100644 index 000000000..6eca42d2b --- /dev/null +++ b/src/puter-js/src/lib/networkUtils.blob.test.js @@ -0,0 +1,76 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { randomBytes } from 'node:crypto'; +import { driverCall } from './networkUtils.js'; + +const SIZE = 1024 * 1024; + +function installBlobXHR(blob) { + class FakeXHR { + _listeners = {}; + status = 0; + readyState = 0; + responseType = ''; + upload = { addEventListener() {} }; + open() {} + setRequestHeader() {} + addEventListener(type, fn) { + (this._listeners[type] ??= []).push(fn); + } + getResponseHeader(name) { + return name.toLowerCase() === 'content-type' ? 'video/mp4' : null; + } + abort() {} + send() { + queueMicrotask(() => { + this.status = 200; + this.response = blob; + this.readyState = 4; + for (const fn of this._listeners.readystatechange ?? []) + fn.call(this); + for (const fn of this._listeners.load ?? []) fn.call(this); + }); + } + } + globalThis.XMLHttpRequest = FakeXHR; +} + +let savedXHR; +beforeEach(() => { + savedXHR = globalThis.XMLHttpRequest; + globalThis.puter = { + authToken: 't', + APIOrigin: 'https://api.test', + env: 'nodejs', + }; +}); +afterEach(() => { + globalThis.XMLHttpRequest = savedXHR; + delete globalThis.puter; + vi.restoreAllMocks(); +}); + +describe('successful blob driver call', () => { + it('returns the blob without decoding it to text', async () => { + const blob = new Blob([randomBytes(SIZE)], { type: 'video/mp4' }); + installBlobXHR(blob); + const textSpy = vi.spyOn(Blob.prototype, 'text'); + + const t0 = performance.now(); + const result = await driverCall( + { + iface: 'puter-video-generation', + method: 'generate', + args: {}, + puter: globalThis.puter, + }, + { responseType: 'blob' }, + ); + const ms = performance.now() - t0; + + expect(result).toBe(blob); + expect(textSpy).not.toHaveBeenCalled(); + process.stdout.write( + `driverCall on a ${SIZE / 1024 / 1024} MiB blob: ${ms.toFixed(1)} ms\n`, + ); + }); +}); diff --git a/src/puter-js/src/lib/networkUtils.bodyToken.test.js b/src/puter-js/src/lib/networkUtils.bodyToken.test.js new file mode 100644 index 000000000..dd55c65ba --- /dev/null +++ b/src/puter-js/src/lib/networkUtils.bodyToken.test.js @@ -0,0 +1,120 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { fsRequest } from '../modules/FileSystem/operations/scaffold.js'; +import { driverCallEnvelope } from './networkUtils.js'; + +function installFakeXHR(responses) { + const requests = []; + class FakeXHR { + _listeners = {}; + _headers = {}; + responseType = ''; + status = 200; + readyState = 0; + upload = { addEventListener() {} }; + open(method, url) { + this.method = method; + this.url = url; + } + setRequestHeader(name, value) { + this._headers[name.toLowerCase()] = value; + } + addEventListener(type, fn) { + (this._listeners[type] ??= []).push(fn); + } + getResponseHeader(name) { + return name.toLowerCase() === 'content-type' + ? 'application/json' + : null; + } + abort() {} + send(body) { + this.body = body; + requests.push(this); + const next = responses.shift(); + queueMicrotask(() => { + this.status = next.status; + this.responseText = JSON.stringify(next.body); + this.response = this.responseText; + this.readyState = 4; + this.onreadystatechange?.(); + for (const fn of this._listeners.readystatechange ?? []) + fn.call(this); + for (const fn of this._listeners.load ?? []) + fn.call(this, { target: this }); + }); + } + } + globalThis.XMLHttpRequest = FakeXHR; + return requests; +} + +const authFailed = { status: 401, body: { code: 'token_auth_failed' } }; + +let savedXHR; +beforeEach(() => { + savedXHR = globalThis.XMLHttpRequest; + globalThis.puter = { + authToken: 'stale', + APIOrigin: 'https://api.test', + env: 'web', + resetAuthToken: vi.fn(() => { + globalThis.puter.authToken = null; + }), + ui: { + authenticateWithPuter: vi.fn(async () => { + globalThis.puter.authToken = 'fresh'; + }), + }, + }; +}); +afterEach(() => { + globalThis.XMLHttpRequest = savedXHR; + delete globalThis.puter; + vi.restoreAllMocks(); +}); + +describe('reauth replay of body-token requests', () => { + it('fs /stat rebuilds its body token after sign-in', async () => { + const requests = installFakeXHR([ + authFailed, + { status: 200, body: { success: true, result: 'ok' } }, + ]); + const fsModule = { + APIOrigin: 'https://api.test', + get authToken() { + return puter.authToken; + }, + }; + await expect( + fsRequest.call(fsModule, { + endpoint: '/stat', + authHeader: false, + body: { path: '/u/x', auth_token: fsModule.authToken }, + }), + ).resolves.toEqual({ success: true, result: 'ok' }); + + expect(puter.ui.authenticateWithPuter).toHaveBeenCalledTimes(1); + expect(puter.authToken).toBe('fresh'); + expect(requests).toHaveLength(2); + expect(JSON.parse(requests[1].body).auth_token).toBe('fresh'); + expect(requests[1]._headers.authorization).toBeUndefined(); + }); + + it('puter.drivers.call rebuilds its body token after sign-in', async () => { + const requests = installFakeXHR([ + authFailed, + { status: 200, body: { success: true, result: 'ok' } }, + ]); + const result = await driverCallEnvelope({ + puter: globalThis.puter, + iface: 'puter-kvstore', + method: 'get', + args: { key: 'k' }, + }); + + expect(puter.ui.authenticateWithPuter).toHaveBeenCalledTimes(1); + expect(requests).toHaveLength(2); + expect(JSON.parse(requests[1].body).auth_token).toBe('fresh'); + expect(result).toEqual({ success: true, result: 'ok' }); + }); +}); diff --git a/src/puter-js/src/lib/networkUtils.js b/src/puter-js/src/lib/networkUtils.js index 92bbdc2ad..f5921116d 100644 --- a/src/puter-js/src/lib/networkUtils.js +++ b/src/puter-js/src/lib/networkUtils.js @@ -88,18 +88,26 @@ function resolveBackgroundReauth(resp, sentToken) { * reauth-recoverable 401 and the caller should handle it normally. */ async function resolveReauth(resp, { interactive = true, sentToken } = {}) { + const needsReauth = + resp?.code === 'reauth_required' || resp?.code === 'token_auth_failed'; + if ( + needsReauth && + sentToken && + puter.authToken && + sentToken !== puter.authToken + ) { + return { action: 'replay' }; + } + if ( + needsReauth && + puter.env === 'app' && + !puter.isGodmodeToken_?.(sentToken ?? puter.authToken) + ) { + return { action: 'reject', error: { status: 401, ...resp } }; + } // A godmode app's token expires on schedule and the desktop renews it. // Asking shows no UI, so background requests ask too. - if ( - (resp?.code === 'reauth_required' || - resp?.code === 'token_auth_failed') && - puter.env === 'app' && - puter.isGodmodeToken_?.(sentToken ?? puter.authToken) - ) { - // Renewed while this request was in flight. - if (sentToken && puter.authToken && sentToken !== puter.authToken) { - return { action: 'replay' }; - } + if (needsReauth && puter.env === 'app') { try { await puter.triggerReauth({ reason: resp.reason ?? 'token_expired', @@ -293,6 +301,22 @@ function buildXhr(spec, { readSafe = false } = {}) { const origSend = xhr.send.bind(xhr); xhr.send = function (body) { spec.body = body; + // Body-token requests: builders that know the token say so, so large + // driver bodies aren't parsed just to read it. + if (typeof spec.bodyAuthToken === 'function') { + const token = spec.bodyAuthToken(); + if (typeof token === 'string' && token) spec._sentAuthToken = token; + } else if ( + typeof body === 'string' && + body.startsWith('{') && + body.includes('"auth_token"') + ) { + try { + const token = JSON.parse(body).auth_token; + if (typeof token === 'string' && token) + spec._sentAuthToken = token; + } catch {} + } // An upload listener makes the browser preflight a cross-origin // request. One carrying a bearer is preflighted already; driver calls // go out as simple requests and must stay that way. @@ -768,7 +792,7 @@ async function classifyRetry(outcome, ctx) { if (outcome.networkError) return transientRetry(ctx); const { xhr, status } = outcome; - if (outcome.parsed === undefined) { + if (outcome.parsed === undefined && [401, 403, 429].includes(status)) { outcome.parsed = await bodyJson(xhr).catch(() => null); } const parsed = outcome.parsed; @@ -929,6 +953,10 @@ function dedupe(key, factory, { windowMs = 2000 } = {}) { * @param {Object} [opts.headers] - Extra request headers (undefined/null values * skipped). * @param {string | Blob | ArrayBuffer | FormData | null} [opts.body] + * @param {() => string | Blob | ArrayBuffer | FormData | null} [opts.buildBody] + * Rebuilds the body for each attempt. + * @param {() => string | null | undefined} [opts.bodyAuthToken] The token the + * built body carries, recorded for reauth. * @param {'' | 'text' | 'json' | 'blob' | 'arraybuffer'} [opts.responseType=''] * Default is `''` * @param {boolean} [opts.withCredentials=true] Default is `true` @@ -957,6 +985,8 @@ function fetchUrl(url, opts = {}) { method = 'GET', headers = {}, body = null, + buildBody, + bodyAuthToken, responseType = '', withCredentials = true, signal, @@ -981,6 +1011,8 @@ function fetchUrl(url, opts = {}) { withCredentials, responseType, body, + buildBody, + bodyAuthToken, signal, logId, interactiveReauth, @@ -1252,6 +1284,7 @@ async function driverCall(call, opts = {}) { responseType, // Rebuilt per attempt, so a reauth replay carries the fresh token. buildBody: () => callBody(call, puter), + bodyAuthToken: () => puter.authToken, timeout, signal, }; @@ -1320,7 +1353,8 @@ async function driverCallEnvelope(call) { const resp = await fetchUrl(`${puter.APIOrigin}/drivers/call`, { method: 'POST', headers: { 'Content-Type': DRIVER_CONTENT_TYPE }, - body: callBody(call, puter), + buildBody: () => callBody(call, puter), + bodyAuthToken: () => puter.authToken, }); // TODO: parser for Content-Type diff --git a/src/puter-js/src/lib/networkUtils.reauth.test.js b/src/puter-js/src/lib/networkUtils.reauth.test.js new file mode 100644 index 000000000..74a8e814c --- /dev/null +++ b/src/puter-js/src/lib/networkUtils.reauth.test.js @@ -0,0 +1,122 @@ +// @vitest-environment jsdom +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('socket.io-client', () => ({ + io: () => ({ + on: vi.fn(), + off: vi.fn(), + emit: vi.fn(), + disconnect: vi.fn(), + }), +})); +vi.mock('./polyfills/localStorage.js', () => ({ default: {} })); +vi.mock('./polyfills/xhrshim.js', () => ({ default: class {} })); + +const STORAGE_KEY = 'puter.auth.token.v2'; +const b64url = (value) => Buffer.from(value).toString('base64url'); +const jwt = (payload) => + `${b64url('{"alg":"HS256"}')}.${b64url(JSON.stringify(payload))}.sig`; +const APP_TOKEN = jwt({ + t: 'au', + v: '2', + au: Buffer.alloc(16, 1).toString('base64'), +}); + +const origDefine = customElements.define.bind(customElements); +customElements.define = (name, cls) => + customElements.get(name) || origDefine(name, cls); + +const origParent = Object.getOwnPropertyDescriptor(globalThis, 'parent'); +const origXHR = globalThis.XMLHttpRequest; +class StubXHR { + open() {} + setRequestHeader() {} + addEventListener() {} + send() {} +} + +let parentPost; +beforeEach(() => { + localStorage.clear(); + sessionStorage.clear(); + parentPost = vi.fn(); + Object.defineProperty(globalThis, 'parent', { + value: { postMessage: parentPost }, + configurable: true, + }); + Object.defineProperty(document, 'readyState', { + value: 'loading', + configurable: true, + }); + globalThis.XMLHttpRequest = StubXHR; +}); +afterEach(() => { + vi.useRealTimers(); + if (origParent) Object.defineProperty(globalThis, 'parent', origParent); + delete document.readyState; + globalThis.XMLHttpRequest = origXHR; + vi.restoreAllMocks(); +}); + +describe('reauth_required in a non-godmode app', () => { + it('rejects promptly without asking the desktop to renew a regular app token', async () => { + history.replaceState( + null, + '', + `/app/index.html?puter.app_instance_id=i&puter.auth.token=${APP_TOKEN}`, + ); + vi.resetModules(); + const { puter } = await import('../index.js'); + const { resolveReauth } = await import('./networkUtils.js'); + expect(puter.env).toBe('app'); + expect(puter.authToken).toBe(APP_TOKEN); + parentPost.mockClear(); + + vi.useFakeTimers(); + let settled; + const pending = resolveReauth( + { code: 'reauth_required', reason: 'session_revoked' }, + { interactive: true, sentToken: APP_TOKEN }, + ).then((v) => { + settled = v; + }); + + await pending; + expect(parentPost).not.toHaveBeenCalled(); + expect(puter.authToken).toBe(APP_TOKEN); + expect(settled).toMatchObject({ + action: 'reject', + error: { code: 'reauth_required' }, + }); + }); +}); + +describe('token_auth_failed for a request sent before a sign-in finished (web)', () => { + it('replays with the fresh token without opening another sign-in', async () => { + vi.resetModules(); + const { resolveReauth } = await import('./networkUtils.js'); + globalThis.puter = { + env: 'web', + // Another call's sign-in already installed this one. + authToken: 'fresh', + resetAuthToken: vi.fn(() => { + globalThis.puter.authToken = null; + }), + ui: { + authenticateWithPuter: vi.fn(async () => { + globalThis.puter.authToken = 'second'; + }), + }, + }; + const verdict = await resolveReauth( + { code: 'token_auth_failed' }, + { interactive: true, sentToken: 'stale' }, + ); + expect(verdict).toEqual({ action: 'replay' }); + expect(globalThis.puter.resetAuthToken).not.toHaveBeenCalled(); + expect( + globalThis.puter.ui.authenticateWithPuter, + ).not.toHaveBeenCalled(); + delete globalThis.puter; + }); +}); diff --git a/src/puter-js/src/lib/utils.js b/src/puter-js/src/lib/utils.js index ea1342b8a..fa58302c9 100644 --- a/src/puter-js/src/lib/utils.js +++ b/src/puter-js/src/lib/utils.js @@ -72,7 +72,7 @@ function replayXhr (response, success_cb, error_cb, resolve_func, reject_func) { const newSpec = { ...spec, _replayed: true }; const newXhr = buildXhr(newSpec); setupXhrEventHandlers(newXhr, success_cb, error_cb, resolve_func, reject_func); - newXhr.send(spec.body); + newXhr.send(typeof newSpec.buildBody === 'function' ? newSpec.buildBody() : spec.body); return true; } diff --git a/src/puter-js/src/modules/Auth.js b/src/puter-js/src/modules/Auth.js index 1b93e330d..faa0431c3 100644 --- a/src/puter-js/src/modules/Auth.js +++ b/src/puter-js/src/modules/Auth.js @@ -116,6 +116,9 @@ import { * are for apps that drive the sign-in flow themselves. */ export class AuthModule extends PuterModule { + /** @internal */ + pendingSignInMessageIds_ = new Set(); + /** * Signs the user in, opening a popup with the appropriate authentication * method. Must be called from a user gesture (such as a click) — without @@ -164,6 +167,7 @@ export class AuthModule extends PuterModule { return new Promise((resolve, reject) => { const signinsession = crypto.randomUUID(); const msg_id = signinsession; + this.pendingSignInMessageIds_.add(msg_id); const popupName = `Puter-sign-in-${signinsession}`; const url = `${puter.defaultGUIOrigin}/action/sign-in?embedded_in_popup=true&msg_id=${msg_id}${window.crossOriginIsolated ? `&cross_origin_isolated=true&signin_session=${signinsession}` : ''}${options.attempt_temp_user_creation ? '&attempt_temp_user_creation=true' : ''}${options.request_auth ? '&request_auth=true' : ''}`; @@ -177,6 +181,7 @@ export class AuthModule extends PuterModule { let popupWindow = null; const cleanup = () => { + this.pendingSignInMessageIds_.delete(msg_id); if (checkClosed) { clearInterval(checkClosed); checkClosed = null; @@ -184,6 +189,18 @@ export class AuthModule extends PuterModule { window.removeEventListener('message', messageHandler); }; + const notifyAuth = () => { + if (typeof puter.onAuth !== 'function') return; + void (async () => { + try { + const user = await puter.whoamiCache_; + puter.onAuth(user ?? (await puter.getUser())); + } catch (error) { + console.error(error); + } + })(); + }; + if (window.crossOriginIsolated) { (async () => { // Bounded too: isolated with a gesture, nothing watches. @@ -223,6 +240,7 @@ export class AuthModule extends PuterModule { settled = true; cleanup(); puter.setAuthToken(auth_token); + notifyAuth(); resolve({ success: true, token: auth_token }); return ''; } @@ -262,6 +280,7 @@ export class AuthModule extends PuterModule { if (e.data.success) { // set the auth token puter.setAuthToken(e.data.token); + notifyAuth(); resolve(e.data); } else { reject(e.data); diff --git a/src/puter-js/src/modules/Auth.signIn.test.js b/src/puter-js/src/modules/Auth.signIn.test.js index cf04a3c0e..89cbcb1f0 100644 --- a/src/puter-js/src/modules/Auth.signIn.test.js +++ b/src/puter-js/src/modules/Auth.signIn.test.js @@ -131,6 +131,18 @@ describe('signIn popup binding', () => { }, ); + it('notifies explicit sign-in once using the cached user lookup', async () => { + const user = { username: 'alice' }; + puter.whoamiCache_ = Promise.resolve(user); + puter.onAuth = vi.fn(); + puter.getUser = vi.fn(); + const signedIn = auth.signIn(); + respond(popups[0], 'token'); + await signedIn; + expect(puter.onAuth).toHaveBeenCalledExactlyOnceWith(user); + expect(puter.getUser).not.toHaveBeenCalled(); + }); + it('keeps the isolated-page relay independent of popup messages', async () => { vi.stubGlobal('crossOriginIsolated', true); let answer; diff --git a/src/puter-js/src/modules/FileSystem/index.js b/src/puter-js/src/modules/FileSystem/index.js index d0cd541b5..4e13e4202 100644 --- a/src/puter-js/src/modules/FileSystem/index.js +++ b/src/puter-js/src/modules/FileSystem/index.js @@ -103,6 +103,10 @@ export class PuterJSFileSystemModule extends PuterModule { this.socket.disconnect(); } + this.socket = null; + if (!this.authToken) return; + this.socketToken_ = this.authToken; + this.socketOrigin_ = this.APIOrigin; this.socket = io(this.APIOrigin, { auth: { auth_token: this.authToken, @@ -212,6 +216,9 @@ export class PuterJSFileSystemModule extends PuterModule { } if ( this.puter.socketEnabled ) { + if (this.socket?.active && + this.socketToken_ === this.authToken && + this.socketOrigin_ === this.APIOrigin) return; this.initializeSocket(); } } diff --git a/src/puter-js/src/modules/FileSystem/index.test.js b/src/puter-js/src/modules/FileSystem/index.test.js index dc6021c00..8a06844ab 100644 --- a/src/puter-js/src/modules/FileSystem/index.test.js +++ b/src/puter-js/src/modules/FileSystem/index.test.js @@ -137,6 +137,43 @@ describe('auth state', () => { expect(fs.socket).not.toBe(previousSocket); }); + it('keeps an active socket when credentials and origin have not changed', () => { + const fs = makeModule('web'); + const previous = fs.socket; + previous.active = true; + fs.onAuthStateChanged(); + expect(fs.socket).toBe(previous); + expect(previous.disconnect).not.toHaveBeenCalled(); + }); + + it('replaces an inactive socket even when credentials are unchanged', () => { + const fs = makeModule('web'); + const previous = fs.socket; + previous.active = false; + fs.onAuthStateChanged(); + expect(fs.socket).not.toBe(previous); + expect(previous.disconnect).toHaveBeenCalledOnce(); + }); + + it('closes the socket on sign-out without opening an anonymous replacement', () => { + const fs = makeModule('web'); + const previous = fs.socket; + fs.puter.authToken = null; + fs.onAuthStateChanged(); + expect(previous.disconnect).toHaveBeenCalledOnce(); + expect(fs.socket).toBeNull(); + }); + + it('replaces an active socket when the API origin changes', () => { + const fs = makeModule('web'); + const previous = fs.socket; + previous.active = true; + fs.puter.APIOrigin = 'https://new-api.test'; + fs.onAuthStateChanged(); + expect(fs.socket).not.toBe(previous); + expect(previous.disconnect).toHaveBeenCalledOnce(); + }); + it('never opens a socket when the client opted out', () => { const fs = makeModule('web', false); diff --git a/src/puter-js/src/modules/FileSystem/operations/copy.js b/src/puter-js/src/modules/FileSystem/operations/copy.js index 3279729c8..3ae8f9e27 100644 --- a/src/puter-js/src/modules/FileSystem/operations/copy.js +++ b/src/puter-js/src/modules/FileSystem/operations/copy.js @@ -7,7 +7,8 @@ import { defineOperation, firstDefined } from './scaffold.js'; /** * Copies a file or directory to another location. Relative paths resolve * against the app's root directory; a UID-shaped string is read as a uid. When - * `destination` is a directory the item is copied into it under the same name. + * `destination` is a directory the item is copied into it under the same name; + * when it is an existing file, that file is replaced if `overwrite` is set. * * @type {{ * (options: CopyOptions): Promise, diff --git a/src/puter-js/src/modules/FileSystem/operations/scaffold.js b/src/puter-js/src/modules/FileSystem/operations/scaffold.js index 91fd9ebf2..b1895b86e 100644 --- a/src/puter-js/src/modules/FileSystem/operations/scaffold.js +++ b/src/puter-js/src/modules/FileSystem/operations/scaffold.js @@ -182,7 +182,12 @@ export async function fsRequest (spec) { } }, rejectWithPrompt); - xhr.send(body === undefined ? undefined : JSON.stringify(body)); + const buildBody = () => body === undefined ? undefined : JSON.stringify({ + ...body, + ...(Object.hasOwn(body, 'auth_token') ? { auth_token: this.authToken } : {}), + }); + if (xhr._puterReq) xhr._puterReq.buildBody = buildBody; + xhr.send(buildBody()); }); } diff --git a/src/puter-js/src/modules/UI.js b/src/puter-js/src/modules/UI.js index 52f1fdc0a..238725df7 100644 --- a/src/puter-js/src/modules/UI.js +++ b/src/puter-js/src/modules/UI.js @@ -669,6 +669,7 @@ export class UIModule extends EventListener { uuid: callback_id, }, '*'); const ret = await p; + if (ret?.$ === 'puter-ipc-error') throw ret.error; if ( callback ) callback(ret); return ret; }; @@ -2615,11 +2616,6 @@ export class UIModule extends EventListener { return puter.auth.signIn({ request_auth: true }).then( () => { settle(true); - if ( puter.onAuth && typeof puter.onAuth === 'function' ) { - puter.getUser().then((user) => { - puter.onAuth(user); - }); - } }, (err) => { settle(false); diff --git a/src/puter-js/src/modules/UI.listeners.test.js b/src/puter-js/src/modules/UI.listeners.test.js index b33af6a8d..982d80abe 100644 --- a/src/puter-js/src/modules/UI.listeners.test.js +++ b/src/puter-js/src/modules/UI.listeners.test.js @@ -134,6 +134,35 @@ describe('AppConnection', () => { }); describe('host replies (env: app)', () => { + it('rejects launchApp when the desktop replies with a handler error', async () => { + const ui = makeUI(); + const result = ui.launchApp('missing-app'); + const rejection = expect(result).rejects.toMatchObject({ + code: 'ipc_handler_failed', + }); + await vi.waitFor(() => expect(parentPostMessage).toHaveBeenCalled()); + const { uuid } = parentPostMessage.mock.calls.at(-1)[0]; + await deliver({ + source: parentWindow, + origin: 'https://puter.com', + data: { + $SCOPE, + id: uuid, + args: [ + { + $: 'puter-ipc-error', + error: { + code: 'ipc_handler_failed', + message: 'The request could not be completed.', + }, + }, + ], + }, + }); + await rejection; + expect(ui.util.rpc.callbackManager.callbacks.size).toBe(0); + }); + it('frees an IPC reply callback once the reply arrives', async () => { const ui = makeUI(); const { callbackManager } = ui.util.rpc;