mirror of
https://github.com/HeyPuter/puter.git
synced 2026-11-13 20:12:57 -10:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
84b82e59ae |
No files matched your search
@@ -21,6 +21,7 @@ import {
|
||||
isAccessTokenActor,
|
||||
isAccountContext,
|
||||
isAppActor,
|
||||
viaWorkerOf,
|
||||
} from '../../core/actor.js';
|
||||
import { HttpError } from '../../core/http/HttpError.js';
|
||||
import {
|
||||
@@ -201,6 +202,13 @@ export class AppController extends PuterController {
|
||||
},
|
||||
async (req, res) => {
|
||||
const actor = req.actor;
|
||||
// Server-side code running as the account (a worker, an events
|
||||
// handler) is not someone opening the app. Answered as success
|
||||
// so deployed workers, which still ask, don't log a failure.
|
||||
if (viaWorkerOf(actor)) {
|
||||
res.json({});
|
||||
return;
|
||||
}
|
||||
const bodyAppUid = req.body?.app_uid;
|
||||
const callerAppUid = actor?.effectiveApp?.uid;
|
||||
const app_uid =
|
||||
|
||||
@@ -1143,6 +1143,34 @@ describe('AppController POST /rao actor gating', () => {
|
||||
).rejects.toMatchObject({ statusCode: 403, legacyCode: 'forbidden' });
|
||||
});
|
||||
|
||||
it('records nothing for a worker running as the app', async () => {
|
||||
const owner = await makeUser();
|
||||
const app = await createApp(owner.actor);
|
||||
const workerActor = makeActor({
|
||||
...owner.actor,
|
||||
app: { uid: app.uid as string },
|
||||
session: { uid: uuidv4(), kind: 'worker' },
|
||||
} as unknown as Actor);
|
||||
|
||||
const { res, captured } = makeRes();
|
||||
await withActor(workerActor, () =>
|
||||
callRoute(
|
||||
'post',
|
||||
'/rao',
|
||||
makeReq({ body: {}, actor: workerActor }),
|
||||
res,
|
||||
),
|
||||
);
|
||||
expect(captured.body).toEqual({});
|
||||
|
||||
await server.controllers.apps.drainPendingAppOpens();
|
||||
const rows = (await server.clients.db.read(
|
||||
'SELECT `app_uid` FROM `app_opens` WHERE `app_uid` = ? AND `user_id` = ?',
|
||||
[app.uid, owner.userId],
|
||||
)) as unknown[];
|
||||
expect(rows).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("refuses to let an app actor record another app's open", async () => {
|
||||
const { actor } = await makeUser();
|
||||
const own = await createApp(actor);
|
||||
|
||||
@@ -4487,34 +4487,6 @@ export class AuthController extends PuterController {
|
||||
res.json({ token: guiToken });
|
||||
}
|
||||
|
||||
@Post('/auth/first-party-session', {
|
||||
subdomain: 'api',
|
||||
rateLimit: SESSION_LIMIT,
|
||||
// Hands out a session, so only the GUI's own page script may ask, and
|
||||
// only as the user's sign-in itself.
|
||||
guiOriginOnly: true,
|
||||
requireUserActor: true,
|
||||
})
|
||||
async handleFirstPartySession(req: Request, res: Response): Promise<void> {
|
||||
const { origin } = req.body ?? {};
|
||||
if (typeof origin !== 'string' || !origin) {
|
||||
throw new HttpError(400, 'Missing `origin`', {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
}
|
||||
const token = await this.services.auth.createFirstPartySessionToken(
|
||||
req.actor!,
|
||||
origin,
|
||||
{
|
||||
ip: req.ip || req.socket?.remoteAddress,
|
||||
user_agent: req.headers?.['user-agent'],
|
||||
origin: req.headers?.origin,
|
||||
host: req.headers?.host,
|
||||
},
|
||||
);
|
||||
res.json({ token });
|
||||
}
|
||||
|
||||
@Get('/session/sync-cookie', {
|
||||
rateLimit: SESSION_LIMIT,
|
||||
// Installs the session cookie. Only page script on our own origin
|
||||
@@ -4539,14 +4511,6 @@ export class AuthController extends PuterController {
|
||||
res.status(400).end();
|
||||
return;
|
||||
}
|
||||
// A session handed to a first-party page never gets a cookie.
|
||||
if (req.actor.session.parentUid) {
|
||||
throw new HttpError(
|
||||
403,
|
||||
'This session cannot install a session cookie',
|
||||
{ legacyCode: 'forbidden' },
|
||||
);
|
||||
}
|
||||
// As in `/get-gui-token`: the actor is the user's row as auth read it.
|
||||
const sessionToken = this.services.auth.createSessionTokenForSession(
|
||||
req.actor.user as UserRow,
|
||||
|
||||
@@ -1,282 +0,0 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import type { IConfig } from '../../types.js';
|
||||
import { setupPuterTestEnv, type PuterTestEnv } from '../../testUtil.js';
|
||||
|
||||
const PAGE = 'https://desk.first-party.test';
|
||||
// Seeded with godmode on.
|
||||
const DEV_CENTER = 'app-0b37f054-07d4-4627-8765-11bd23e889d4';
|
||||
|
||||
/**
|
||||
* One of the deployment's own pages, served apart from the GUI, signs in with a
|
||||
* `web` session of its own under the user's sign-in.
|
||||
*/
|
||||
describe('first-party page sessions', () => {
|
||||
let env: PuterTestEnv;
|
||||
let userId = 0;
|
||||
|
||||
beforeAll(async () => {
|
||||
env = await setupPuterTestEnv({
|
||||
first_party_session_origins: [`${PAGE}/`],
|
||||
} as IConfig);
|
||||
const row = await env.server.stores.user.getByUsername(
|
||||
env.users.user.username,
|
||||
);
|
||||
userId = row!.id;
|
||||
}, 120_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await env?.shutdown();
|
||||
});
|
||||
|
||||
const call = (
|
||||
method: string,
|
||||
path: string,
|
||||
token: string,
|
||||
body?: unknown,
|
||||
headers: Record<string, string> = {},
|
||||
) =>
|
||||
fetch(new URL(path, env.apiOrigin), {
|
||||
method,
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
authorization: `Bearer ${token}`,
|
||||
...headers,
|
||||
},
|
||||
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
||||
});
|
||||
|
||||
/** A sign-in of its own, so revoking it leaves the shared one up. */
|
||||
const signIn = async () => {
|
||||
const user = await env.server.stores.user.getById(userId);
|
||||
const { gui_token, session } =
|
||||
await env.server.services.auth.createSessionToken(user!);
|
||||
return { token: gui_token, uuid: session.uuid as string };
|
||||
};
|
||||
|
||||
const mint = async (token: string, origin = PAGE) => {
|
||||
const res = await call('POST', '/auth/first-party-session', token, {
|
||||
origin,
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
return ((await res.json()) as { token: string }).token;
|
||||
};
|
||||
|
||||
const actorOf = async (token: string) =>
|
||||
(await env.server.services.auth.authenticate(token)).actor;
|
||||
|
||||
const sessions = async (token: string) => {
|
||||
const res = await call('GET', '/auth/list-sessions', token);
|
||||
expect(res.status).toBe(200);
|
||||
return (await res.json()) as Array<Record<string, unknown>>;
|
||||
};
|
||||
|
||||
it('hands the page a session of its own under the sign-in', async () => {
|
||||
const root = await signIn();
|
||||
const token = await mint(root.token);
|
||||
|
||||
const actor = await actorOf(token);
|
||||
expect(actor?.app ?? null).toBeNull();
|
||||
expect(actor?.accessToken ?? null).toBeNull();
|
||||
expect(actor?.session).toMatchObject({
|
||||
kind: 'web',
|
||||
parentUid: root.uuid,
|
||||
});
|
||||
expect(actor?.session?.uid).not.toBe(root.uuid);
|
||||
|
||||
const row = (await sessions(root.token)).find(
|
||||
(s) => s.uuid === actor!.session!.uid,
|
||||
);
|
||||
expect(row).toMatchObject({
|
||||
kind: 'web',
|
||||
parent_session_id: root.uuid,
|
||||
first_party_origin: PAGE,
|
||||
current: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('reuses the session on a second ask', async () => {
|
||||
const root = await signIn();
|
||||
const first = await actorOf(await mint(root.token));
|
||||
const second = await actorOf(await mint(root.token));
|
||||
expect(second?.session?.uid).toBe(first?.session?.uid);
|
||||
});
|
||||
|
||||
it('acts as the account: launches apps, godmode ones included', async () => {
|
||||
const root = await signIn();
|
||||
const token = await mint(root.token);
|
||||
|
||||
const res = await call('POST', '/auth/get-user-app-token', token, {
|
||||
app_uid: DEV_CENTER,
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.json()).toMatchObject({ godmode: true });
|
||||
});
|
||||
|
||||
it('refuses an origin that is not listed', async () => {
|
||||
const root = await signIn();
|
||||
const res = await call(
|
||||
'POST',
|
||||
'/auth/first-party-session',
|
||||
root.token,
|
||||
{
|
||||
origin: 'https://elsewhere.test',
|
||||
},
|
||||
);
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it('refuses a page on another origin, the listed page included', async () => {
|
||||
const root = await signIn();
|
||||
for (const origin of [PAGE, 'https://elsewhere.test']) {
|
||||
const res = await call(
|
||||
'POST',
|
||||
'/auth/first-party-session',
|
||||
root.token,
|
||||
{ origin: PAGE },
|
||||
{ origin },
|
||||
);
|
||||
expect(res.status).toBe(403);
|
||||
}
|
||||
});
|
||||
|
||||
it('refuses app tokens, access tokens and a first-party session', async () => {
|
||||
const root = await signIn();
|
||||
const page = await mint(root.token);
|
||||
const app = (await (
|
||||
await call('POST', '/auth/get-user-app-token', root.token, {
|
||||
origin: 'https://some-app.test',
|
||||
})
|
||||
).json()) as { token: string };
|
||||
const godmode = (await (
|
||||
await call('POST', '/auth/get-user-app-token', root.token, {
|
||||
app_uid: DEV_CENTER,
|
||||
})
|
||||
).json()) as { token: string };
|
||||
|
||||
for (const token of [page, app.token, godmode.token]) {
|
||||
const res = await call('POST', '/auth/first-party-session', token, {
|
||||
origin: PAGE,
|
||||
});
|
||||
expect(res.status).toBe(403);
|
||||
}
|
||||
});
|
||||
|
||||
it('never gets a session cookie', async () => {
|
||||
const root = await signIn();
|
||||
const page = await mint(root.token);
|
||||
|
||||
const syncCookie = (token: string) =>
|
||||
fetch(new URL('/session/sync-cookie', env.origin), {
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
});
|
||||
expect((await syncCookie(root.token)).status).toBe(204);
|
||||
|
||||
const res = await syncCookie(page);
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.headers.get('set-cookie')).toBeNull();
|
||||
});
|
||||
|
||||
it('ends with the sign-in it was handed out under', async () => {
|
||||
const root = await signIn();
|
||||
const page = await mint(root.token);
|
||||
expect(await actorOf(page)).toBeDefined();
|
||||
|
||||
await env.server.services.auth.revokeSession(root.uuid);
|
||||
const result = await env.server.services.auth.authenticate(page);
|
||||
expect(result.actor).toBeUndefined();
|
||||
expect(result.reauth?.reason).toBe('session_revoked');
|
||||
});
|
||||
|
||||
it('takes the tokens minted under it along when its sign-in ends', async () => {
|
||||
const root = await signIn();
|
||||
const page = await mint(root.token);
|
||||
const godmode = (await (
|
||||
await call('POST', '/auth/get-user-app-token', page, {
|
||||
app_uid: DEV_CENTER,
|
||||
})
|
||||
).json()) as { token: string };
|
||||
const scoped = (await (
|
||||
await call('POST', '/auth/create-access-token', godmode.token, {
|
||||
permissions: ['service:foo:ii:read'],
|
||||
expiresIn: '7d',
|
||||
})
|
||||
).json()) as { token: string };
|
||||
expect(await actorOf(scoped.token)).toBeDefined();
|
||||
|
||||
await env.server.services.auth.revokeSession(root.uuid);
|
||||
for (const token of [godmode.token, scoped.token]) {
|
||||
expect(
|
||||
(await env.server.services.auth.authenticate(token)).actor,
|
||||
).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('is revoked on its own without ending the sign-in', async () => {
|
||||
const root = await signIn();
|
||||
const page = await mint(root.token);
|
||||
const pageUuid = (await actorOf(page))!.session!.uid;
|
||||
|
||||
await env.server.services.auth.revokeSession(pageUuid);
|
||||
expect(
|
||||
(await env.server.services.auth.authenticate(page)).actor,
|
||||
).toBeUndefined();
|
||||
expect(await actorOf(root.token)).toBeDefined();
|
||||
});
|
||||
|
||||
it('keeps the whole current sign-in when revoking all others', async () => {
|
||||
const root = await signIn();
|
||||
const page = await mint(root.token);
|
||||
const other = await signIn();
|
||||
const otherPage = await mint(other.token);
|
||||
|
||||
// Asked from the page: its sign-in stays, and so does the page.
|
||||
await env.server.services.auth.revokeAllSessions(
|
||||
(await actorOf(page))!,
|
||||
);
|
||||
expect(await actorOf(page)).toBeDefined();
|
||||
expect(await actorOf(root.token)).toBeDefined();
|
||||
expect(
|
||||
(await env.server.services.auth.authenticate(other.token)).actor,
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
(await env.server.services.auth.authenticate(otherPage)).actor,
|
||||
).toBeUndefined();
|
||||
|
||||
// Asked from the sign-in: the page under it stays too.
|
||||
await env.server.services.auth.revokeAllSessions(
|
||||
(await actorOf(root.token))!,
|
||||
);
|
||||
expect(await actorOf(page)).toBeDefined();
|
||||
});
|
||||
|
||||
it('is not counted as a sign-in', async () => {
|
||||
const root = await signIn();
|
||||
const before = await env.server.stores.session.listSignIns(userId, {
|
||||
limit: 100,
|
||||
});
|
||||
await mint(root.token);
|
||||
const after = await env.server.stores.session.listSignIns(userId, {
|
||||
limit: 100,
|
||||
});
|
||||
expect(after.length).toBe(before.length);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,165 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
import { HttpError } from '../../core/http/HttpError.js';
|
||||
import { PuterController } from '../types.js';
|
||||
|
||||
const ALLOWED_LAYOUTS = ['icons', 'details', 'list'];
|
||||
const ALLOWED_SORT_BY = ['name', 'size', 'modified', 'type'];
|
||||
const ALLOWED_SORT_ORDER = ['asc', 'desc'];
|
||||
|
||||
/**
|
||||
* Folder view preference routes: layout and sort_by/sort_order, stored on the
|
||||
* folder's fsentries row.
|
||||
*/
|
||||
/**
|
||||
* Desktop preference writes — layout, sort order. Each persists on every call,
|
||||
* and the GUI fires them on direct user action, so a per-minute ceiling well
|
||||
* above human speed is enough to catch a stuck client.
|
||||
*/
|
||||
const PREFERENCE_WRITE_LIMIT = {
|
||||
scope: 'desktop-preference',
|
||||
limit: 120,
|
||||
window: 60_000,
|
||||
key: 'user',
|
||||
};
|
||||
|
||||
export class DesktopController extends PuterController {
|
||||
registerRoutes(router) {
|
||||
// -- Folder layout -------------------------------------------
|
||||
|
||||
router.post(
|
||||
'/set_layout',
|
||||
{
|
||||
subdomain: 'api',
|
||||
requireUserActor: true,
|
||||
allowFullAccessToken: true,
|
||||
rateLimit: PREFERENCE_WRITE_LIMIT,
|
||||
},
|
||||
async (req, res) => {
|
||||
const { item_uid, item_path, layout } = req.body ?? {};
|
||||
if (!layout || !ALLOWED_LAYOUTS.includes(layout)) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
`\`layout\` must be one of: ${ALLOWED_LAYOUTS.join(', ')}`,
|
||||
{ legacyCode: 'bad_request' },
|
||||
);
|
||||
}
|
||||
await this.#updateFSEntry(
|
||||
req.actor,
|
||||
{ item_uid, item_path },
|
||||
{ layout },
|
||||
);
|
||||
res.json({});
|
||||
},
|
||||
);
|
||||
|
||||
// -- Folder sort ---------------------------------------------
|
||||
|
||||
router.post(
|
||||
'/set_sort_by',
|
||||
{
|
||||
subdomain: 'api',
|
||||
requireUserActor: true,
|
||||
allowFullAccessToken: true,
|
||||
rateLimit: PREFERENCE_WRITE_LIMIT,
|
||||
},
|
||||
async (req, res) => {
|
||||
const { item_uid, item_path, sort_by, sort_order } =
|
||||
req.body ?? {};
|
||||
if (!sort_by || !ALLOWED_SORT_BY.includes(sort_by)) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
`\`sort_by\` must be one of: ${ALLOWED_SORT_BY.join(', ')}`,
|
||||
{ legacyCode: 'bad_request' },
|
||||
);
|
||||
}
|
||||
const resolvedOrder = sort_order ?? 'asc';
|
||||
if (!ALLOWED_SORT_ORDER.includes(resolvedOrder)) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
`\`sort_order\` must be one of: ${ALLOWED_SORT_ORDER.join(', ')}`,
|
||||
{ legacyCode: 'bad_request' },
|
||||
);
|
||||
}
|
||||
await this.#updateFSEntry(
|
||||
req.actor,
|
||||
{ item_uid, item_path },
|
||||
{
|
||||
sort_by,
|
||||
sort_order: resolvedOrder,
|
||||
},
|
||||
);
|
||||
res.json({});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// -- Helpers ------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Update columns on an actor-owned fsentry. Accepts either `item_uid` (fast
|
||||
* path) or `item_path` (path lookups in FSEntryStore now have a
|
||||
* recursive-CTE fallback for legacy rows with a NULL `path` column, so this
|
||||
* works for old accounts too).
|
||||
*
|
||||
* Ownership: `entry.user_id === actor.user.id`. Kept as added validation
|
||||
* against bad paths — the previous "drop user_id entirely" theory turned
|
||||
* out to be wrong (the actual legacy issue was NULL paths, not user_id
|
||||
* drift), so this filter doesn't lock out old accounts in practice.
|
||||
*/
|
||||
async #updateFSEntry(actor, { item_uid, item_path }, patch) {
|
||||
if (!item_uid && !item_path) {
|
||||
throw new HttpError(400, 'Missing `item_uid` or `item_path`', {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
}
|
||||
|
||||
const entry = item_uid
|
||||
? await this.stores.fsEntry.getEntryByUuid(item_uid)
|
||||
: await this.stores.fsEntry.getEntryByPath(item_path);
|
||||
if (!entry) {
|
||||
throw new HttpError(404, 'Item not found', {
|
||||
legacyCode: 'not_found',
|
||||
});
|
||||
}
|
||||
|
||||
const actorUserId = actor?.user?.id;
|
||||
if (typeof actorUserId !== 'number' || entry.userId !== actorUserId) {
|
||||
throw new HttpError(403, 'Not allowed to update this item', {
|
||||
legacyCode: 'forbidden',
|
||||
});
|
||||
}
|
||||
|
||||
const keys = Object.keys(patch);
|
||||
const setClause = keys.map((k) => `\`${k}\` = ?`).join(', ');
|
||||
const values = keys.map((k) => patch[k]);
|
||||
|
||||
await this.clients.db.write(
|
||||
`UPDATE \`fsentries\` SET ${setClause} WHERE \`id\` = ?`,
|
||||
[...values, entry.id],
|
||||
);
|
||||
|
||||
await this.stores.fsEntry.invalidateEntryCacheByUuid(entry.uuid);
|
||||
}
|
||||
|
||||
onServerStart() {}
|
||||
onServerPrepareShutdown() {}
|
||||
onServerShutdown() {}
|
||||
}
|
||||
@@ -0,0 +1,381 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
import type { Request, RequestHandler, Response } from 'express';
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import type { Actor } from '../../core/actor.js';
|
||||
import { PuterRouter } from '../../core/http/PuterRouter.js';
|
||||
import { PuterServer } from '../../server.js';
|
||||
import { setupTestServer } from '../../testUtil.js';
|
||||
import { generateDefaultFsentries } from '../../util/userProvisioning.js';
|
||||
|
||||
// ── Test harness ────────────────────────────────────────────────────
|
||||
//
|
||||
// Boots one real PuterServer (in-memory sqlite + dynamo + s3 + mock
|
||||
// redis) and re-registers DesktopController's inline lambda routes
|
||||
// onto a fresh PuterRouter so each handler is reachable. Each test
|
||||
// makes its own user via `makeUser` and exercises the live controller
|
||||
// against the real wired stores (user, fsEntry) and DB client.
|
||||
|
||||
let server: PuterServer;
|
||||
let router: PuterRouter;
|
||||
|
||||
beforeAll(async () => {
|
||||
server = await setupTestServer();
|
||||
router = new PuterRouter();
|
||||
server.controllers.desktop.registerRoutes(router);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await server?.shutdown();
|
||||
});
|
||||
|
||||
const makeUser = async (): Promise<{ actor: Actor; userId: number }> => {
|
||||
const username = `dc-${Math.random().toString(36).slice(2, 10)}`;
|
||||
const created = await server.stores.user.create({
|
||||
username,
|
||||
uuid: uuidv4(),
|
||||
password: null,
|
||||
email: `${username}@test.local`,
|
||||
free_storage: 100 * 1024 * 1024,
|
||||
requires_email_confirmation: false,
|
||||
});
|
||||
await generateDefaultFsentries(
|
||||
server.clients.db,
|
||||
server.stores.user,
|
||||
created,
|
||||
);
|
||||
const refreshed = (await server.stores.user.getById(created.id))!;
|
||||
return {
|
||||
userId: refreshed.id,
|
||||
actor: {
|
||||
user: {
|
||||
id: refreshed.id,
|
||||
uuid: refreshed.uuid,
|
||||
username: refreshed.username,
|
||||
email: refreshed.email ?? null,
|
||||
email_confirmed: true,
|
||||
} as Actor['user'],
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
interface CapturedResponse {
|
||||
statusCode: number;
|
||||
body: unknown;
|
||||
}
|
||||
|
||||
const makeReq = (init: {
|
||||
body?: unknown;
|
||||
actor?: Actor;
|
||||
}): Request => {
|
||||
return {
|
||||
body: init.body ?? {},
|
||||
query: {},
|
||||
headers: {},
|
||||
actor: init.actor,
|
||||
} as unknown as Request;
|
||||
};
|
||||
|
||||
const makeRes = () => {
|
||||
const captured: CapturedResponse = { statusCode: 200, body: undefined };
|
||||
const res = {
|
||||
json: vi.fn((value: unknown) => {
|
||||
captured.body = value;
|
||||
return res;
|
||||
}),
|
||||
status: vi.fn((code: number) => {
|
||||
captured.statusCode = code;
|
||||
return res;
|
||||
}),
|
||||
setHeader: vi.fn(() => res),
|
||||
};
|
||||
return { res: res as unknown as Response, captured };
|
||||
};
|
||||
|
||||
const findHandler = (method: string, path: string): RequestHandler => {
|
||||
const route = router.routes.find(
|
||||
(r) => r.method === method && r.path === path,
|
||||
);
|
||||
if (!route) throw new Error(`No ${method.toUpperCase()} ${path} route`);
|
||||
return route.handler;
|
||||
};
|
||||
|
||||
const callRoute = async (
|
||||
method: string,
|
||||
path: string,
|
||||
req: Request,
|
||||
res: Response,
|
||||
) => {
|
||||
const handler = findHandler(method, path);
|
||||
await handler(req, res, () => {
|
||||
throw new Error('handler called next() unexpectedly');
|
||||
});
|
||||
};
|
||||
|
||||
describe('DesktopController routes', () => {
|
||||
it('has no desktop background route', () => {
|
||||
expect(
|
||||
router.routes.some((r) => r.path === '/set-desktop-bg'),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('has no taskbar items route', () => {
|
||||
expect(
|
||||
router.routes.some((r) => r.path === '/update-taskbar-items'),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── /set_layout ─────────────────────────────────────────────────────
|
||||
|
||||
describe('DesktopController POST /set_layout', () => {
|
||||
it('persists the new layout on the matching fsentry', async () => {
|
||||
const { actor } = await makeUser();
|
||||
const username = actor.user!.username!;
|
||||
const documents = await server.stores.fsEntry.getEntryByPath(
|
||||
`/${username}/Documents`,
|
||||
);
|
||||
expect(documents).not.toBeNull();
|
||||
|
||||
const { res, captured } = makeRes();
|
||||
await callRoute(
|
||||
'post',
|
||||
'/set_layout',
|
||||
makeReq({
|
||||
body: { item_uid: documents!.uuid, layout: 'icons' },
|
||||
actor,
|
||||
}),
|
||||
res,
|
||||
);
|
||||
expect(captured.body).toEqual({});
|
||||
|
||||
const refreshed = await server.stores.fsEntry.getEntryByUuid(
|
||||
documents!.uuid,
|
||||
);
|
||||
expect(refreshed?.layout).toBe('icons');
|
||||
});
|
||||
|
||||
it('resolves by item_path when item_uid is omitted', async () => {
|
||||
const { actor } = await makeUser();
|
||||
const username = actor.user!.username!;
|
||||
const path = `/${username}/Pictures`;
|
||||
|
||||
const { res } = makeRes();
|
||||
await callRoute(
|
||||
'post',
|
||||
'/set_layout',
|
||||
makeReq({
|
||||
body: { item_path: path, layout: 'list' },
|
||||
actor,
|
||||
}),
|
||||
res,
|
||||
);
|
||||
|
||||
const refreshed = await server.stores.fsEntry.getEntryByPath(path);
|
||||
expect(refreshed?.layout).toBe('list');
|
||||
});
|
||||
|
||||
it('throws 400 for an unknown layout value', async () => {
|
||||
const { actor } = await makeUser();
|
||||
const username = actor.user!.username!;
|
||||
const documents = await server.stores.fsEntry.getEntryByPath(
|
||||
`/${username}/Documents`,
|
||||
);
|
||||
|
||||
const { res } = makeRes();
|
||||
await expect(
|
||||
callRoute(
|
||||
'post',
|
||||
'/set_layout',
|
||||
makeReq({
|
||||
body: { item_uid: documents!.uuid, layout: 'gallery' },
|
||||
actor,
|
||||
}),
|
||||
res,
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
});
|
||||
|
||||
it('throws 400 when neither item_uid nor item_path is supplied', async () => {
|
||||
const { actor } = await makeUser();
|
||||
const { res } = makeRes();
|
||||
await expect(
|
||||
callRoute(
|
||||
'post',
|
||||
'/set_layout',
|
||||
makeReq({ body: { layout: 'icons' }, actor }),
|
||||
res,
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
});
|
||||
|
||||
it('throws 404 when the fsentry cannot be resolved', async () => {
|
||||
const { actor } = await makeUser();
|
||||
const { res } = makeRes();
|
||||
await expect(
|
||||
callRoute(
|
||||
'post',
|
||||
'/set_layout',
|
||||
makeReq({
|
||||
body: {
|
||||
item_uid: '00000000-0000-0000-0000-000000000000',
|
||||
layout: 'icons',
|
||||
},
|
||||
actor,
|
||||
}),
|
||||
res,
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 404 });
|
||||
});
|
||||
|
||||
it("throws 403 when the fsentry belongs to another user", async () => {
|
||||
const owner = await makeUser();
|
||||
const intruder = await makeUser();
|
||||
const ownerUsername = owner.actor.user!.username!;
|
||||
const ownerEntry = await server.stores.fsEntry.getEntryByPath(
|
||||
`/${ownerUsername}/Documents`,
|
||||
);
|
||||
|
||||
const { res } = makeRes();
|
||||
await expect(
|
||||
callRoute(
|
||||
'post',
|
||||
'/set_layout',
|
||||
makeReq({
|
||||
body: { item_uid: ownerEntry!.uuid, layout: 'icons' },
|
||||
actor: intruder.actor,
|
||||
}),
|
||||
res,
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 403 });
|
||||
|
||||
// Owner's entry remains untouched.
|
||||
const refreshed = await server.stores.fsEntry.getEntryByUuid(
|
||||
ownerEntry!.uuid,
|
||||
);
|
||||
expect(refreshed?.layout).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
// ── /set_sort_by ────────────────────────────────────────────────────
|
||||
|
||||
describe('DesktopController POST /set_sort_by', () => {
|
||||
it('persists sort_by + sort_order on the matching fsentry', async () => {
|
||||
const { actor } = await makeUser();
|
||||
const username = actor.user!.username!;
|
||||
const documents = await server.stores.fsEntry.getEntryByPath(
|
||||
`/${username}/Documents`,
|
||||
);
|
||||
|
||||
const { res, captured } = makeRes();
|
||||
await callRoute(
|
||||
'post',
|
||||
'/set_sort_by',
|
||||
makeReq({
|
||||
body: {
|
||||
item_uid: documents!.uuid,
|
||||
sort_by: 'name',
|
||||
sort_order: 'desc',
|
||||
},
|
||||
actor,
|
||||
}),
|
||||
res,
|
||||
);
|
||||
expect(captured.body).toEqual({});
|
||||
|
||||
const refreshed = await server.stores.fsEntry.getEntryByUuid(
|
||||
documents!.uuid,
|
||||
);
|
||||
expect(refreshed?.sortBy).toBe('name');
|
||||
expect(refreshed?.sortOrder).toBe('desc');
|
||||
});
|
||||
|
||||
it('defaults sort_order to "asc" when omitted', async () => {
|
||||
const { actor } = await makeUser();
|
||||
const username = actor.user!.username!;
|
||||
const pictures = await server.stores.fsEntry.getEntryByPath(
|
||||
`/${username}/Pictures`,
|
||||
);
|
||||
|
||||
const { res } = makeRes();
|
||||
await callRoute(
|
||||
'post',
|
||||
'/set_sort_by',
|
||||
makeReq({
|
||||
body: { item_uid: pictures!.uuid, sort_by: 'modified' },
|
||||
actor,
|
||||
}),
|
||||
res,
|
||||
);
|
||||
|
||||
const refreshed = await server.stores.fsEntry.getEntryByUuid(
|
||||
pictures!.uuid,
|
||||
);
|
||||
expect(refreshed?.sortBy).toBe('modified');
|
||||
expect(refreshed?.sortOrder).toBe('asc');
|
||||
});
|
||||
|
||||
it('throws 400 for an unknown sort_by value', async () => {
|
||||
const { actor } = await makeUser();
|
||||
const username = actor.user!.username!;
|
||||
const docs = await server.stores.fsEntry.getEntryByPath(
|
||||
`/${username}/Documents`,
|
||||
);
|
||||
|
||||
const { res } = makeRes();
|
||||
await expect(
|
||||
callRoute(
|
||||
'post',
|
||||
'/set_sort_by',
|
||||
makeReq({
|
||||
body: { item_uid: docs!.uuid, sort_by: 'random' },
|
||||
actor,
|
||||
}),
|
||||
res,
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
});
|
||||
|
||||
it('throws 400 for an invalid sort_order', async () => {
|
||||
const { actor } = await makeUser();
|
||||
const username = actor.user!.username!;
|
||||
const docs = await server.stores.fsEntry.getEntryByPath(
|
||||
`/${username}/Documents`,
|
||||
);
|
||||
|
||||
const { res } = makeRes();
|
||||
await expect(
|
||||
callRoute(
|
||||
'post',
|
||||
'/set_sort_by',
|
||||
makeReq({
|
||||
body: {
|
||||
item_uid: docs!.uuid,
|
||||
sort_by: 'name',
|
||||
sort_order: 'sideways',
|
||||
},
|
||||
actor,
|
||||
}),
|
||||
res,
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
});
|
||||
});
|
||||
@@ -21,6 +21,7 @@ import { AppController } from './apps/AppController.js';
|
||||
import { AppFeedbackController } from './feedback/AppFeedbackController.js';
|
||||
import { AuthController } from './auth/AuthController.js';
|
||||
import { BroadcastController } from './broadcast/BroadcastController.js';
|
||||
import { DesktopController } from './desktop/DesktopController.js';
|
||||
import { DriverController } from './drivers/DriverController.js';
|
||||
import { EventsController } from './events/EventsController.js';
|
||||
import { FSController } from './fs/FSController.js';
|
||||
@@ -48,6 +49,7 @@ export const puterControllers = {
|
||||
profile: ProfileController,
|
||||
apps: AppController,
|
||||
appFeedback: AppFeedbackController,
|
||||
desktop: DesktopController,
|
||||
hosting: HostingController,
|
||||
system: SystemController,
|
||||
fs: FSController,
|
||||
|
||||
@@ -76,14 +76,8 @@ export interface Actor {
|
||||
/**
|
||||
* Set when authenticated by a session token or an app-under-user token
|
||||
* carrying one. `kind` mirrors the session row (`web`, `app`, `worker`).
|
||||
* `parentUid` is the sign-in a `web` session was handed out under, set only
|
||||
* on a first-party page's session.
|
||||
*/
|
||||
session?: {
|
||||
uid: string;
|
||||
kind?: string | null;
|
||||
parentUid?: string | null;
|
||||
} | null;
|
||||
session?: { uid: string; kind?: string | null } | null;
|
||||
/**
|
||||
* Events handler runs behind this actor's writes; set only from an events
|
||||
* handler token or a token one minted. Provenance, not authority, so
|
||||
|
||||
@@ -26,7 +26,7 @@ import type { IConfig } from '../../../types';
|
||||
* Browsers send none of those variations, but config values do — a hand-edited
|
||||
* `origin` with a trailing slash would otherwise reject every real request.
|
||||
*/
|
||||
export const normalizeOrigin = (raw: string | undefined): string =>
|
||||
const normalizeOrigin = (raw: string | undefined): string =>
|
||||
(raw ?? '').trim().replace(/\/+$/, '').toLowerCase();
|
||||
|
||||
/**
|
||||
|
||||
@@ -1949,3 +1949,66 @@ describe('WorkerDriver keeps a worker`s token tied to the worker', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('WorkerDriver teardown of a worker’s sandbox app', () => {
|
||||
const SANDBOX_URL = 'https://worker-sandbox.puter.com/';
|
||||
|
||||
/** A worker bound to an app made the way puter.js makes its sandbox. */
|
||||
const deploySandboxed = async ({
|
||||
appName,
|
||||
indexUrl = SANDBOX_URL,
|
||||
}: { appName?: (worker: string) => string; indexUrl?: string } = {}) => {
|
||||
const { user, actor } = await makeUser();
|
||||
const name = `sbx-${user.username}`;
|
||||
const app = await server.stores.app.create(
|
||||
{
|
||||
name: appName ? appName(name) : `sandbox-${name}`,
|
||||
title: name,
|
||||
index_url: indexUrl,
|
||||
},
|
||||
{ ownerUserId: user.id },
|
||||
);
|
||||
const path = `/${user.username}/sbx.js`;
|
||||
await writeSource(actor, user.id, path, 'export default {}');
|
||||
await inCtx(actor, () =>
|
||||
target.create({ appId: app.uid, workerName: name, filePath: path }),
|
||||
);
|
||||
return { user, actor, app, name, path };
|
||||
};
|
||||
|
||||
it('deletes the sandbox app with the worker, freeing its name', async () => {
|
||||
const { actor, app, name } = await deploySandboxed();
|
||||
await inCtx(actor, () => target.destroy({ workerName: name }));
|
||||
expect(await server.stores.app.getByUid(app.uid)).toBeFalsy();
|
||||
expect(await server.stores.app.getByName(`sandbox-${name}`)).toBeFalsy();
|
||||
});
|
||||
|
||||
it('keeps the app while another worker still runs as it', async () => {
|
||||
const { user, actor, app, name, path } = await deploySandboxed();
|
||||
const other = `${name}-2`;
|
||||
await inCtx(actor, () =>
|
||||
target.create({ appId: app.uid, workerName: other, filePath: path }),
|
||||
);
|
||||
await inCtx(actor, () => target.destroy({ workerName: name }));
|
||||
expect(await server.stores.app.getByUid(app.uid)).toBeTruthy();
|
||||
expect(
|
||||
await server.stores.subdomain.getBySubdomain(`workers.puter.${other}`),
|
||||
).toMatchObject({ user_id: user.id });
|
||||
});
|
||||
|
||||
it('keeps an app that is not named for the worker', async () => {
|
||||
const { actor, app, name } = await deploySandboxed({
|
||||
appName: (worker) => `mine-${worker}`,
|
||||
});
|
||||
await inCtx(actor, () => target.destroy({ workerName: name }));
|
||||
expect(await server.stores.app.getByUid(app.uid)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('keeps a sandbox-named app that points somewhere else', async () => {
|
||||
const { actor, app, name } = await deploySandboxed({
|
||||
indexUrl: 'https://example.com/',
|
||||
});
|
||||
await inCtx(actor, () => target.destroy({ workerName: name }));
|
||||
expect(await server.stores.app.getByUid(app.uid)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -26,6 +26,7 @@ import { resolveCallableMethods } from '../meta.js';
|
||||
import {
|
||||
INTERNAL_ADMIN_DESTROY,
|
||||
INTERNAL_ADMISSION_BYPASS,
|
||||
WORKER_NAME_MAX_LENGTH,
|
||||
} from './WorkerDriver.js';
|
||||
import type { WorkerDriver } from './WorkerDriver.js';
|
||||
|
||||
@@ -164,6 +165,33 @@ describe('WorkerDriver', () => {
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
});
|
||||
|
||||
it('rejects names too long for the worker subdomain', async () => {
|
||||
// `workers.puter.` + 51 characters overflows the 64-character column.
|
||||
await expect(
|
||||
inCtx(() =>
|
||||
target.create({
|
||||
appId: 'test-app',
|
||||
workerName: 'a'.repeat(WORKER_NAME_MAX_LENGTH + 1),
|
||||
filePath: '/test.js',
|
||||
}),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
});
|
||||
|
||||
it('accepts a name at the length limit', async () => {
|
||||
// 503 is the missing Cloudflare config, past the name checks.
|
||||
expect(WORKER_NAME_MAX_LENGTH).toBe(50);
|
||||
await expect(
|
||||
inCtx(() =>
|
||||
target.create({
|
||||
appId: 'test-app',
|
||||
workerName: 'a'.repeat(WORKER_NAME_MAX_LENGTH),
|
||||
filePath: '/test.js',
|
||||
}),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 503 });
|
||||
});
|
||||
|
||||
it('allows valid worker names with underscores and dashes', async () => {
|
||||
// This will fail at the CF config check (503) rather than the
|
||||
// name validation (400), proving the name was accepted.
|
||||
|
||||
@@ -58,6 +58,18 @@ const CF_BASE_URL = 'https://api.cloudflare.com/client/v4/accounts';
|
||||
export const WORKER_COMPATIBILITY_DATE = '2025-07-15';
|
||||
export const WORKER_COMPATIBILITY_FLAGS = ['global_fetch_strictly_public'];
|
||||
const WORKER_NAME_REGEX = /^[a-zA-Z0-9_-]+$/;
|
||||
/** `subdomains.subdomain` is varchar(64) and holds `workers.puter.<name>`. */
|
||||
export const WORKER_NAME_MAX_LENGTH = 64 - WORKER_SUBDOMAIN_PREFIX.length;
|
||||
/** Puter.js names a worker's default sandbox app `sandbox-<workerName>`. */
|
||||
const SANDBOX_APP_PREFIX = 'sandbox-';
|
||||
/** The page a sandbox app points at, served on the `worker-sandbox` subdomain. */
|
||||
const isWorkerSandboxUrl = (url: unknown): boolean => {
|
||||
try {
|
||||
return new URL(String(url)).hostname.split('.')[0] === 'worker-sandbox';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
/** Cap on ordinary workers while user-scope handlers are off. */
|
||||
const MAX_WORKERS_PER_USER = 100;
|
||||
|
||||
@@ -434,6 +446,15 @@ export class WorkerDriver extends PuterDriver {
|
||||
{ legacyCode: 'bad_request' },
|
||||
);
|
||||
}
|
||||
// A deploy target names its own script, so only a worker that becomes
|
||||
// a subdomain row is bound by the column's width.
|
||||
if (!deployTarget && workerName.length > WORKER_NAME_MAX_LENGTH) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
`Worker name must be at most ${WORKER_NAME_MAX_LENGTH} characters`,
|
||||
{ legacyCode: 'bad_request' },
|
||||
);
|
||||
}
|
||||
this.#rejectReserved(workerName);
|
||||
const subdomainName = `${WORKER_SUBDOMAIN_PREFIX}${workerName}`;
|
||||
|
||||
@@ -778,6 +799,50 @@ export class WorkerDriver extends PuterDriver {
|
||||
} catch {
|
||||
// Non-critical.
|
||||
}
|
||||
await this.#releaseSandboxApp(row, workerName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the `sandbox-<name>` app puter.js made for this worker, so the
|
||||
* next owner of the worker name can make their own. Only when nothing else
|
||||
* runs as it: deleting an app cascades to every row bound to it.
|
||||
*/
|
||||
async #releaseSandboxApp(
|
||||
row: SubdomainRow,
|
||||
workerName: string,
|
||||
): Promise<void> {
|
||||
if (row.app_owner === null || row.app_owner === undefined) return;
|
||||
try {
|
||||
const app = await this.stores.app.getById(Number(row.app_owner));
|
||||
if (
|
||||
!app ||
|
||||
app.protected ||
|
||||
app.name !== `${SANDBOX_APP_PREFIX}${workerName}` ||
|
||||
Number(app.owner_user_id) !== Number(row.user_id) ||
|
||||
!isWorkerSandboxUrl(app.index_url)
|
||||
)
|
||||
return;
|
||||
const stillBound = await this.stores.subdomain.listAppCascade({
|
||||
appId: Number(app.id),
|
||||
});
|
||||
if (stillBound.length > 0) return;
|
||||
await this.stores.app.delete(Number(app.id));
|
||||
this.clients.event.emit(
|
||||
'app.changed',
|
||||
{
|
||||
app_uid: app.uid,
|
||||
app: null,
|
||||
old_app: app,
|
||||
action: 'deleted',
|
||||
},
|
||||
{},
|
||||
);
|
||||
} catch (e) {
|
||||
console.error(
|
||||
`[workers] sandbox app cleanup failed for ${workerName}`,
|
||||
e,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async getFilePaths(args: Record<string, unknown>): Promise<unknown> {
|
||||
|
||||
@@ -645,59 +645,6 @@ describe('PuterServer host header validation — permissive modes', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('PuterServer behind a trusted proxy', () => {
|
||||
let server: PuterServer;
|
||||
let port: number;
|
||||
|
||||
beforeAll(async () => {
|
||||
port = await allocateEphemeralPort();
|
||||
server = await setupTestServer(
|
||||
{
|
||||
port,
|
||||
domain: 'puter.localhost',
|
||||
origin: `http://puter.localhost:${port}`,
|
||||
api_base_url: `http://api.puter.localhost:${port}`,
|
||||
allow_all_host_values: false,
|
||||
allow_no_host_header: false,
|
||||
custom_domains_enabled: false,
|
||||
static_hosting_domain: 'site.puter.localhost',
|
||||
trust_proxy: 1,
|
||||
} as unknown as IConfig,
|
||||
{ listen: true },
|
||||
);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await server?.shutdown();
|
||||
});
|
||||
|
||||
it('serves the main domain whatever X-Forwarded-Host names', async () => {
|
||||
const res = await rawRequest(port, '/healthcheck', {
|
||||
host: `puter.localhost:${port}`,
|
||||
'x-forwarded-host': 'nosuchsite.site.puter.localhost',
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers['x-frame-options']).toBe('SAMEORIGIN');
|
||||
});
|
||||
|
||||
it('keeps a hosted-site host on site hosting', async () => {
|
||||
const res = await rawRequest(port, '/healthcheck', {
|
||||
host: `nosuchsite.site.puter.localhost:${port}`,
|
||||
'x-forwarded-host': 'api.puter.localhost',
|
||||
});
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('reads subdomains from Host', async () => {
|
||||
const res = await rawRequest(port, '/healthcheck', {
|
||||
host: `api.puter.localhost:${port}`,
|
||||
'x-forwarded-host': 'puter.localhost',
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers['x-frame-options']).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PuterServer host header validation — custom domains with every host accepted', () => {
|
||||
let server: PuterServer;
|
||||
let port: number;
|
||||
|
||||
@@ -416,17 +416,6 @@ export class PuterServer {
|
||||
* `#materializeRoute` as those options ship.
|
||||
*/
|
||||
#installGlobalMiddleware() {
|
||||
// -- Forwarded host ------------------------------------------
|
||||
// With `trust proxy` set, express derives `req.hostname` and
|
||||
// `req.subdomains` from X-Forwarded-Host, which any client can send.
|
||||
// Dropping it keeps them on Host, the header host validation checks,
|
||||
// so a request can't pick a different site or subdomain router than
|
||||
// the host it was sent to.
|
||||
this.#app.use((req, _res, next) => {
|
||||
delete req.headers['x-forwarded-host'];
|
||||
next();
|
||||
});
|
||||
|
||||
// -- Egress metering -----------------------------------------
|
||||
// First, so the byte counter wraps `res.write` before compression
|
||||
// does and therefore counts what actually goes out rather than what
|
||||
|
||||
@@ -26,7 +26,6 @@ import {
|
||||
type Actor,
|
||||
} from '../../core/actor';
|
||||
import { HttpError } from '../../core/http/HttpError.js';
|
||||
import { normalizeOrigin } from '../../core/http/middleware/originGate.js';
|
||||
import { WEB_AND_EXTENSION_PROTOCOLS } from '../../util/validation.js';
|
||||
import { awaitLater } from '../../util/concurrency.js';
|
||||
import { appGenerationStart } from '../../util/appGeneration.js';
|
||||
@@ -394,68 +393,6 @@ export class AuthService extends PuterService {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `origin` is one of the deployment's own pages that sign in with a
|
||||
* session of their own (`first_party_session_origins`).
|
||||
*/
|
||||
isFirstPartySessionOrigin(origin: unknown): boolean {
|
||||
if (typeof origin !== 'string' || !origin) return false;
|
||||
const wanted = normalizeOrigin(origin);
|
||||
return (this.config.first_party_session_origins ?? []).some(
|
||||
(listed) => normalizeOrigin(listed) === wanted,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mint the GUI token one of the deployment's own pages signs in with: a
|
||||
* `web` session of its own under the caller's sign-in, so it is listed and
|
||||
* revoked apart from that sign-in and ends with it. Only a sign-in itself
|
||||
* may ask, never a session handed out this way. No session cookie is ever
|
||||
* issued for the row.
|
||||
*/
|
||||
async createFirstPartySessionToken(
|
||||
actor: Actor,
|
||||
origin: string,
|
||||
meta: Record<string, unknown> = {},
|
||||
): Promise<string> {
|
||||
if (
|
||||
!isPlainUserActor(actor) ||
|
||||
!actor.user?.id ||
|
||||
!actor.user.uuid ||
|
||||
actor.session?.kind !== 'web' ||
|
||||
actor.session.parentUid
|
||||
) {
|
||||
throw new HttpError(
|
||||
403,
|
||||
'Only a signed-in browser session can sign this page in',
|
||||
{ legacyCode: 'forbidden' },
|
||||
);
|
||||
}
|
||||
if (!this.isFirstPartySessionOrigin(origin)) {
|
||||
throw new HttpError(403, 'This origin cannot be signed in', {
|
||||
legacyCode: 'forbidden',
|
||||
});
|
||||
}
|
||||
|
||||
const auth_id = this.#authIdFor(actor.user as UserRow);
|
||||
const row = await this.stores.session.getOrCreateFirstPartySession(
|
||||
actor.user.id,
|
||||
{
|
||||
parentSessionUid: actor.session.uid,
|
||||
origin: normalizeOrigin(origin),
|
||||
expiresAt: nowSeconds() + WEB_WINDOW_SECONDS,
|
||||
meta,
|
||||
auth_id,
|
||||
},
|
||||
);
|
||||
if (!row?.uuid) {
|
||||
throw new HttpError(500, 'Could not sign this page in', {
|
||||
legacyCode: 'internal_error',
|
||||
});
|
||||
}
|
||||
return this.createGuiToken(actor.user as UserRow, row.uuid);
|
||||
}
|
||||
|
||||
/** Sign a session token for an existing session (upgrade from GUI token). */
|
||||
createSessionTokenForSession(user: UserRow, sessionUuid: string): string {
|
||||
return this.#signSessionTypeToken(
|
||||
@@ -853,7 +790,7 @@ export class AuthService extends PuterService {
|
||||
row.kind !== 'asset' &&
|
||||
// Operator-created debug sessions are never the user's own.
|
||||
row.kind !== DEBUG_APP_SESSION_KIND &&
|
||||
((row.kind !== 'access_token' && row.kind !== 'web') ||
|
||||
(row.kind !== 'access_token' ||
|
||||
!row.parent_session_id ||
|
||||
live.has(row.parent_session_id)),
|
||||
);
|
||||
@@ -1160,24 +1097,16 @@ export class AuthService extends PuterService {
|
||||
legacyCode: 'forbidden',
|
||||
});
|
||||
}
|
||||
// The current sign-in is the session, the sign-in it was handed out
|
||||
// under, and the sessions handed out under either.
|
||||
const current = new Set(
|
||||
[actor.session?.uid, actor.session?.parentUid].filter(Boolean),
|
||||
);
|
||||
const rows = (await this.stores.session.getByUserId(
|
||||
const currentUuid = actor.session?.uid;
|
||||
const rows = await this.stores.session.getByUserId(
|
||||
actor.user.id as number,
|
||||
{ primary: true },
|
||||
)) as SessionRow[];
|
||||
);
|
||||
await this.#revokeCascades(
|
||||
rows
|
||||
.filter((row) =>
|
||||
row.kind === 'web'
|
||||
? opts.includeCurrent ||
|
||||
!(
|
||||
current.has(row.uuid) ||
|
||||
current.has(row.parent_session_id ?? '')
|
||||
)
|
||||
? opts.includeCurrent || row.uuid !== currentUuid
|
||||
: row.kind === 'app' && opts.includeApps,
|
||||
)
|
||||
.map((row) => row.uuid as string),
|
||||
@@ -2824,18 +2753,6 @@ export class AuthService extends PuterService {
|
||||
const session: SessionRow | null = rawRow;
|
||||
|
||||
if (!session) return { invalid: true };
|
||||
// A session handed to a first-party page lives no longer than the
|
||||
// sign-in it was handed out under.
|
||||
if (session.kind === 'web' && session.parent_session_id) {
|
||||
const parent = (await this.stores.session.getByUuidAny(
|
||||
session.parent_session_id,
|
||||
)) as SessionRow | null;
|
||||
if (!parent) {
|
||||
return { reauth: { reason: 'session_revoked', auth_id } };
|
||||
}
|
||||
const parentEnded = this.#endedSession(parent, auth_id);
|
||||
if (parentEnded) return parentEnded;
|
||||
}
|
||||
if (!(await deployed)) return { invalid: true };
|
||||
|
||||
void this.#touchSession(session.uuid, user.id, ctx);
|
||||
@@ -3128,13 +3045,7 @@ export class AuthService extends PuterService {
|
||||
return makeActor({
|
||||
user: this.#actorUserFromRow(user),
|
||||
session: session
|
||||
? {
|
||||
uid: session.uuid,
|
||||
kind: session.kind ?? null,
|
||||
...(session.kind === 'web' && session.parent_session_id
|
||||
? { parentUid: session.parent_session_id }
|
||||
: {}),
|
||||
}
|
||||
? { uid: session.uuid, kind: session.kind ?? null }
|
||||
: null,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -399,11 +399,8 @@ export class PuterHomepageService extends PuterService {
|
||||
// instead of the socket wire.
|
||||
eventsNotifications: notificationsFoldInEnabled(this.config),
|
||||
...meta,
|
||||
// After `meta`, so nothing page-specific can name the opener or
|
||||
// the pages a sign-in popup hands a session.
|
||||
// After `meta`, so nothing page-specific can name the opener.
|
||||
...(popupFrame ? { embed_host_origin: popupFrame.hostOrigin } : {}),
|
||||
first_party_session_origins:
|
||||
this.config.first_party_session_origins ?? [],
|
||||
};
|
||||
// Only ever this request's; without one the GUI asks for its token as
|
||||
// it always has.
|
||||
|
||||
@@ -63,9 +63,6 @@ export const ASSET_WINDOW_SECONDS = 7 * 24 * 60 * 60; // 7 days
|
||||
// An app session created on a user's behalf for debugging: hard expiry, never
|
||||
// slid, and hidden from the user's own session list.
|
||||
export const DEBUG_APP_SESSION_KIND = 'debug_app';
|
||||
// `created_via` of a `web` row handed to one of the deployment's own pages
|
||||
// under another sign-in (its `parent_session_id`).
|
||||
export const FIRST_PARTY_SESSION_VIA = 'first_party';
|
||||
// A godmode app's token lapses at most this long after the desktop last asked
|
||||
// for it, so a closed desktop's tokens die on their own. A token past the max
|
||||
// age is never renewed again; the next ask replaces it and revokes the old one.
|
||||
@@ -169,8 +166,7 @@ export class SessionStore extends PuterStore {
|
||||
* Interactive sign-ins for a user, newest first, for the account's own
|
||||
* activity view. Revoked rows are included -- a session someone opened and
|
||||
* closed is exactly what the reader is looking for. Derived kinds (app,
|
||||
* access token, asset, worker) and sessions handed out under another
|
||||
* sign-in are not sign-ins and stay out.
|
||||
* access token, asset, worker) are not sign-ins and stay out.
|
||||
*
|
||||
* Uncached and keyset-paginated on `id`: the caller merges this with
|
||||
* another stream, so it asks for one page at a time rather than the whole
|
||||
@@ -183,7 +179,7 @@ export class SessionStore extends PuterStore {
|
||||
async listSignIns(userId, { limit, beforeId = null }) {
|
||||
const rows = await this.clients.db.read(
|
||||
'SELECT `id`, `created_at`, `last_ip`, `last_user_agent` FROM `sessions` ' +
|
||||
"WHERE `user_id` = ? AND `kind` = 'web' AND `parent_session_id` IS NULL" +
|
||||
"WHERE `user_id` = ? AND `kind` = 'web'" +
|
||||
(beforeId === null ? '' : ' AND `id` < ?') +
|
||||
' ORDER BY `id` DESC LIMIT ?',
|
||||
beforeId === null ? [userId, limit] : [userId, beforeId, limit],
|
||||
@@ -497,19 +493,13 @@ export class SessionStore extends PuterStore {
|
||||
* sessions costs a few primary round trips rather than a few per session.
|
||||
* Returns `{ userId, uuids }` for each user that had rows revoked.
|
||||
*
|
||||
* A first-party page's session, revoked under its sign-in, is cascaded from
|
||||
* in turn: it has derived rows of its own.
|
||||
*
|
||||
* @param {string[]} rootUuids
|
||||
*/
|
||||
async revokeCascades(rootUuids) {
|
||||
const roots = [...new Set(rootUuids.filter(Boolean).map(String))];
|
||||
const queued = new Set(roots);
|
||||
const uuidsByUser = new Map();
|
||||
// `roots` grows as it is walked, so each pass takes what is there.
|
||||
for (let i = 0; i < roots.length; ) {
|
||||
for (let i = 0; i < roots.length; i += REVOKE_CHUNK_SIZE) {
|
||||
const chunk = roots.slice(i, i + REVOKE_CHUNK_SIZE);
|
||||
i += chunk.length;
|
||||
const placeholders = chunk.map(() => '?').join(', ');
|
||||
const scope = `(\`uuid\` IN (${placeholders}) OR \`parent_session_id\` IN (${placeholders})) AND \`revoked_at\` IS NULL`;
|
||||
const scopeParams = [...chunk, ...chunk];
|
||||
@@ -541,14 +531,6 @@ export class SessionStore extends PuterStore {
|
||||
const userId = Number(r.user_id);
|
||||
if (!uuidsByUser.has(userId)) uuidsByUser.set(userId, []);
|
||||
uuidsByUser.get(userId).push(String(r.uuid));
|
||||
if (
|
||||
r.kind === 'web' &&
|
||||
r.parent_session_id &&
|
||||
!queued.has(String(r.uuid))
|
||||
) {
|
||||
queued.add(String(r.uuid));
|
||||
roots.push(String(r.uuid));
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...uuidsByUser].map(([userId, uuids]) => ({ userId, uuids }));
|
||||
@@ -777,57 +759,6 @@ export class SessionStore extends PuterStore {
|
||||
return { row, replaced: found ?? null };
|
||||
}
|
||||
|
||||
/**
|
||||
* The `web` session one of the deployment's own pages runs on under a
|
||||
* sign-in: the live one for (`parentSessionUid`, `origin`) if there is one,
|
||||
* else a new row. Revoking the parent cascades here. Concurrent first asks
|
||||
* can each insert a row; both stay valid.
|
||||
*
|
||||
* @param {number} userId - User row id.
|
||||
* @param {{
|
||||
* parentSessionUid: string;
|
||||
* origin: string;
|
||||
* expiresAt: number;
|
||||
* meta?: Record<string, unknown>;
|
||||
* auth_id?: string;
|
||||
* }} opts
|
||||
* - `origin` is the page's origin, kept in `meta.first_party_origin`. `meta`
|
||||
* is the request context, as on any `web` row.
|
||||
*/
|
||||
async getOrCreateFirstPartySession(
|
||||
userId,
|
||||
{ parentSessionUid, origin, expiresAt, meta = {}, auth_id },
|
||||
) {
|
||||
if (!userId || !parentSessionUid || !origin) return null;
|
||||
|
||||
const now = nowSeconds();
|
||||
// One per listed origin under a sign-in, so a handful at most.
|
||||
const rows = await this.clients.db.pread(
|
||||
"SELECT * FROM `sessions` WHERE `kind` = 'web' AND `user_id` = ? AND `parent_session_id` = ? AND `created_via` = ? AND `revoked_at` IS NULL ORDER BY `id` DESC LIMIT 20",
|
||||
[userId, parentSessionUid, FIRST_PARTY_SESSION_VIA],
|
||||
);
|
||||
const found = rows
|
||||
.map((row) => this.#normalizeRow(row))
|
||||
.find(
|
||||
(row) =>
|
||||
row &&
|
||||
!isExpired(row, now) &&
|
||||
row.meta?.first_party_origin === origin,
|
||||
);
|
||||
if (found) return found;
|
||||
|
||||
return this.create(userId, {
|
||||
kind: 'web',
|
||||
parent_session_id: parentSessionUid,
|
||||
created_via: FIRST_PARTY_SESSION_VIA,
|
||||
meta: { ...meta, first_party_origin: origin },
|
||||
last_ip: meta.ip ?? null,
|
||||
last_user_agent: meta.user_agent ?? null,
|
||||
expires_at: expiresAt,
|
||||
auth_id,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Idempotent "give me the app session for this (user, app)" lookup. Returns
|
||||
* the existing active app session if one exists, or creates a new one.
|
||||
|
||||
+3
-15
@@ -269,12 +269,7 @@ export interface IPreludeConfig {
|
||||
* an RCS agent provisioned in the Prelude account to actually use RCS.
|
||||
*/
|
||||
preferredChannel?:
|
||||
| 'sms'
|
||||
| 'rcs'
|
||||
| 'whatsapp'
|
||||
| 'viber'
|
||||
| 'zalo'
|
||||
| 'telegram';
|
||||
'sms' | 'rcs' | 'whatsapp' | 'viber' | 'zalo' | 'telegram';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -854,14 +849,6 @@ interface IConfigOptional {
|
||||
* obtain its token via the AuthMe flow instead.
|
||||
*/
|
||||
allow_gui_origins?: string[];
|
||||
/**
|
||||
* Origins of this deployment's own pages, served apart from the GUI, that
|
||||
* the GUI's sign-in may hand a session of their own instead of an app token
|
||||
* (see `AuthService.createFirstPartySessionToken`). Exact origins only.
|
||||
* Such a page acts as the account, so list only pages this deployment
|
||||
* builds and serves itself.
|
||||
*/
|
||||
first_party_session_origins?: string[];
|
||||
/** HMAC secret for signed file URLs (/file, /writeFile, /sign). */
|
||||
url_signature_secret: string;
|
||||
/**
|
||||
@@ -1424,7 +1411,8 @@ export interface ServerDrainInfo {
|
||||
}
|
||||
|
||||
export interface WithCostsReporting extends WithLifecycle {
|
||||
getReportedCosts?: () => // eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
getReportedCosts?: () =>
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
| Promise<Record<string, any>[]>
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
| Record<string, any>[];
|
||||
|
||||
@@ -23,7 +23,7 @@ puter.workers.create(workerName, filePath, options)
|
||||
<div class="info">Workers cannot be larger than <strong>10MB</strong>.</div>
|
||||
|
||||
#### `workerName` (String)(Required)
|
||||
The name for the worker. It can contain letters, numbers, hyphens, and underscores.
|
||||
The name for the worker. It can contain letters, numbers, hyphens, and underscores, and can be up to 50 characters long. A longer name is rejected with `400 bad_request`.
|
||||
|
||||
#### `filePath` (String)(Required)
|
||||
The path to a JavaScript file in your Puter account that contains your [router](/Workers/router/) code.
|
||||
@@ -48,6 +48,8 @@ A `Promise` that resolves to a [`WorkerDeployment`](/Objects/workerdeployment) o
|
||||
|
||||
On failure, throws an `Error` with the reason.
|
||||
|
||||
With the default sandbox, if another user already owns the `sandbox-<workerName>` app, the call rejects with `{ message, code: 'sandbox_not_owned' }`. To deploy anyway, choose another worker name or pass `{ sandbox: false }`.
|
||||
|
||||
## Worker identity and shared state
|
||||
|
||||
Every worker runs as some app, and that identity decides which [`puter.kv`](/KV/) namespace and which `AppData` directory the worker reaches. Two workers running as the same app read and write **the same** KV keys and the same files.
|
||||
|
||||
@@ -6,6 +6,8 @@ platforms: [websites, apps, nodejs, workers]
|
||||
|
||||
Deletes an existing worker and stops its execution.
|
||||
|
||||
If the worker runs in its own `sandbox-<workerName>` app, that app is deleted with it, unless another worker still runs as it. A worker you create later under the same name gets a new sandbox app, so it starts with an empty KV namespace and AppData directory.
|
||||
|
||||
## Syntax
|
||||
|
||||
```js
|
||||
|
||||
@@ -408,6 +408,8 @@ For long-lived mounts, sign in with a `-token` username and an API token as the
|
||||
| Concurrent worker calls | 10 | 5 | 3 |
|
||||
| Concurrent deploys | 5 | 2 | 2 |
|
||||
|
||||
A worker's name may be up to 50 characters. A [`puter.workers.create()`](/Workers/create/) call with a longer name is rejected with `400 bad_request`.
|
||||
|
||||
### Apps
|
||||
|
||||
| Operation | Paid | Free | Anonymous |
|
||||
|
||||
@@ -31,12 +31,14 @@
|
||||
// Either name becomes a hostname label, which DNS caps at 63; the backend
|
||||
// takes a 64-character website name, but nothing can reach it.
|
||||
const LABEL_MAX_LENGTH = 63;
|
||||
// A worker is stored as `workers.puter.<name>` in a 64-character column.
|
||||
const WORKER_NAME_MAX_LENGTH = 50;
|
||||
|
||||
/** @type {{ website: PublishNameRule, worker: PublishNameRule }} */
|
||||
export const PUBLISH_NAME_RULES = {
|
||||
// A website name can't hold an underscore, so it separates words too.
|
||||
website: { separators: /[\s_]+/g, invalid: /[^a-z0-9-]/g, maxLength: LABEL_MAX_LENGTH },
|
||||
worker: { separators: /\s+/g, invalid: /[^a-z0-9_-]/g, maxLength: LABEL_MAX_LENGTH },
|
||||
worker: { separators: /\s+/g, invalid: /[^a-z0-9_-]/g, maxLength: WORKER_NAME_MAX_LENGTH },
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -57,8 +57,11 @@ describe('finalizePublishName', () => {
|
||||
expect(finalizePublishName('!!! ', website)).toBe('');
|
||||
});
|
||||
|
||||
it('caps the length at a hostname label\'s 63', () => {
|
||||
it('caps a website name at a hostname label\'s 63', () => {
|
||||
expect(finalizePublishName('a'.repeat(80), website)).toHaveLength(63);
|
||||
expect(finalizePublishName('a'.repeat(80), worker)).toHaveLength(63);
|
||||
});
|
||||
|
||||
it('caps a worker name at the 50 its subdomain row can hold', () => {
|
||||
expect(finalizePublishName('a'.repeat(80), worker)).toHaveLength(50);
|
||||
});
|
||||
});
|
||||
+58
-12
@@ -24,7 +24,6 @@ import UIAuthLogin from './Auth/UIAuthLogin.js';
|
||||
import UIWindowItemProperties from './UIWindowItemProperties.js';
|
||||
import new_context_menu_item from '../helpers/newContextMenuItem.js';
|
||||
import refresh_item_container from '../helpers/refreshItemContainer.js';
|
||||
import { setFolderView } from '../helpers/folderViewPrefs.js';
|
||||
import { atPath } from '../helpers/pathElements.js';
|
||||
import { resolve_typed_path } from '../helpers/expandHomePath.js';
|
||||
import UIWindowAppFeedback from './UIWindowAppFeedback.js';
|
||||
@@ -3090,6 +3089,9 @@ window.update_window_path = async function (el_window, target_path) {
|
||||
$(el_window).removeClass(`window-${ $(el_window).attr('data-uid')}`);
|
||||
$(el_window).addClass(`window-${ fsentry.id}`);
|
||||
$(el_window).attr('data-uid', fsentry.id);
|
||||
$(el_window).attr('data-sort_by', fsentry.sort_by ?? 'name');
|
||||
$(el_window).attr('data-sort_order', fsentry.sort_order ?? 'asc');
|
||||
$(el_window).attr('data-layout', fsentry.layout ?? 'icons');
|
||||
$(el_window_item_container).attr('data-uid', fsentry.id);
|
||||
// title - use i18n for system directories
|
||||
if ( target_path === window.home_path )
|
||||
@@ -3130,7 +3132,12 @@ window.update_window_path = async function (el_window, target_path) {
|
||||
$(el_window).attr('data-path', target_path);
|
||||
$(el_window_navbar_path_input).val(target_path);
|
||||
$(el_window_navbar_path_input).attr('data-path', target_path);
|
||||
// The folder's sort and layout are applied by refresh_item_container below.
|
||||
// update layout
|
||||
window.update_window_layout(el_window, fsentry.layout);
|
||||
// update explore header if in details view
|
||||
if ( fsentry.layout === 'details' ) {
|
||||
window.update_details_layout_sort_visuals(el_window, fsentry.sort_by, fsentry.sort_order);
|
||||
}
|
||||
}).catch(() => {
|
||||
if ( $(el_window).attr('data-path') !== target_path ) {
|
||||
return;
|
||||
@@ -3482,7 +3489,27 @@ window.set_sort_by = function (item_uid, sort_by, sort_order) {
|
||||
sort_order = 'asc';
|
||||
}
|
||||
|
||||
setFolderView(item_uid, { sort_by, sort_order });
|
||||
$.ajax({
|
||||
url: `${window.api_origin }/set_sort_by`,
|
||||
type: 'POST',
|
||||
data: JSON.stringify({
|
||||
sort_by: sort_by,
|
||||
item_uid: item_uid,
|
||||
sort_order: sort_order,
|
||||
}),
|
||||
async: true,
|
||||
contentType: 'application/json',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${window.auth_token}`,
|
||||
},
|
||||
statusCode: {
|
||||
401: function (xhr) {
|
||||
window.handle401(xhr);
|
||||
},
|
||||
},
|
||||
success: function () {
|
||||
},
|
||||
});
|
||||
// update the sort_by & sort_order attr of every matching element
|
||||
$(`[data-uid="${item_uid}"]`).attr({
|
||||
'data-sort_by': sort_by,
|
||||
@@ -4960,15 +4987,34 @@ $(document).on('click', '.explore-table-headers-th', function (e) {
|
||||
});
|
||||
|
||||
window.set_layout = function (item_uid, layout) {
|
||||
setFolderView(item_uid, { layout });
|
||||
if ( layout === 'details' ) {
|
||||
let el_window = $(`.window[data-uid="${item_uid}"]`);
|
||||
if ( el_window.length > 0 ) {
|
||||
let sort_by = el_window.attr('data-sort_by');
|
||||
let sort_order = el_window.attr('data-sort_order');
|
||||
window.update_details_layout_sort_visuals(el_window, sort_by, sort_order);
|
||||
}
|
||||
}
|
||||
$.ajax({
|
||||
url: `${window.api_origin }/set_layout`,
|
||||
type: 'POST',
|
||||
data: JSON.stringify({
|
||||
item_uid: item_uid,
|
||||
layout: layout,
|
||||
}),
|
||||
async: true,
|
||||
contentType: 'application/json',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${window.auth_token}`,
|
||||
},
|
||||
statusCode: {
|
||||
401: function (xhr) {
|
||||
window.handle401(xhr);
|
||||
},
|
||||
},
|
||||
success: function () {
|
||||
if ( layout === 'details' ) {
|
||||
let el_window = $(`.window[data-uid="${item_uid}"]`);
|
||||
if ( el_window.length > 0 ) {
|
||||
let sort_by = el_window.attr('data-sort_by');
|
||||
let sort_order = el_window.attr('data-sort_order');
|
||||
window.update_details_layout_sort_visuals(el_window, sort_by, sort_order);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
window.update_details_layout_sort_visuals = function (el_window, sort_by, sort_order) {
|
||||
|
||||
@@ -47,24 +47,12 @@ const deviceIconSvg = (session) => {
|
||||
if ( session.kind === 'worker' ) return ICONS.worker;
|
||||
if ( session.kind === 'access_token' ) return ICONS.key;
|
||||
if ( session.kind === 'app' ) return ICONS.app;
|
||||
if ( firstPartyHost(session) ) return ICONS.globe;
|
||||
const { os } = parseUserAgent(session.last_user_agent);
|
||||
if ( os === 'iOS' || os === 'Android' ) return ICONS.phone;
|
||||
if ( os ) return ICONS.laptop;
|
||||
return ICONS.globe;
|
||||
};
|
||||
|
||||
// Host of the first-party page a session was handed to under a sign-in, or
|
||||
// null for any other session.
|
||||
export const firstPartyHost = (session) => {
|
||||
if ( typeof session?.first_party_origin !== 'string' ) return null;
|
||||
try {
|
||||
return new URL(session.first_party_origin).host || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// Short, human label for the kind pill (falls back to the raw kind).
|
||||
const kindBadgeLabel = (kind) => {
|
||||
switch ( kind ) {
|
||||
@@ -315,8 +303,7 @@ const UIWindowManageSessions = async function UIWindowManageSessions (options) {
|
||||
|| i18n('ui_session_kind_access_token') || 'Access token';
|
||||
}
|
||||
if ( session.kind === 'web' ) {
|
||||
return session.label || firstPartyHost(session)
|
||||
|| i18n('ui_session_kind_web') || 'Browser session';
|
||||
return session.label || i18n('ui_session_kind_web') || 'Browser session';
|
||||
}
|
||||
return session.label || session.kind || 'Session';
|
||||
};
|
||||
@@ -333,7 +320,6 @@ const UIWindowManageSessions = async function UIWindowManageSessions (options) {
|
||||
sessionTitle(session),
|
||||
session.label,
|
||||
session.kind,
|
||||
firstPartyHost(session),
|
||||
session.last_ip,
|
||||
session.app?.title,
|
||||
session.app?.name,
|
||||
@@ -523,11 +509,6 @@ const UIWindowManageSessions = async function UIWindowManageSessions (options) {
|
||||
b.classList.add('session-widget-badge', `session-widget-badge-${session.kind}`);
|
||||
b.textContent = kindBadgeLabel(session.kind);
|
||||
el_badges.appendChild(b);
|
||||
} else if ( firstPartyHost(session) ) {
|
||||
const b = document.createElement('span');
|
||||
b.classList.add('session-widget-badge', 'session-widget-badge-site');
|
||||
b.textContent = i18n('ui_session_kind_site') || 'Site session';
|
||||
el_badges.appendChild(b);
|
||||
}
|
||||
el_titleline.appendChild(el_badges);
|
||||
el_main.appendChild(el_titleline);
|
||||
|
||||
@@ -3988,11 +3988,6 @@ html.dark-mode .usage-table-fade-overlay {
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
.session-widget-badge-site {
|
||||
background: #dcfce7;
|
||||
color: #15803d;
|
||||
}
|
||||
|
||||
/* Meta lines */
|
||||
.session-widget-meta {
|
||||
display: flex;
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Pages of this deployment that are served apart from the GUI sign in with a
|
||||
* session of their own rather than an app token. The server lists their
|
||||
* origins (`gui_params.first_party_session_origins`) and mints the session.
|
||||
*/
|
||||
|
||||
const originOf = (value) => {
|
||||
try {
|
||||
return new URL(value).origin;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether a sign-in for the page at `origin` hands over a session.
|
||||
*
|
||||
* @param {string} origin
|
||||
* @param {string[]} [listed]
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export const isFirstPartySessionOrigin = (
|
||||
origin,
|
||||
listed = window.gui_params?.first_party_session_origins,
|
||||
) => {
|
||||
const wanted = originOf(origin);
|
||||
if ( !wanted || !Array.isArray(listed) ) return false;
|
||||
return listed.some(entry => originOf(entry) === wanted);
|
||||
};
|
||||
|
||||
/**
|
||||
* The token a sign-in hands the page that asked for it: a session of the
|
||||
* page's own for a first-party page, else the app token the exchange returned.
|
||||
* Throws when the session can't be minted, since an app token can't run such
|
||||
* a page.
|
||||
*
|
||||
* @param {string} origin - the page's attested origin
|
||||
* @param {string} appToken
|
||||
* @param {object} [deps]
|
||||
* @param {string[]} [deps.listed]
|
||||
* @param {typeof fetch} [deps.fetchImpl]
|
||||
* @param {string} [deps.apiOrigin]
|
||||
* @param {string} [deps.authToken] - the GUI session token
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
export const signInTokenForOpener = async (origin, appToken, deps = {}) => {
|
||||
if ( ! isFirstPartySessionOrigin(origin, deps.listed) ) return appToken;
|
||||
|
||||
const fetchImpl = deps.fetchImpl ?? fetch;
|
||||
const apiOrigin = deps.apiOrigin ?? window.api_origin;
|
||||
const authToken = deps.authToken ?? window.auth_token;
|
||||
const response = await fetchImpl(`${apiOrigin}/auth/first-party-session`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${authToken}`,
|
||||
},
|
||||
body: JSON.stringify({ origin: originOf(origin) }),
|
||||
});
|
||||
const body = response.ok ? await response.json() : null;
|
||||
if ( typeof body?.token !== 'string' || !body.token ) {
|
||||
throw new Error('first-party session request returned no token');
|
||||
}
|
||||
return body.token;
|
||||
};
|
||||
@@ -1,80 +0,0 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { isFirstPartySessionOrigin, signInTokenForOpener } from './firstPartySession.js';
|
||||
|
||||
const PAGE = 'https://desk.puter.test';
|
||||
const listed = [`${PAGE}/`];
|
||||
|
||||
const answering = (status, body) => vi.fn(async () => ({
|
||||
ok: status >= 200 && status < 300,
|
||||
json: async () => body,
|
||||
}));
|
||||
|
||||
describe('isFirstPartySessionOrigin', () => {
|
||||
it('matches a listed origin exactly', () => {
|
||||
expect(isFirstPartySessionOrigin(PAGE, listed)).toBe(true);
|
||||
expect(isFirstPartySessionOrigin(`${PAGE}/some/path`, listed)).toBe(true);
|
||||
});
|
||||
|
||||
it('matches nothing else', () => {
|
||||
expect(isFirstPartySessionOrigin('http://desk.puter.test', listed)).toBe(false);
|
||||
expect(isFirstPartySessionOrigin('https://desk.puter.test:8443', listed)).toBe(false);
|
||||
expect(isFirstPartySessionOrigin('https://x.desk.puter.test', listed)).toBe(false);
|
||||
expect(isFirstPartySessionOrigin('https://desk.puter.test.evil.example', listed)).toBe(false);
|
||||
expect(isFirstPartySessionOrigin('', listed)).toBe(false);
|
||||
expect(isFirstPartySessionOrigin(PAGE, null)).toBe(false);
|
||||
expect(isFirstPartySessionOrigin(PAGE, [])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('signInTokenForOpener', () => {
|
||||
const deps = (fetchImpl) => ({
|
||||
listed,
|
||||
fetchImpl,
|
||||
apiOrigin: 'https://api.puter.test',
|
||||
authToken: 'gui-token',
|
||||
});
|
||||
|
||||
it('hands any other page its app token without asking the server', async () => {
|
||||
const fetchImpl = answering(200, { token: 'session-token' });
|
||||
const token = await signInTokenForOpener('https://notes.example.com', 'app-token', deps(fetchImpl));
|
||||
expect(token).toBe('app-token');
|
||||
expect(fetchImpl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('hands a first-party page the session the server mints for its origin', async () => {
|
||||
const fetchImpl = answering(200, { token: 'session-token' });
|
||||
const token = await signInTokenForOpener(PAGE, 'app-token', deps(fetchImpl));
|
||||
expect(token).toBe('session-token');
|
||||
expect(fetchImpl).toHaveBeenCalledOnce();
|
||||
const [url, init] = fetchImpl.mock.calls[0];
|
||||
expect(url).toBe('https://api.puter.test/auth/first-party-session');
|
||||
expect(init.headers.Authorization).toBe('Bearer gui-token');
|
||||
expect(JSON.parse(init.body)).toEqual({ origin: PAGE });
|
||||
});
|
||||
|
||||
it('fails rather than fall back to the app token', async () => {
|
||||
await expect(signInTokenForOpener(PAGE, 'app-token', deps(answering(403, { code: 'forbidden' }))))
|
||||
.rejects.toThrow();
|
||||
await expect(signInTokenForOpener(PAGE, 'app-token', deps(answering(200, {}))))
|
||||
.rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -1,112 +0,0 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
/**
|
||||
* How each folder is shown — its sort and its layout — kept in the user's
|
||||
* key-value store, one key per folder uid, as `{ sort_by, sort_order, layout }`.
|
||||
*/
|
||||
|
||||
export const LAYOUTS = ['icons', 'list', 'details'];
|
||||
export const SORT_BYS = ['name', 'modified', 'type', 'size'];
|
||||
export const SORT_ORDERS = ['asc', 'desc'];
|
||||
|
||||
export const DEFAULT_FOLDER_VIEW = Object.freeze({
|
||||
sort_by: 'name',
|
||||
sort_order: 'asc',
|
||||
layout: 'icons',
|
||||
});
|
||||
|
||||
const ALLOWED = { sort_by: SORT_BYS, sort_order: SORT_ORDERS, layout: LAYOUTS };
|
||||
|
||||
export const folderViewKey = uid => `folder_view:${uid}`;
|
||||
|
||||
/**
|
||||
* The known preferences in `value`, without anything unrecognized.
|
||||
*
|
||||
* @param {unknown} value
|
||||
* @returns {{ sort_by?: string, sort_order?: string, layout?: string }}
|
||||
*/
|
||||
export function pickFolderView(value) {
|
||||
if ( typeof value === 'string' ) {
|
||||
try {
|
||||
value = JSON.parse(value);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
const picked = {};
|
||||
if ( !value || typeof value !== 'object' ) return picked;
|
||||
for ( const [field, allowed] of Object.entries(ALLOWED) ) {
|
||||
if ( allowed.includes(value[field]) ) picked[field] = value[field];
|
||||
}
|
||||
return picked;
|
||||
}
|
||||
|
||||
const usableUid = uid => typeof uid === 'string' && uid !== '' && uid !== 'null' && uid !== 'undefined';
|
||||
|
||||
// The last stored value this session knows of, per folder, so a change to
|
||||
// one preference keeps the others it doesn't touch.
|
||||
const known = new Map();
|
||||
|
||||
const kv = () => globalThis.puter.kv;
|
||||
|
||||
/**
|
||||
* How to show a folder: what's stored for it, then what its fsentry carries
|
||||
* (the server kept a layout there before), then the defaults.
|
||||
*
|
||||
* @param {object | null | undefined} fsentry - from `puter.fs.stat()`
|
||||
* @returns {Promise<{ sort_by: string, sort_order: string, layout: string }>}
|
||||
*/
|
||||
export async function getFolderView(fsentry) {
|
||||
const uid = fsentry?.uid ?? fsentry?.id;
|
||||
let stored = {};
|
||||
if ( usableUid(uid) ) {
|
||||
const read = kv().get(folderViewKey(uid)).then(pickFolderView);
|
||||
known.set(uid, read.catch(() => ({})));
|
||||
try {
|
||||
stored = await read;
|
||||
} catch ( err ) {
|
||||
console.warn('Could not load folder view:', err);
|
||||
}
|
||||
}
|
||||
return { ...DEFAULT_FOLDER_VIEW, ...pickFolderView(fsentry), ...stored };
|
||||
}
|
||||
|
||||
/**
|
||||
* Store some of a folder's preferences; the rest stay as they were.
|
||||
*
|
||||
* @param {string} uid
|
||||
* @param {{ sort_by?: string, sort_order?: string, layout?: string }} changes
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export function setFolderView(uid, changes) {
|
||||
if ( ! usableUid(uid) ) return Promise.resolve();
|
||||
const base = known.get(uid)
|
||||
?? kv().get(folderViewKey(uid)).then(pickFolderView).catch(() => ({}));
|
||||
const next = base.then(stored => ({ ...stored, ...pickFolderView(changes) }));
|
||||
known.set(uid, next);
|
||||
return next
|
||||
.then(value => kv().set(folderViewKey(uid), value))
|
||||
.catch(err => console.warn('Could not save folder view:', err));
|
||||
}
|
||||
|
||||
/** Forget what this session knows of stored values; for tests. */
|
||||
export function resetFolderViewCache() {
|
||||
known.clear();
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
DEFAULT_FOLDER_VIEW,
|
||||
folderViewKey,
|
||||
getFolderView,
|
||||
pickFolderView,
|
||||
resetFolderViewCache,
|
||||
setFolderView,
|
||||
} from './folderViewPrefs.js';
|
||||
|
||||
let store;
|
||||
let kv;
|
||||
|
||||
beforeEach(() => {
|
||||
store = new Map();
|
||||
kv = {
|
||||
get: vi.fn(async key => store.get(key) ?? null),
|
||||
set: vi.fn(async (key, value) => {
|
||||
store.set(key, value);
|
||||
return true;
|
||||
}),
|
||||
};
|
||||
vi.stubGlobal('puter', { kv });
|
||||
resetFolderViewCache();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('pickFolderView', () => {
|
||||
it('keeps known values only', () => {
|
||||
expect(pickFolderView({
|
||||
sort_by: 'size',
|
||||
sort_order: 'sideways',
|
||||
layout: 'details',
|
||||
extra: 1,
|
||||
})).toEqual({ sort_by: 'size', layout: 'details' });
|
||||
});
|
||||
|
||||
it('reads a JSON string and ignores anything else', () => {
|
||||
expect(pickFolderView('{"sort_order":"desc"}')).toEqual({ sort_order: 'desc' });
|
||||
expect(pickFolderView('nope')).toEqual({});
|
||||
expect(pickFolderView(null)).toEqual({});
|
||||
expect(pickFolderView(7)).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getFolderView', () => {
|
||||
it('prefers what is stored, then the fsentry, then the defaults', async () => {
|
||||
store.set(folderViewKey('dir'), { sort_by: 'modified' });
|
||||
expect(await getFolderView({ uid: 'dir', layout: 'list', sort_by: 'size' })).toEqual({
|
||||
sort_by: 'modified',
|
||||
sort_order: 'asc',
|
||||
layout: 'list',
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back when the store can\'t be read', async () => {
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
kv.get.mockRejectedValue(new Error('offline'));
|
||||
expect(await getFolderView({ id: 'dir', layout: 'details' }))
|
||||
.toEqual({ ...DEFAULT_FOLDER_VIEW, layout: 'details' });
|
||||
});
|
||||
|
||||
it('doesn\'t ask the store about a folder without a uid', async () => {
|
||||
expect(await getFolderView({ uid: 'null' })).toEqual(DEFAULT_FOLDER_VIEW);
|
||||
expect(await getFolderView(undefined)).toEqual(DEFAULT_FOLDER_VIEW);
|
||||
expect(kv.get).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('setFolderView', () => {
|
||||
it('keeps the preferences a change doesn\'t touch', async () => {
|
||||
store.set(folderViewKey('dir'), { layout: 'details', sort_by: 'size' });
|
||||
await setFolderView('dir', { sort_by: 'name', sort_order: 'desc' });
|
||||
expect(store.get(folderViewKey('dir'))).toEqual({
|
||||
layout: 'details',
|
||||
sort_by: 'name',
|
||||
sort_order: 'desc',
|
||||
});
|
||||
});
|
||||
|
||||
it('builds on the last change, not on a read that predates it', async () => {
|
||||
await getFolderView({ uid: 'dir' });
|
||||
const sort = setFolderView('dir', { sort_by: 'type' });
|
||||
const layout = setFolderView('dir', { layout: 'list' });
|
||||
await Promise.all([sort, layout]);
|
||||
expect(store.get(folderViewKey('dir'))).toEqual({ sort_by: 'type', layout: 'list' });
|
||||
expect(kv.get).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('drops unknown values and folders without a uid', async () => {
|
||||
await setFolderView('dir', { layout: 'grid', sort_by: 'size' });
|
||||
expect(store.get(folderViewKey('dir'))).toEqual({ sort_by: 'size' });
|
||||
await setFolderView('null', { layout: 'list' });
|
||||
await setFolderView(undefined, { layout: 'list' });
|
||||
expect(kv.set).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('reports a failed save instead of throwing', async () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
kv.set.mockRejectedValue(new Error('offline'));
|
||||
await expect(setFolderView('dir', { layout: 'list' })).resolves.toBeUndefined();
|
||||
expect(warn).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -23,7 +23,6 @@ import item_icon from './itemIcon.js';
|
||||
import list_all_shared from './listAllShared.js';
|
||||
import parse_item_metadata from './parseItemMetadata.js';
|
||||
import { remember_shared_roots } from './sharedAccess.js';
|
||||
import { getFolderView } from './folderViewPrefs.js';
|
||||
|
||||
const refresh_item_container = function (el_item_container, options) {
|
||||
// start a transaction
|
||||
@@ -106,6 +105,9 @@ const refresh_item_container = function (el_item_container, options) {
|
||||
// the class is how a deleted folder's windows are found, and a window opened by path starts without it
|
||||
$(el_window).removeClass(`window-${$(el_window).attr('data-uid')}`).addClass(`window-${fsentry.id}`);
|
||||
$(el_window).attr('data-uid', fsentry.id);
|
||||
$(el_window).attr('data-sort_by', fsentry.sort_by ?? 'name');
|
||||
$(el_window).attr('data-sort_order', fsentry.sort_order ?? 'asc');
|
||||
$(el_window).attr('data-layout', fsentry.layout ?? 'icons');
|
||||
// data-name
|
||||
$(el_window).attr('data-name', fsentry.name);
|
||||
// data-path
|
||||
@@ -113,27 +115,17 @@ const refresh_item_container = function (el_item_container, options) {
|
||||
$(el_window).find('.window-navbar-path-input').val(container_path);
|
||||
$(el_window).find('.window-navbar-path-input').attr('data-path', container_path);
|
||||
}
|
||||
return getFolderView(fsentry).then(view => {
|
||||
if ( ! is_current() ) {
|
||||
return;
|
||||
}
|
||||
// Items may already be in, placed by the sort the container had before.
|
||||
const resort = $(el_item_container).attr('data-sort_by') !== view.sort_by
|
||||
|| $(el_item_container).attr('data-sort_order') !== view.sort_order;
|
||||
$(el_item_container).attr('data-sort_by', view.sort_by);
|
||||
$(el_item_container).attr('data-sort_order', view.sort_order);
|
||||
if ( el_window && el_window.length > 0 ) {
|
||||
$(el_window).attr('data-sort_by', view.sort_by);
|
||||
$(el_window).attr('data-sort_order', view.sort_order);
|
||||
window.update_window_layout(el_window, view.layout);
|
||||
if ( view.layout === 'details' ) {
|
||||
window.update_details_layout_sort_visuals(el_window, view.sort_by, view.sort_order);
|
||||
}
|
||||
}
|
||||
if ( resort ) {
|
||||
window.sort_items(el_item_container, view.sort_by, view.sort_order);
|
||||
}
|
||||
});
|
||||
$(el_item_container).attr('data-sort_by', fsentry.sort_by ?? 'name');
|
||||
$(el_item_container).attr('data-sort_order', fsentry.sort_order ?? 'asc');
|
||||
// update layout
|
||||
if ( el_window && el_window.length > 0 )
|
||||
{
|
||||
window.update_window_layout(el_window, fsentry.layout);
|
||||
}
|
||||
//
|
||||
if ( fsentry.layout === 'details' ) {
|
||||
window.update_details_layout_sort_visuals(el_window, fsentry.sort_by, fsentry.sort_order);
|
||||
}
|
||||
}).catch(() => {
|
||||
// The listing request below reports the failure to the user.
|
||||
});
|
||||
|
||||
@@ -229,57 +229,3 @@ describe('refresh_item_container after the window moves to another folder', () =
|
||||
expect(renderedPaths()).toEqual(['/alice/Documents/a.txt']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('refresh_item_container and the folder\'s stored view', () => {
|
||||
let views;
|
||||
|
||||
beforeEach(() => {
|
||||
views = {};
|
||||
puter.kv = {
|
||||
get: vi.fn((key) => {
|
||||
views[key] ??= deferred();
|
||||
return views[key].promise;
|
||||
}),
|
||||
};
|
||||
puter.fs.stat.mockImplementation(async ({ path }) => ({ id: path.slice(1), name: path.slice(1) }));
|
||||
puter.fs.readdir.mockResolvedValue([]);
|
||||
vi.spyOn(window, 'sort_items');
|
||||
vi.spyOn(window, 'update_window_layout');
|
||||
document.body.innerHTML =
|
||||
'<div class="window"><div class="item-container" data-sort_by="name" data-sort_order="asc"></div></div>';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete puter.kv;
|
||||
});
|
||||
|
||||
it('lays out and re-sorts the folder by what is stored for it', async () => {
|
||||
navigate('/docs');
|
||||
await vi.waitFor(() => expect(views['folder_view:docs']).toBeDefined());
|
||||
views['folder_view:docs'].resolve({ sort_by: 'size', sort_order: 'desc', layout: 'details' });
|
||||
|
||||
await vi.waitFor(() => expect(window.sort_items).toHaveBeenCalledWith(
|
||||
expect.anything(), 'size', 'desc',
|
||||
));
|
||||
expect($('.item-container').attr('data-sort_by')).toBe('size');
|
||||
expect($('.window').attr('data-sort_order')).toBe('desc');
|
||||
expect(window.update_window_layout).toHaveBeenCalledWith(expect.anything(), 'details');
|
||||
});
|
||||
|
||||
it('ignores the view of a folder the window has left', async () => {
|
||||
navigate('/docs');
|
||||
await vi.waitFor(() => expect(views['folder_view:docs']).toBeDefined());
|
||||
navigate('/pics');
|
||||
await vi.waitFor(() => expect(views['folder_view:pics']).toBeDefined());
|
||||
|
||||
views['folder_view:pics'].resolve(null);
|
||||
views['folder_view:docs'].resolve({ sort_by: 'size', layout: 'list' });
|
||||
await vi.waitFor(() => expect(window.update_window_layout).toHaveBeenCalledOnce());
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
expect(window.update_window_layout).toHaveBeenCalledWith(expect.anything(), 'icons');
|
||||
expect(window.update_window_layout).not.toHaveBeenCalledWith(expect.anything(), 'list');
|
||||
expect($('.item-container').attr('data-sort_by')).toBe('name');
|
||||
expect(window.sort_items).not.toHaveBeenCalledWith(expect.anything(), 'size', expect.anything());
|
||||
});
|
||||
});
|
||||
@@ -701,7 +701,6 @@ const en = {
|
||||
ui_session_expires: 'Expires',
|
||||
ui_session_kind_access_token: 'Access token',
|
||||
ui_session_kind_app: 'App session',
|
||||
ui_session_kind_site: 'Site session',
|
||||
ui_session_kind_web: 'Browser session',
|
||||
ui_session_kind_worker: 'Worker',
|
||||
ui_session_last_active: 'Last active',
|
||||
|
||||
@@ -61,9 +61,7 @@ describe('the popup token hand-off in postAuthActions', () => {
|
||||
const steps = [
|
||||
postAuthActions.indexOf('fetch(`${window.api_origin}/login/set`'),
|
||||
postAuthActions.indexOf('runsUserAppTokenExchange(action)'),
|
||||
postAuthActions.search(
|
||||
/success: true,\s+token: await signInTokenForOpener\(origin, data\.token\)/,
|
||||
),
|
||||
postAuthActions.search(/success: true,\s+token: data\.token/),
|
||||
];
|
||||
for (const step of steps) {
|
||||
expect(step).toBeGreaterThan(gate);
|
||||
|
||||
+3
-10
@@ -88,7 +88,6 @@ import item_icon from './helpers/itemIcon.js';
|
||||
import { installAppIconFallback } from './helpers/appIcon.js';
|
||||
import launch_app from './helpers/launchApp.js';
|
||||
import { urlFileLaunchOptions } from './helpers/confirmUrlFileAccess.js';
|
||||
import { signInTokenForOpener } from './helpers/firstPartySession.js';
|
||||
import { readSignupBonusCode } from './helpers/signupBonusCode.js';
|
||||
import { guiTokenBootStep, pageRenderedForThisLoad, sameTokenClaims } from './helpers/guiToken.js';
|
||||
import { parse_url_paths } from './helpers/urlPaths.js';
|
||||
@@ -530,7 +529,7 @@ const postAuthActions = async (action) => {
|
||||
window.opener?.postMessage({
|
||||
msg: 'puter.token',
|
||||
success: true,
|
||||
token: await signInTokenForOpener(origin, data.token),
|
||||
token: data.token,
|
||||
app_uid: data.app_uid,
|
||||
username: window.user.username,
|
||||
msg_id: msg_id,
|
||||
@@ -2862,10 +2861,7 @@ window.initgui = async function (options) {
|
||||
msg: 'puter.token',
|
||||
success: true,
|
||||
msg_id: msg_id,
|
||||
token: await signInTokenForOpener(
|
||||
window.openerOrigin,
|
||||
data.token,
|
||||
),
|
||||
token: data.token,
|
||||
username: window.user.username,
|
||||
app_uid: data.app_uid,
|
||||
},
|
||||
@@ -2952,10 +2948,7 @@ window.initgui = async function (options) {
|
||||
msg: 'puter.token',
|
||||
success: true,
|
||||
msg_id: msg_id,
|
||||
token: await signInTokenForOpener(
|
||||
window.openerOrigin,
|
||||
data.token,
|
||||
),
|
||||
token: data.token,
|
||||
username: window.user.username,
|
||||
app_uid: data.app_uid,
|
||||
},
|
||||
|
||||
+18
-57
@@ -104,25 +104,6 @@ class Lock {
|
||||
// (using defaultGUIOrigin breaks locally-hosted apps)
|
||||
const PROD_ORIGIN = 'https://puter.com';
|
||||
|
||||
// The desktops Puter itself serves. An app learns which one launched it from
|
||||
// the URL (`puter.origin`), and only one of these is believed: the GUI origin
|
||||
// is what every message from the desktop is checked against, so taking an
|
||||
// arbitrary one would let any page that frames an app drive it.
|
||||
const FIRST_PARTY_GUI_ORIGINS = new Set([
|
||||
'https://puter.com',
|
||||
'https://desktop.puter.com',
|
||||
]);
|
||||
|
||||
/** `value`'s origin when it is a first-party desktop, otherwise null. */
|
||||
const firstPartyGUIOrigin = (value) => {
|
||||
try {
|
||||
const { origin } = new URL(value);
|
||||
return FIRST_PARTY_GUI_ORIGINS.has(origin) ? origin : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// localStorage key for the auth token. The retired key below is never read —
|
||||
// only deleted, so a token from before the format change doesn't linger in a
|
||||
// visitor's browser (see `discardRetiredAuthToken_`).
|
||||
@@ -232,9 +213,6 @@ export class Puter {
|
||||
|
||||
#defaultAPIOrigin = 'https://api.puter.com';
|
||||
#defaultGUIOrigin = 'https://puter.com';
|
||||
// In app mode, the first-party desktop that launched this app, when it
|
||||
// isn't the build's default one.
|
||||
#launchGUIOrigin = null;
|
||||
|
||||
/** @returns {string} */
|
||||
get defaultAPIOrigin() {
|
||||
@@ -252,7 +230,6 @@ export class Puter {
|
||||
get defaultGUIOrigin() {
|
||||
return (
|
||||
globalThis.PUTER_ORIGIN ||
|
||||
this.#launchGUIOrigin ||
|
||||
globalThis.PUTER_ORIGIN_ENV ||
|
||||
this.#defaultGUIOrigin
|
||||
);
|
||||
@@ -300,15 +277,6 @@ export class Puter {
|
||||
*/
|
||||
openerLaunchItem_ = null;
|
||||
|
||||
/**
|
||||
* Whether this is a tab the GUI opened for an app and handed a session
|
||||
* token, which it then keeps in sessionStorage the way a framed app does.
|
||||
*
|
||||
* @internal
|
||||
* @type {boolean}
|
||||
*/
|
||||
appTabSession_ = false;
|
||||
|
||||
// Holds the unique app instance ID that is provided by the host environment
|
||||
appInstanceID;
|
||||
|
||||
@@ -348,6 +316,14 @@ export class Puter {
|
||||
*/
|
||||
socketEnabled = globalThis.puter_socket_enabled !== false;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
* Whether this client reports an app open (`/rao`) when it authenticates.
|
||||
* Seeded off `puter_report_app_open`, which the worker runtimes set to
|
||||
* `false`: code running as the account isn't a visit.
|
||||
*/
|
||||
reportsAppOpen = globalThis.puter_report_app_open !== false;
|
||||
|
||||
/**
|
||||
* Puter.js Modules
|
||||
*
|
||||
@@ -657,15 +633,6 @@ export class Puter {
|
||||
this.APIOrigin = `https://api.${URLParams.get('puter.domain')}`;
|
||||
}
|
||||
|
||||
// An app launched by a first-party desktop other than puter.com
|
||||
// (e.g. desktop.puter.com) talks to that desktop: its messages come
|
||||
// from there, and replies meant only for it go there.
|
||||
if (this.env === 'app') {
|
||||
this.#launchGUIOrigin = firstPartyGUIOrigin(
|
||||
URLParams.get('puter.origin'),
|
||||
);
|
||||
}
|
||||
|
||||
// === START :: Logger ===
|
||||
|
||||
// Basic logger replacement (console-based)
|
||||
@@ -792,9 +759,6 @@ export class Puter {
|
||||
// the SDK needs to show a dialog to the user to ask for permission to access their Puter account.
|
||||
else if (this.env === 'web') {
|
||||
try {
|
||||
// A godmode app reloading in the tab the GUI opened it in;
|
||||
// only that handoff stores a session token on this origin.
|
||||
const godmodeToken = this.readStoredSessionToken_();
|
||||
const storedToken = this.normalizeAuthTokenCandidate(
|
||||
localStorage.getItem(STORAGE_KEY_V2),
|
||||
);
|
||||
@@ -807,10 +771,7 @@ export class Puter {
|
||||
const boundOrigin = this.normalizeStringCandidate(
|
||||
localStorage.getItem(STORAGE_KEY_ORIGIN_V2),
|
||||
);
|
||||
if (godmodeToken) {
|
||||
this.appTabSession_ = true;
|
||||
this.setAuthToken(godmodeToken);
|
||||
} else if (
|
||||
if (
|
||||
storedToken &&
|
||||
this._storedTokenUsableForCurrentOrigin(boundOrigin)
|
||||
) {
|
||||
@@ -847,12 +808,7 @@ export class Puter {
|
||||
launch.item,
|
||||
);
|
||||
}
|
||||
if (launch?.token) {
|
||||
this.appTabSession_ = this.isUserSessionToken_(
|
||||
launch.token,
|
||||
);
|
||||
this.setAuthToken(launch.token);
|
||||
}
|
||||
if (launch?.token) this.setAuthToken(launch.token);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -917,7 +873,12 @@ export class Puter {
|
||||
// when running as a Puter app (i.e. within an iframe in the GUI).
|
||||
// Same call opted out of by `socketEnabled: false` — that flag means
|
||||
// this client is a disposable per-invocation one, not a real visit.
|
||||
if (this.env === 'gui' || this.env === 'app' || !this.socketEnabled) {
|
||||
if (
|
||||
this.env === 'gui' ||
|
||||
this.env === 'app' ||
|
||||
!this.socketEnabled ||
|
||||
!this.reportsAppOpen
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1093,10 +1054,10 @@ export class Puter {
|
||||
|
||||
// If the SDK is running on a 3rd-party site or an app, then save the authToken in localStorage.
|
||||
// A user session token (a godmode app's launch token) never goes there:
|
||||
// an app keeps it in sessionStorage so a reload still has it.
|
||||
// an app keeps it in sessionStorage so a frame reload still has it.
|
||||
if (this.env === 'web' || this.env === 'app') {
|
||||
this.storeSessionToken_(
|
||||
(this.env === 'app' || this.appTabSession_) &&
|
||||
this.env === 'app' &&
|
||||
this.isUserSessionToken_(normalizedAuthToken)
|
||||
? normalizedAuthToken
|
||||
: null,
|
||||
|
||||
+39
-152
@@ -240,88 +240,6 @@ describe('app-mode launch token', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('GUI origin of an app', () => {
|
||||
const DESKTOP = 'https://desktop.puter.com';
|
||||
const launchedBy = (origin) =>
|
||||
bootApp(
|
||||
`${LAUNCH}${APP_TOKEN}&puter.origin=${encodeURIComponent(origin)}`,
|
||||
);
|
||||
|
||||
/** Sends `data` to the app as its parent at `origin` would. */
|
||||
const fromParent = async (origin, data) => {
|
||||
window.dispatchEvent(
|
||||
new MessageEvent('message', {
|
||||
origin,
|
||||
source: globalThis.parent,
|
||||
data,
|
||||
}),
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
};
|
||||
|
||||
const closeAnswers = () =>
|
||||
globalThis.parent.postMessage.mock.calls
|
||||
.map(([message]) => message)
|
||||
.filter((message) => message?.original_msg_id === 'close-1');
|
||||
|
||||
it('is the first-party desktop that launched it', async () => {
|
||||
expect((await launchedBy(DESKTOP)).defaultGUIOrigin).toBe(DESKTOP);
|
||||
expect((await launchedBy('https://puter.com')).defaultGUIOrigin).toBe(
|
||||
'https://puter.com',
|
||||
);
|
||||
});
|
||||
|
||||
it('stays puter.com for any other launcher', async () => {
|
||||
for (const origin of [
|
||||
'https://evil.example',
|
||||
'https://desktop.puter.com.evil.example',
|
||||
'https://x.desktop.puter.com',
|
||||
'http://desktop.puter.com',
|
||||
'not a url',
|
||||
]) {
|
||||
expect((await launchedBy(origin)).defaultGUIOrigin).toBe(
|
||||
'https://puter.com',
|
||||
);
|
||||
}
|
||||
expect((await bootApp(`${LAUNCH}${APP_TOKEN}`)).defaultGUIOrigin).toBe(
|
||||
'https://puter.com',
|
||||
);
|
||||
});
|
||||
|
||||
it('yields to an explicit PUTER_ORIGIN', async () => {
|
||||
globalThis.PUTER_ORIGIN = 'https://self-hosted.test';
|
||||
try {
|
||||
expect((await launchedBy(DESKTOP)).defaultGUIOrigin).toBe(
|
||||
'https://self-hosted.test',
|
||||
);
|
||||
} finally {
|
||||
delete globalThis.PUTER_ORIGIN;
|
||||
}
|
||||
});
|
||||
|
||||
it('is not taken from the URL of a top-level page', async () => {
|
||||
const puter = await bootWeb(
|
||||
`?puter.app_instance_id=i&puter.origin=${encodeURIComponent(DESKTOP)}`,
|
||||
);
|
||||
expect(puter.env).not.toBe('app');
|
||||
expect(puter.defaultGUIOrigin).toBe('https://puter.com');
|
||||
});
|
||||
|
||||
it('answers windowWillClose from that desktop and no other', async () => {
|
||||
await launchedBy(DESKTOP);
|
||||
const willClose = { msg: 'windowWillClose', msg_id: 'close-1' };
|
||||
|
||||
await fromParent('https://evil.example', willClose);
|
||||
await fromParent('https://puter.com', willClose);
|
||||
expect(closeAnswers()).toEqual([]);
|
||||
|
||||
await fromParent(DESKTOP, willClose);
|
||||
expect(closeAnswers()).toEqual([
|
||||
expect.objectContaining({ msg: true, original_msg_id: 'close-1' }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('token adoption on a top-level page', () => {
|
||||
it('takes a token from a window it opened, and from nothing else', async () => {
|
||||
const puter = await bootWeb('');
|
||||
@@ -368,76 +286,6 @@ describe('web-mode stored token', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('godmode app in a tab the GUI opened', () => {
|
||||
const TAB = '?puter.app_instance_id=i';
|
||||
|
||||
/** Boots a top-level tab whose GUI opener hands it `token`. */
|
||||
const bootTab = async (token) => {
|
||||
const opener = {
|
||||
closed: false,
|
||||
postMessage: (data, guiOrigin) =>
|
||||
window.dispatchEvent(
|
||||
new MessageEvent('message', {
|
||||
origin: guiOrigin,
|
||||
source: opener,
|
||||
data: {
|
||||
msg: 'puter.launch',
|
||||
appInstanceID: data.appInstanceID,
|
||||
token,
|
||||
},
|
||||
}),
|
||||
),
|
||||
};
|
||||
Object.defineProperty(globalThis, 'opener', {
|
||||
value: opener,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
const puter = await bootWeb(TAB);
|
||||
await puter.openerLaunch_;
|
||||
return puter;
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.opener = null;
|
||||
});
|
||||
|
||||
it('keeps the handed-over token in sessionStorage, so a reload has it', async () => {
|
||||
const puter = await bootTab(GODMODE_TOKEN);
|
||||
expect(puter.env).toBe('web');
|
||||
expect(puter.authToken).toBe(GODMODE_TOKEN);
|
||||
expect(localStorage.getItem(STORAGE_KEY)).toBeNull();
|
||||
expect(sessionStorage.getItem(STORAGE_KEY)).toBe(GODMODE_TOKEN);
|
||||
|
||||
// The opener was cut off, so the reload has only what it stored.
|
||||
expect(globalThis.opener).toBeNull();
|
||||
const reloaded = await bootWeb(TAB);
|
||||
expect(reloaded.authToken).toBe(GODMODE_TOKEN);
|
||||
expect(sessionStorage.getItem(STORAGE_KEY)).toBe(GODMODE_TOKEN);
|
||||
});
|
||||
|
||||
it('reloads on the godmode token over an app token stored earlier', async () => {
|
||||
localStorage.setItem(STORAGE_KEY, APP_TOKEN);
|
||||
localStorage.setItem(ORIGIN_KEY, 'https://api.puter.com');
|
||||
await bootTab(GODMODE_TOKEN);
|
||||
|
||||
const reloaded = await bootWeb(TAB);
|
||||
expect(reloaded.authToken).toBe(GODMODE_TOKEN);
|
||||
});
|
||||
|
||||
it('drops the sessionStorage copy on sign-out', async () => {
|
||||
const puter = await bootTab(GODMODE_TOKEN);
|
||||
puter.resetAuthToken();
|
||||
expect(sessionStorage.getItem(STORAGE_KEY)).toBeNull();
|
||||
});
|
||||
|
||||
it('still keeps an app token in localStorage only', async () => {
|
||||
await bootTab(APP_TOKEN);
|
||||
expect(localStorage.getItem(STORAGE_KEY)).toBe(APP_TOKEN);
|
||||
expect(sessionStorage.getItem(STORAGE_KEY)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('GUI cache refresh', () => {
|
||||
it('keeps one refresh loop however often the token is set', async () => {
|
||||
const puter = await bootApp(`${LAUNCH}${APP_TOKEN}`);
|
||||
@@ -528,3 +376,42 @@ describe('GUI message listener', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('app-open reporting', () => {
|
||||
/** URLs opened through XHR, the transport `fetchUrl` uses. */
|
||||
let opened;
|
||||
beforeEach(() => {
|
||||
opened = [];
|
||||
globalThis.XMLHttpRequest = class extends StubXHR {
|
||||
open(_method, url) {
|
||||
opened.push(String(url));
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
/** Starts the report (never answered) and waits for its request. */
|
||||
const raoRequested = async (puter) => {
|
||||
void puter.request_rao_();
|
||||
for (let i = 0; i < 50; i++) {
|
||||
if (opened.some((url) => url.endsWith('/rao'))) return true;
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
it('reports an open from a page', async () => {
|
||||
const puter = await bootWeb('');
|
||||
expect(await raoRequested(puter)).toBe(true);
|
||||
});
|
||||
|
||||
it('reports nothing from a client a worker runtime built', async () => {
|
||||
globalThis.puter_report_app_open = false;
|
||||
try {
|
||||
const puter = await bootWeb('');
|
||||
expect(puter.reportsAppOpen).toBe(false);
|
||||
expect(await raoRequested(puter)).toBe(false);
|
||||
} finally {
|
||||
delete globalThis.puter_report_app_open;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -88,8 +88,12 @@ export class WorkersHandler extends PuterModule {
|
||||
} catch ( e ) {
|
||||
sandboxApp = await this.puter.apps.create(`sandbox-${ workerName }`, 'https://worker-sandbox.puter.com/');
|
||||
}
|
||||
if ( sandboxApp.owner.uuid !== user.uuid ) {
|
||||
throw new Error(`Sandbox context is not owned by you! This worker's sandbox is currently owned by: ${ sandboxApp.owner.username }`);
|
||||
// Another user's app comes back without `owner`.
|
||||
if ( sandboxApp.owner?.uuid !== user.uuid ) {
|
||||
throw {
|
||||
message: `The sandbox app 'sandbox-${ workerName }' belongs to another user. Choose another worker name, or pass { sandbox: false }.`,
|
||||
code: 'sandbox_not_owned',
|
||||
};
|
||||
}
|
||||
appId = sandboxApp.uid;
|
||||
}
|
||||
|
||||
@@ -369,4 +369,44 @@ export default suite('workers', {
|
||||
const worker = await t.puter.workers.get('workers-suite-delete');
|
||||
t.assert.ok(!worker, 'deleted worker should no longer be returned');
|
||||
},
|
||||
|
||||
'delete removes the worker sandbox app, freeing its name': async (t) => {
|
||||
const name = 'workers-suite-delete-sbx';
|
||||
await deployWorker(t, name);
|
||||
t.assert.equal(await appExists(t, `sandbox-${name}`), true);
|
||||
await t.puter.workers.delete(name);
|
||||
t.assert.equal(
|
||||
await appExists(t, `sandbox-${name}`),
|
||||
false,
|
||||
'the sandbox app should go with the worker',
|
||||
);
|
||||
},
|
||||
|
||||
'create refuses a sandbox app another user owns': async (t) => {
|
||||
const name = 'workers-suite-sbx-taken';
|
||||
const res = await fetch(`${t.env.apiOrigin}/drivers/call`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${t.env.users.other.token}`,
|
||||
Origin: t.env.apiOrigin,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
interface: 'puter-apps',
|
||||
method: 'create',
|
||||
args: {
|
||||
object: {
|
||||
name: `sandbox-${name}`,
|
||||
title: name,
|
||||
index_url: 'https://worker-sandbox.puter.com/',
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
t.assert.equal(res.status, 200, 'the other user owns the sandbox app');
|
||||
const err = (await t.assert.rejects(() => deployWorker(t, name))) as {
|
||||
code?: string;
|
||||
};
|
||||
t.assert.equal(err.code, 'sandbox_not_owned');
|
||||
},
|
||||
});
|
||||
@@ -42,6 +42,9 @@ globalThis.init_puter_portable = (auth, apiOrigin, type, options = {}) => {
|
||||
if (options.socket === false) {
|
||||
goodContext.puter_socket_enabled = false;
|
||||
}
|
||||
// Server-side code running as the account is never someone opening
|
||||
// the app, so no client built here reports an app open.
|
||||
goodContext.puter_report_app_open = false;
|
||||
// The fetch this client's own requests go through, for a caller that
|
||||
// watches how Puter's API answered them.
|
||||
if (typeof options.fetch === 'function') {
|
||||
|
||||
Reference in new issue
Block a user