mirror of
https://github.com/HeyPuter/puter.git
synced 2026-10-13 15:20:26 +00:00
Fix webdav locks issue and tighten GUI ipc rules for setWindow* (#4207)
This commit is contained in:
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`;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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 )
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user