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(
0webdav-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');
+ });
+});