diff --git a/src/backend/controllers/webdav/WebDAVController.test.ts b/src/backend/controllers/webdav/WebDAVController.test.ts index 162c687bf..42d505ec2 100644 --- a/src/backend/controllers/webdav/WebDAVController.test.ts +++ b/src/backend/controllers/webdav/WebDAVController.test.ts @@ -1163,6 +1163,64 @@ describe('WebDAVController', () => { const xml = cap2.body as string; expect(xml).toContain(lockToken); }); + + it('grants and reports the requested Timeout, on create and refresh', async () => { + const uniquePath = `/test/timeout-lock-${Date.now()}.txt`; + const actor = { + user: { id: 1, uuid: 'test-uuid', username: 'test' }, + }; + const { res: res1, captured: cap1 } = makeRes(); + await dispatchMiddleware( + makeReq({ + method: 'LOCK', + path: uniquePath, + headers: { timeout: 'Second-120' }, + actor, + }), + res1, + noop, + ); + expect(cap1.statusCode).toBe(200); + expect(cap1.body as string).toContain( + 'Second-120', + ); + const lockToken = cap1.headers['lock-token']!.replace(/[<>]/g, ''); + + const { res: res2, captured: cap2 } = makeRes(); + await dispatchMiddleware( + makeReq({ + method: 'LOCK', + path: uniquePath, + headers: { if: `(<${lockToken}>)`, timeout: 'Second-300' }, + actor, + }), + res2, + noop, + ); + expect(cap2.statusCode).toBe(200); + expect(cap2.body as string).toContain( + 'Second-300', + ); + }); + + it('reports the server max when no Timeout is requested', async () => { + const { res, captured } = makeRes(); + await dispatchMiddleware( + makeReq({ + method: 'LOCK', + path: `/test/default-timeout-${Date.now()}.txt`, + actor: { + user: { id: 1, uuid: 'test-uuid', username: 'test' }, + }, + }), + res, + noop, + ); + expect(captured.statusCode).toBe(200); + expect(captured.body as string).toContain( + 'Second-3600', + ); + }); }); describe('error handling', () => { diff --git a/src/backend/controllers/webdav/WebDAVController.ts b/src/backend/controllers/webdav/WebDAVController.ts index 02b1c8eb4..90d9dd494 100644 --- a/src/backend/controllers/webdav/WebDAVController.ts +++ b/src/backend/controllers/webdav/WebDAVController.ts @@ -67,6 +67,7 @@ import { getFileLocks, getLockIfValid, hasWritePermission, + parseLockTimeout, refreshLock, } from './locks.js'; import { @@ -1024,6 +1025,10 @@ export class WebDAVController extends PuterController { // and block writes for everyone else. await this.#assertWrite(actor, davPath); + const timeoutSeconds = parseLockTimeout( + req.headers.timeout as string | undefined, + ); + // Refresh existing lock if (headerToken) { const existing = await getLockIfValid(r, headerToken); @@ -1031,14 +1036,19 @@ export class WebDAVController extends PuterController { throw new HttpError(412, 'Lock token not found', { legacyCode: 'conflict', }); - await refreshLock(r, headerToken); + await refreshLock(r, headerToken, timeoutSeconds); res.status(200) .set({ 'Content-Type': 'application/xml; charset=utf-8', ...DAV_HEADERS, }) .send( - lockResponseXml(headerToken, davPath, existing.lockScope), + lockResponseXml( + headerToken, + davPath, + existing.lockScope, + timeoutSeconds, + ), ); return; } @@ -1062,7 +1072,7 @@ export class WebDAVController extends PuterController { } } - const token = await createLock(r, davPath, lockScope); + const token = await createLock(r, davPath, lockScope, timeoutSeconds); const status = 200; res.status(status) @@ -1071,7 +1081,7 @@ export class WebDAVController extends PuterController { 'Lock-Token': `<${token}>`, ...DAV_HEADERS, }) - .send(lockResponseXml(token, davPath, lockScope)); + .send(lockResponseXml(token, davPath, lockScope, timeoutSeconds)); } // -- UNLOCK ------------------------------------------------------ @@ -1382,6 +1392,7 @@ function lockResponseXml( token: string, path: string, scope: 'exclusive' | 'shared', + timeoutSeconds: number, ): string { return ` @@ -1391,7 +1402,7 @@ function lockResponseXml( 0 webdav-user - Second-7200 + Second-${timeoutSeconds} ${escapeXml(token)} ${escapeXml(encodeURI(path))} diff --git a/src/backend/controllers/webdav/locks.test.ts b/src/backend/controllers/webdav/locks.test.ts index 91d4cacd4..882242316 100644 --- a/src/backend/controllers/webdav/locks.test.ts +++ b/src/backend/controllers/webdav/locks.test.ts @@ -28,6 +28,8 @@ import { getFileLocks, getLockIfValid, hasWritePermission, + MAX_LOCK_TIMEOUT_SECONDS, + parseLockTimeout, refreshLock, } from './locks.js'; @@ -69,6 +71,32 @@ describe('extractLockToken', () => { }); }); +describe('parseLockTimeout', () => { + it('grants the requested Second-N', () => { + expect(parseLockTimeout('Second-60')).toBe(60); + }); + + it('uses the first Second-N entry after Infinite', () => { + expect(parseLockTimeout('Infinite, Second-120')).toBe(120); + }); + + it('caps requests above the max', () => { + expect(parseLockTimeout('Second-4100000000')).toBe( + MAX_LOCK_TIMEOUT_SECONDS, + ); + }); + + it('grants at least one second', () => { + expect(parseLockTimeout('Second-0')).toBe(1); + }); + + it('falls back to the max for missing, Infinite or malformed headers', () => { + expect(parseLockTimeout(undefined)).toBe(MAX_LOCK_TIMEOUT_SECONDS); + expect(parseLockTimeout('Infinite')).toBe(MAX_LOCK_TIMEOUT_SECONDS); + expect(parseLockTimeout('Second-abc')).toBe(MAX_LOCK_TIMEOUT_SECONDS); + }); +}); + describe('createLock / getLockIfValid', () => { it('mints a urn:uuid token that resolves back to its path and scope', async () => { const path = uniquePath('a.txt'); @@ -110,6 +138,49 @@ describe('createLock / getLockIfValid', () => { }); }); +describe('lock timeouts', () => { + it('expires the token key after the granted timeout', async () => { + const token = await createLock( + redis, + uniquePath('ttl.txt'), + 'exclusive', + 120, + ); + const ttl = await redis.ttl(`dav:lock:${token}`); + expect(ttl).toBeGreaterThan(110); + expect(ttl).toBeLessThanOrEqual(120); + }); + + it('refresh extends the token and path keys to the new timeout', async () => { + const path = uniquePath('ttl-refresh.txt'); + const token = await createLock(redis, path, 'exclusive', 30); + await refreshLock(redis, token, 600); + expect(await redis.ttl(`dav:lock:${token}`)).toBeGreaterThan(590); + expect(await redis.ttl(`dav:locks:${path}`)).toBeGreaterThan(590); + }); + + it('a shorter sibling lock does not shorten the path map', async () => { + const path = uniquePath('ttl-siblings.txt'); + await createLock(redis, path, 'shared', 600); + await createLock(redis, path, 'shared', 5); + expect(await redis.ttl(`dav:locks:${path}`)).toBeGreaterThan(590); + }); + + it('ignores path-map entries that have expired', async () => { + const path = uniquePath('ttl-stale-entry.txt'); + const live = await createLock(redis, path, 'shared', 600); + const raw = JSON.parse((await redis.get(`dav:locks:${path}`))!); + raw['urn:uuid:22222222-2222-2222-2222-222222222222'] = { + lockScope: 'exclusive', + lockType: 'write', + expiresAt: Date.now() - 1000, + }; + await redis.set(`dav:locks:${path}`, JSON.stringify(raw)); + const locks = await getFileLocks(redis, path); + expect(locks.map((l) => l.lockToken)).toEqual([live]); + }); +}); + describe('getFileLocks inheritance', () => { it('reports a lock held on an ancestor directory', async () => { const dir = uniquePath('dir'); @@ -214,6 +285,22 @@ describe('hasWritePermission', () => { expect(await hasWritePermission(redis, locked, elsewhere)).toBe(false); }); + it('lets a directory lock cover its descendants', async () => { + const dir = uniquePath('covered'); + const token = await createLock(redis, dir, 'exclusive'); + expect(await hasWritePermission(redis, `${dir}/c.txt`, token)).toBe( + true, + ); + }); + + it('does not let a lock cover a sibling sharing its name prefix', async () => { + const dir = uniquePath('prefix'); + const sibling = `${dir}c`; + await createLock(redis, sibling, 'shared'); + const token = await createLock(redis, dir, 'shared'); + expect(await hasWritePermission(redis, sibling, token)).toBe(false); + }); + it("denies when another holder's exclusive lock also covers the path", async () => { const dir = uniquePath('conflict'); const filePath = `${dir}/file.txt`; diff --git a/src/backend/controllers/webdav/locks.ts b/src/backend/controllers/webdav/locks.ts index 2868eb870..1eb65aa61 100644 --- a/src/backend/controllers/webdav/locks.ts +++ b/src/backend/controllers/webdav/locks.ts @@ -26,12 +26,13 @@ import type { Cluster } from 'ioredis'; * * Two key families: `dav:lock:` → JSON `{ path, lockScope, lockType }` * (per-token metadata) `dav:locks:` → JSON `{ : { lockScope, - * lockType }, ... }` (per-path map) + * lockType, expiresAt }, ... }` (per-path map) * - * Both keys share the same TTL so they expire together. + * Each token key expires with its own lock; the path map lives as long as its + * longest-lived entry, and expired entries in it are ignored. */ -const DAV_LOCK_TTL_SECONDS = 30; +export const MAX_LOCK_TIMEOUT_SECONDS = 3600; const LOCK_PREFIX = 'dav:lock:'; const LOCKS_PREFIX = 'dav:locks:'; @@ -42,32 +43,51 @@ export interface LockInfo { path: string; } +interface PathLockEntry { + lockScope: 'exclusive' | 'shared'; + lockType: 'write'; + /** Epoch ms. */ + expiresAt: number; +} + +type PathLockMap = Record; + +/** + * Lock timeout to grant for a `Timeout` request header (RFC 4918 §10.7): the + * first `Second-N` entry, capped at the max. `Infinite` or no usable entry gets + * the max. + */ +export function parseLockTimeout(header: string | undefined): number { + for (const part of (header ?? '').split(',')) { + const match = part.trim().match(/^Second-(\d+)$/i); + if (!match) continue; + const seconds = Number(match[1]); + return Math.min(Math.max(seconds, 1), MAX_LOCK_TIMEOUT_SECONDS); + } + return MAX_LOCK_TIMEOUT_SECONDS; +} + export async function createLock( redis: Cluster, filePath: string, lockScope: 'exclusive' | 'shared', - lockType: 'write' = 'write', + timeoutSeconds: number = MAX_LOCK_TIMEOUT_SECONDS, ): Promise { const lockToken = `urn:uuid:${randomUUID()}`; + const lockType = 'write'; const meta = JSON.stringify({ path: filePath, lockScope, lockType }); // Per-token metadata - await redis.set( - `${LOCK_PREFIX}${lockToken}`, - meta, - 'EX', - DAV_LOCK_TTL_SECONDS, - ); + await redis.set(`${LOCK_PREFIX}${lockToken}`, meta, 'EX', timeoutSeconds); // Per-path map — merge with any existing locks on this path - const existing = await getPathLockMap(redis, filePath); - existing[lockToken] = { lockScope, lockType }; - await redis.set( - `${LOCKS_PREFIX}${filePath}`, - JSON.stringify(existing), - 'EX', - DAV_LOCK_TTL_SECONDS, - ); + const pathMap = await getPathLockMap(redis, filePath); + pathMap[lockToken] = { + lockScope, + lockType, + expiresAt: Date.now() + timeoutSeconds * 1000, + }; + await writePathLockMap(redis, filePath, pathMap); return lockToken; } @@ -81,16 +101,7 @@ export async function deleteLock( const meta = JSON.parse(raw) as { path: string }; const pathMap = await getPathLockMap(redis, meta.path); delete pathMap[lockToken]; - if (Object.keys(pathMap).length === 0) { - await redis.del(`${LOCKS_PREFIX}${meta.path}`); - } else { - await redis.set( - `${LOCKS_PREFIX}${meta.path}`, - JSON.stringify(pathMap), - 'EX', - DAV_LOCK_TTL_SECONDS, - ); - } + await writePathLockMap(redis, meta.path, pathMap); } await redis.del(`${LOCK_PREFIX}${lockToken}`); } @@ -98,28 +109,27 @@ export async function deleteLock( export async function refreshLock( redis: Cluster, lockToken: string, + timeoutSeconds: number = MAX_LOCK_TIMEOUT_SECONDS, ): Promise { const raw = await redis.get(`${LOCK_PREFIX}${lockToken}`); if (!raw) return false; - const meta = JSON.parse(raw) as { path: string }; + const meta = JSON.parse(raw) as { + path: string; + lockScope: 'exclusive' | 'shared'; + lockType: 'write'; + }; - // Re-set with fresh TTL - await redis.set( - `${LOCK_PREFIX}${lockToken}`, - raw, - 'EX', - DAV_LOCK_TTL_SECONDS, - ); - // Refresh the path map TTL too - const pathRaw = await redis.get(`${LOCKS_PREFIX}${meta.path}`); - if (pathRaw) { - await redis.set( - `${LOCKS_PREFIX}${meta.path}`, - pathRaw, - 'EX', - DAV_LOCK_TTL_SECONDS, - ); - } + await redis.set(`${LOCK_PREFIX}${lockToken}`, raw, 'EX', timeoutSeconds); + + // Re-add the entry even if the path map expired, so the lock keeps + // blocking other writers. + const pathMap = await getPathLockMap(redis, meta.path); + pathMap[lockToken] = { + lockScope: meta.lockScope, + lockType: meta.lockType, + expiresAt: Date.now() + timeoutSeconds * 1000, + }; + await writePathLockMap(redis, meta.path, pathMap); return true; } @@ -139,9 +149,8 @@ export async function getFileLocks( for (const [token, info] of Object.entries(map)) { results.push({ lockToken: token, - lockScope: (info as { lockScope: 'exclusive' | 'shared' }) - .lockScope, - lockType: (info as { lockType: 'write' }).lockType, + lockScope: info.lockScope, + lockType: info.lockType, path: current, }); } @@ -184,9 +193,7 @@ export async function hasWritePermission( if (!myLock) return false; // token expired or invalid // Token's path must match or be an ancestor of the target - if (!filePath.startsWith(myLock.path) && myLock.path !== filePath) { - return false; - } + if (!isSameOrDescendant(filePath, myLock.path)) return false; // Check lock scope rules for (const lock of locks) { @@ -198,20 +205,51 @@ export async function hasWritePermission( // -- Internals ------------------------------------------------------- +function isSameOrDescendant(filePath: string, ancestor: string): boolean { + if (filePath === ancestor) return true; + const prefix = ancestor.endsWith('/') ? ancestor : `${ancestor}/`; + return filePath.startsWith(prefix); +} + +/** The path's lock map with already-expired entries dropped. */ async function getPathLockMap( redis: Cluster, filePath: string, -): Promise> { +): Promise { const raw = await redis.get(`${LOCKS_PREFIX}${filePath}`); if (!raw) return {}; + let map: PathLockMap; try { - return JSON.parse(raw) as Record< - string, - { lockScope: string; lockType: string } - >; + map = JSON.parse(raw) as PathLockMap; } catch { return {}; } + const now = Date.now(); + for (const [token, entry] of Object.entries(map)) { + // Entries without expiresAt predate per-entry expiry; refreshLock + // re-adds live ones. + if (!(entry.expiresAt > now)) { + delete map[token]; + } + } + return map; +} + +/** Store the map with a TTL covering its longest-lived entry, or drop it. */ +async function writePathLockMap( + redis: Cluster, + filePath: string, + map: PathLockMap, +): Promise { + const key = `${LOCKS_PREFIX}${filePath}`; + if (Object.keys(map).length === 0) { + await redis.del(key); + return; + } + const now = Date.now(); + const latest = Math.max(...Object.values(map).map((e) => e.expiresAt)); + const ttlSeconds = Math.max(1, Math.ceil((latest - now) / 1000)); + await redis.set(key, JSON.stringify(map), 'EX', ttlSeconds); } /** diff --git a/src/docs/src/UI/setWindowHeight.md b/src/docs/src/UI/setWindowHeight.md index a4054e70e..25cdd23e3 100755 --- a/src/docs/src/UI/setWindowHeight.md +++ b/src/docs/src/UI/setWindowHeight.md @@ -18,7 +18,7 @@ puter.ui.setWindowHeight(height, window_id) The new height for this window. Must be a positive number. Minimum height is 200px, if a value less than 200 is provided, the height will be set to 200px. #### `window_id` (optional) -Targets a specific window other than the app's main window. Accepts either a window id string or a window handle returned by [`puter.ui.createWindow()`](/UI/createWindow/) (an object with an `id` property). When omitted, the app's main window is used. +Targets a specific window other than the app's main window. Accepts either a window id string or a window handle returned by [`puter.ui.createWindow()`](/UI/createWindow/) (an object with an `id` property). The window must be the app's own or one it created; any other id is ignored. When omitted, the app's main window is used. ## Examples diff --git a/src/docs/src/UI/setWindowPosition.md b/src/docs/src/UI/setWindowPosition.md index 8c54aab67..8ae027660 100755 --- a/src/docs/src/UI/setWindowPosition.md +++ b/src/docs/src/UI/setWindowPosition.md @@ -21,7 +21,7 @@ The new x position for this window. Must be a positive number. The new y position for this window. Must be a positive number. #### `window_id` (optional) -Targets a specific window other than the app's main window. Accepts either a window id string or a window handle returned by [`puter.ui.createWindow()`](/UI/createWindow/) (an object with an `id` property). When omitted, the app's main window is used. +Targets a specific window other than the app's main window. Accepts either a window id string or a window handle returned by [`puter.ui.createWindow()`](/UI/createWindow/) (an object with an `id` property). The window must be the app's own or one it created; any other id is ignored. When omitted, the app's main window is used. ## Examples diff --git a/src/docs/src/UI/setWindowSize.md b/src/docs/src/UI/setWindowSize.md index 3b661a15d..76e1e7216 100755 --- a/src/docs/src/UI/setWindowSize.md +++ b/src/docs/src/UI/setWindowSize.md @@ -21,7 +21,7 @@ The new width for this window. Must be a positive number. Minimum width is 200px The new height for this window. Must be a positive number. Minimum height is 200px, if a value less than 200 is provided, the height will be set to 200px. #### `window_id` (optional) -Targets a specific window other than the app's main window. Accepts either a window id string or a window handle returned by [`puter.ui.createWindow()`](/UI/createWindow/) (an object with an `id` property). When omitted, the app's main window is used. +Targets a specific window other than the app's main window. Accepts either a window id string or a window handle returned by [`puter.ui.createWindow()`](/UI/createWindow/) (an object with an `id` property). The window must be the app's own or one it created; any other id is ignored. When omitted, the app's main window is used. ## Examples diff --git a/src/docs/src/UI/setWindowTitle.md b/src/docs/src/UI/setWindowTitle.md index f1048e31e..34b31f77b 100755 --- a/src/docs/src/UI/setWindowTitle.md +++ b/src/docs/src/UI/setWindowTitle.md @@ -18,7 +18,7 @@ puter.ui.setWindowTitle(title, window_id) The new title for this window. #### `window_id` (optional) -Targets a specific window other than the app's main window. Accepts either a window id string or a window handle returned by [`puter.ui.createWindow()`](/UI/createWindow/) (an object with an `id` property). When omitted, the app's main window is used. +Targets a specific window other than the app's main window. Accepts either a window id string or a window handle returned by [`puter.ui.createWindow()`](/UI/createWindow/) (an object with an `id` property). The window must be the app's own or one it created; any other id is ignored. When omitted, the app's main window is used. ## Examples diff --git a/src/docs/src/UI/setWindowWidth.md b/src/docs/src/UI/setWindowWidth.md index 15f1cda60..dd6975cd5 100755 --- a/src/docs/src/UI/setWindowWidth.md +++ b/src/docs/src/UI/setWindowWidth.md @@ -18,7 +18,7 @@ puter.ui.setWindowWidth(width, window_id) The new width for this window. Must be a positive number. Minimum width is 200px, if a value less than 200 is provided, the width will be set to 200px. #### `window_id` (optional) -Targets a specific window other than the app's main window. Accepts either a window id string or a window handle returned by [`puter.ui.createWindow()`](/UI/createWindow/) (an object with an `id` property). When omitted, the app's main window is used. +Targets a specific window other than the app's main window. Accepts either a window id string or a window handle returned by [`puter.ui.createWindow()`](/UI/createWindow/) (an object with an `id` property). The window must be the app's own or one it created; any other id is ignored. When omitted, the app's main window is used. ## Examples diff --git a/src/docs/src/rate-limits-and-quotas.md b/src/docs/src/rate-limits-and-quotas.md index 79b894cc2..a6708cd72 100644 --- a/src/docs/src/rate-limits-and-quotas.md +++ b/src/docs/src/rate-limits-and-quotas.md @@ -246,9 +246,12 @@ The last two apply to an `fs:` permission request for a path that doesn't exist | Concurrent requests | 10 | Per network | | Failed sign-ins | 10 per 15 min | Per account being signed into | | Failed sign-ins | 50 per 15 min | Per network | +| Lock timeout | Up to 1 hour | Per lock | Successful sign-ins don't count. Over either sign-in limit, `dav` answers `429` until the window passes, even for the right password, so a client retrying a stale password keeps itself locked out: fix the password, then wait. This only affects `dav`; the desktop, the API and `puter.auth` keep working. +A `LOCK` gets the `Timeout` the client asks for, up to 1 hour, and the response states the timeout granted. Refresh the lock before it runs out to keep it. + For long-lived mounts, sign in with a `-token` username and an API token as the password. That skips the per-account sign-in limit, and the token can be revoked from the dashboard without changing the account password. ### Sites and workers diff --git a/src/gui/src/IPC.js b/src/gui/src/IPC.js index eff620dcb..703d39284 100644 --- a/src/gui/src/IPC.js +++ b/src/gui/src/IPC.js @@ -38,6 +38,7 @@ import UIWindowSignup from './UI/UIWindowSignup.js'; import UINotification from './UI/UINotification.js'; import { renewGodmodeToken } from './helpers/godmodeTokens.js'; +import { ipc_target_window } from './helpers/ipcTargetWindow.js'; import { openVerificationGateWindow } from './helpers/verification_gates.js'; import { PROCESS_IPC_ATTACHED } from './definitions.js'; import TeePromise from './util/TeePromise.js'; @@ -663,17 +664,7 @@ const ipc_listener = async (event, handled) => { // setWindowTitle //-------------------------------------------------------- else if ( event.data.msg === 'setWindowTitle' && event.data.new_title !== undefined ) { - let el_window; - // specific window - if ( event.data.window_id ) - { - el_window = $(`.window[data-element_uuid="${html_encode(event.data.window_id)}"]`); - } - // app window - else - { - el_window = window.window_for_app_instance(event.data.appInstanceID); - } + const el_window = ipc_target_window(event.data.appInstanceID, event.data.window_id); // window not found if ( !el_window || el_window.length === 0 ) @@ -1014,17 +1005,7 @@ const ipc_listener = async (event, handled) => { // setWindowWidth //-------------------------------------------------------- else if ( event.data.msg === 'setWindowWidth' && event.data.width !== undefined ) { - let el_window; - // specific window - if ( event.data.window_id ) - { - el_window = $(`.window[data-element_uuid="${html_encode(event.data.window_id)}"]`); - } - // app window - else - { - el_window = window.window_for_app_instance(event.data.appInstanceID); - } + const el_window = ipc_target_window(event.data.appInstanceID, event.data.window_id); // window not found if ( !el_window || el_window.length === 0 ) @@ -1049,17 +1030,7 @@ const ipc_listener = async (event, handled) => { // setWindowHeight //-------------------------------------------------------- else if ( event.data.msg === 'setWindowHeight' && event.data.height !== undefined ) { - let el_window; - // specific window - if ( event.data.window_id ) - { - el_window = $(`.window[data-element_uuid="${html_encode(event.data.window_id)}"]`); - } - // app window - else - { - el_window = window.window_for_app_instance(event.data.appInstanceID); - } + const el_window = ipc_target_window(event.data.appInstanceID, event.data.window_id); // window not found if ( !el_window || el_window.length === 0 ) @@ -1086,17 +1057,7 @@ const ipc_listener = async (event, handled) => { // setWindowSize //-------------------------------------------------------- else if ( event.data.msg === 'setWindowSize' && (event.data.width !== undefined || event.data.height !== undefined) ) { - let el_window; - // specific window - if ( event.data.window_id ) - { - el_window = $(`.window[data-element_uuid="${html_encode(event.data.window_id)}"]`); - } - // app window - else - { - el_window = window.window_for_app_instance(event.data.appInstanceID); - } + const el_window = ipc_target_window(event.data.appInstanceID, event.data.window_id); // window not found if ( !el_window || el_window.length === 0 ) @@ -1134,17 +1095,7 @@ const ipc_listener = async (event, handled) => { // setWindowPosition //-------------------------------------------------------- else if ( event.data.msg === 'setWindowPosition' && (event.data.x !== undefined || event.data.y !== undefined) ) { - let el_window; - // specific window - if ( event.data.window_id ) - { - el_window = $(`.window[data-element_uuid="${html_encode(event.data.window_id)}"]`); - } - // app window - else - { - el_window = window.window_for_app_instance(event.data.appInstanceID); - } + const el_window = ipc_target_window(event.data.appInstanceID, event.data.window_id); // window not found if ( !el_window || el_window.length === 0 ) @@ -1194,17 +1145,7 @@ const ipc_listener = async (event, handled) => { // setWindowX //-------------------------------------------------------- else if ( event.data.msg === 'setWindowX' && (event.data.x !== undefined) ) { - let el_window; - // specific window - if ( event.data.window_id ) - { - el_window = $(`.window[data-element_uuid="${html_encode(event.data.window_id)}"]`); - } - // app window - else - { - el_window = window.window_for_app_instance(event.data.appInstanceID); - } + const el_window = ipc_target_window(event.data.appInstanceID, event.data.window_id); // window not found if ( !el_window || el_window.length === 0 ) @@ -1238,17 +1179,7 @@ const ipc_listener = async (event, handled) => { // setWindowY //-------------------------------------------------------- else if ( event.data.msg === 'setWindowY' && (event.data.y !== undefined) ) { - let el_window; - // specific window - if ( event.data.window_id ) - { - el_window = $(`.window[data-element_uuid="${html_encode(event.data.window_id)}"]`); - } - // app window - else - { - el_window = window.window_for_app_instance(event.data.appInstanceID); - } + const el_window = ipc_target_window(event.data.appInstanceID, event.data.window_id); // window not found if ( !el_window || el_window.length === 0 ) diff --git a/src/gui/src/helpers/ipcTargetWindow.js b/src/gui/src/helpers/ipcTargetWindow.js new file mode 100644 index 000000000..320414660 --- /dev/null +++ b/src/gui/src/helpers/ipcTargetWindow.js @@ -0,0 +1,37 @@ +/* + * 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 . + */ + +/** + * The window an app's IPC call may act on: its own window when no + * `window_id` is given, otherwise only a window it created + * (`data-parent_uuid`). Other apps' windows, including apps it launched, + * resolve to null. + * + * @param {string} app_instance_id validated instance id of the calling app + * @param {string | undefined} window_id + * @returns {HTMLElement | null} + */ +export const ipc_target_window = (app_instance_id, window_id) => { + if ( !window_id || window_id === app_instance_id ) { + return document.querySelector(`.window[data-element_uuid="${app_instance_id}"]`); + } + // window_id is untrusted, so it is compared rather than put in a selector. + const owned = document.querySelectorAll(`.window[data-parent_uuid="${app_instance_id}"]`); + return [...owned].find(el => el.getAttribute('data-element_uuid') === window_id) ?? null; +}; diff --git a/src/gui/src/helpers/ipcTargetWindow.test.js b/src/gui/src/helpers/ipcTargetWindow.test.js new file mode 100644 index 000000000..b7f8432f1 --- /dev/null +++ b/src/gui/src/helpers/ipcTargetWindow.test.js @@ -0,0 +1,72 @@ +/* + * 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 . + */ + +// @vitest-environment jsdom + +import { afterEach, describe, expect, it } from 'vitest'; +import { ipc_target_window } from './ipcTargetWindow.js'; + +const add_window = (element_uuid, attrs = {}) => { + const el = document.createElement('div'); + el.className = 'window'; + el.setAttribute('data-element_uuid', element_uuid); + for ( const [name, value] of Object.entries(attrs) ) { + el.setAttribute(`data-${name}`, value); + } + document.body.appendChild(el); + return el; +}; + +afterEach(() => { + document.body.innerHTML = ''; +}); + +describe('ipc_target_window', () => { + it("returns the caller's own window when no window_id is given", () => { + const own = add_window('app-1'); + expect(ipc_target_window('app-1', undefined)).toBe(own); + expect(ipc_target_window('app-1', 'app-1')).toBe(own); + }); + + it('returns a window the caller created', () => { + add_window('app-1'); + const created = add_window('win-1', { parent_uuid: 'app-1' }); + expect(ipc_target_window('app-1', 'win-1')).toBe(created); + }); + + it('rejects the window of an app the caller launched', () => { + add_window('app-1'); + add_window('child-1', { parent_uuid: 'null', parent_instance_id: 'app-1' }); + expect(ipc_target_window('app-1', 'child-1')).toBeNull(); + }); + + it("rejects another app's window and its created windows", () => { + add_window('app-1'); + add_window('app-2'); + add_window('win-2', { parent_uuid: 'app-2' }); + expect(ipc_target_window('app-1', 'app-2')).toBeNull(); + expect(ipc_target_window('app-1', 'win-2')).toBeNull(); + }); + + it('returns null for an unknown or selector-shaped window_id', () => { + add_window('app-1'); + expect(ipc_target_window('app-1', 'missing')).toBeNull(); + expect(ipc_target_window('app-1', '"], .window, [x="')).toBeNull(); + }); +}); diff --git a/src/puter-js/tests/e2e/fixtures/window-ownership.html b/src/puter-js/tests/e2e/fixtures/window-ownership.html new file mode 100644 index 000000000..cc35a8fea --- /dev/null +++ b/src/puter-js/tests/e2e/fixtures/window-ownership.html @@ -0,0 +1,101 @@ + + + + + puter-js test fixture: window ownership + + + +

puter-js window ownership fixture

+

Loading puter.js…

+ + + + + +
+ + + + + + diff --git a/src/puter-js/tests/e2e/specs/windowOwnership.spec.js b/src/puter-js/tests/e2e/specs/windowOwnership.spec.js new file mode 100644 index 000000000..09d8a9805 --- /dev/null +++ b/src/puter-js/tests/e2e/specs/windowOwnership.spec.js @@ -0,0 +1,70 @@ +import { test, expect } from '@playwright/test'; +import { registerTestApp, deleteTestApp, gotoTestApp, FIXTURE_URL } from '../helpers/testApp.js'; + +const WINDOW_OWNERSHIP_FIXTURE_URL = FIXTURE_URL.replace( + 'menubar-contextmenu.html', + 'window-ownership.html', +); + +const entry = (log, value) => log.locator(`[data-entry="${value}"]`); + +// Desktop windows show the title in their head; headless dashboard windows +// show it in the control drawer. +const titleOf = (win) => win.evaluate(el => ( + el.querySelector('.window-head-title, .dashboard-app-drawer-title')?.textContent ?? null +)); + +const logValue = async (log, prefix) => { + const text = await log.locator(`[data-entry^="${prefix}"]`).first().textContent(); + return text.slice(prefix.length); +}; + +test.describe('setWindowTitle / setWindow* window ownership (env=app)', () => { + let appName; + let appFrame; + let log; + + test.beforeEach(async ({ page }) => { + appName = await registerTestApp(page, { fixtureURL: WINDOW_OWNERSHIP_FIXTURE_URL }); + await gotoTestApp(page, appName); + // The fixture's own window is the first one open; created windows and + // launched children add iframes after it. + appFrame = page.frameLocator('iframe.window-app-iframe').first(); + log = appFrame.locator('#log'); + }); + + test.afterEach(async ({ page }) => { + await deleteTestApp(page, appName); + }); + + test("an app can still retitle and resize its own window", async ({ page }) => { + await appFrame.locator('#own').click(); + await expect(entry(log, 'own-title:replied')).toBeAttached(); + await expect(entry(log, 'own-size:replied')).toBeAttached(); + + const own = page.locator(`.window[data-app="${appName}"]`).first(); + await expect.poll(() => titleOf(own)).toBe('own-retitled'); + }); + + test('an app can retitle and move a window it created', async ({ page }) => { + await appFrame.locator('#created').click(); + await expect(entry(log, 'created-title:replied')).toBeAttached(); + await expect(entry(log, 'created-position:replied')).toBeAttached(); + + const id = await logValue(log, 'created-id:'); + const created = page.locator(`.window[data-element_uuid="${id}"]`); + await expect.poll(() => titleOf(created)).toBe('created-retitled'); + }); + + test('an app cannot retitle or resize an app it launched', async ({ page }) => { + await appFrame.locator('#child').click(); + await expect(entry(log, 'child-title:no-reply')).toBeAttached({ timeout: 30_000 }); + await expect(entry(log, 'child-width:no-reply')).toBeAttached(); + + const id = await logValue(log, 'child-id:'); + const child = page.locator(`.window[data-element_uuid="${id}"]`); + await expect(child).toHaveCount(1); + expect(await titleOf(child)).not.toBe('hijacked'); + expect(await child.evaluate(el => el.style.width)).not.toBe('201px'); + }); +});