Fix webdav locks issue and tighten GUI ipc rules for setWindow* (#4207)
Backend Tests / changes (push) Waiting to run
Maintain Release Merge PR / update-release-pr (push) Waiting to run
Notify HeyPuter / notify (push) Waiting to run
release-please / release-please (push) Waiting to run

This commit is contained in:
Neal Shah authored and GitHub committed 2026-10-12 17:39:09 -04:00
1 parent 542ca9ee1c
commit b8bb6292d6
15 files changed
+552 -144

No files matched your search

@@ -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(
'<D:timeout>Second-120</D:timeout>',
);
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(
'<D:timeout>Second-300</D:timeout>',
);
});
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(
'<D:timeout>Second-3600</D:timeout>',
);
});
});
describe('error handling', () => {
@@ -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 `<?xml version="1.0" encoding="utf-8"?>
<D:prop xmlns:D="DAV:">
@@ -1391,7 +1402,7 @@ function lockResponseXml(
<D:lockscope><D:${scope}/></D:lockscope>
<D:depth>0</D:depth>
<D:owner><D:href>webdav-user</D:href></D:owner>
<D:timeout>Second-7200</D:timeout>
<D:timeout>Second-${timeoutSeconds}</D:timeout>
<D:locktoken><D:href>${escapeXml(token)}</D:href></D:locktoken>
<D:lockroot><D:href>${escapeXml(encodeURI(path))}</D:href></D:lockroot>
</D:activelock>
@@ -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`;
+95 -57
View File
@@ -26,12 +26,13 @@ import type { Cluster } from 'ioredis';
*
* Two key families: `dav:lock:<token>` → JSON `{ path, lockScope, lockType }`
* (per-token metadata) `dav:locks:<path>` → JSON `{ <token>: { 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<string, PathLockEntry>;
/**
* 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<string> {
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<boolean> {
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<Record<string, { lockScope: string; lockType: string }>> {
): Promise<PathLockMap> {
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<void> {
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);
}
/**
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+3
View File
@@ -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
+8 -77
View File
@@ -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 )
+37
View File
@@ -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 <https://www.gnu.org/licenses/>.
*/
/**
* 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;
};
@@ -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 <https://www.gnu.org/licenses/>.
*/
// @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();
});
});
@@ -0,0 +1,101 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>puter-js test fixture: window ownership</title>
<style>
body { font-family: system-ui, sans-serif; padding: 16px; }
button { padding: 8px 12px; margin-right: 8px; font-size: 14px; }
#log { margin-top: 16px; padding: 8px; background: #f5f5f5; border: 1px solid #ddd; min-height: 60px; font-family: monospace; white-space: pre-wrap; }
.ready #status { color: green; }
</style>
</head>
<body>
<h1>puter-js window ownership fixture</h1>
<p id="status">Loading puter.js…</p>
<button id="own" disabled>Retitle and resize own window</button>
<button id="created" disabled>Retitle and move a created window</button>
<button id="child" disabled>Try to retitle and resize a launched child</button>
<div id="log" data-testid="log"></div>
<script>
window.PUTER_ORIGIN = 'http://puter.localhost:4100';
window.PUTER_API_ORIGIN = 'http://api.puter.localhost:4100';
</script>
<script src="/dist/puter.dev.js"></script>
<script>
const logEl = document.getElementById('log');
const statusEl = document.getElementById('status');
function log(entry) {
const line = document.createElement('div');
line.textContent = entry;
line.dataset.entry = entry;
logEl.appendChild(line);
}
// The desktop doesn't answer a call it refuses, so a refused call
// shows up as no reply.
const replyOrTimeout = (promise) => Promise.race([
promise.then(() => 'replied'),
new Promise(resolve => setTimeout(() => resolve('no-reply'), 2000)),
]);
function bind(id, fn) {
const button = document.getElementById(id);
button.addEventListener('click', async () => {
try {
await fn();
} catch (e) {
log(`error:${e?.message ?? e}`);
}
});
button.disabled = false;
}
function runAsParent() {
bind('own', async () => {
log(`own-title:${await replyOrTimeout(puter.ui.setWindowTitle('own-retitled'))}`);
log(`own-size:${await replyOrTimeout(puter.ui.setWindowSize(640, 480))}`);
});
bind('created', async () => {
const win = await puter.ui.createWindow({ title: 'created-original', content: '<p>created</p>' });
log(`created-id:${win.id}`);
log(`created-title:${await replyOrTimeout(puter.ui.setWindowTitle('created-retitled', win))}`);
log(`created-position:${await replyOrTimeout(puter.ui.setWindowPosition(50, 60, win))}`);
});
bind('child', async () => {
// The desktop tells the parent its child's real window id in
// childAppLaunched; that id is what must not be usable here.
const launched = new Promise(resolve => window.addEventListener('message', e => {
if ( e.data?.msg === 'childAppLaunched' ) resolve(e.data.child_instance_id);
}));
const child = await puter.ui.launchApp();
await new Promise(resolve => child.on('message', msg => msg?.kind === 'ready' && resolve()));
const childWindowId = await launched;
log(`child-id:${childWindowId}`);
log(`child-title:${await replyOrTimeout(puter.ui.setWindowTitle('hijacked', childWindowId))}`);
log(`child-width:${await replyOrTimeout(puter.ui.setWindowWidth(201, childWindowId))}`);
});
}
function ready() {
document.body.classList.add('ready');
const parent = puter.ui.parentApp();
statusEl.textContent = `Ready (role=${parent ? 'child' : 'parent'})`;
if (parent) parent.postMessage({ kind: 'ready' });
else runAsParent();
}
function waitForPuter() {
if (window.puter && puter.ui) ready();
else setTimeout(waitForPuter, 50);
}
waitForPuter();
</script>
</body>
</html>
@@ -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');
});
});