diff --git a/extensions/installedApps.test.ts b/extensions/installedApps.test.ts index d5d19735f..111513f3e 100644 --- a/extensions/installedApps.test.ts +++ b/extensions/installedApps.test.ts @@ -138,6 +138,35 @@ describe('installedApps extension — handleInstalledApps', () => { expect(Object.prototype.hasOwnProperty.call(list[0], 'iconUrl')).toBe( true, ); + // An owned app is not external, and the raw owner id must not leak. + expect(list[0].external).toBe(false); + expect( + Object.prototype.hasOwnProperty.call(list[0], 'owner_user_id'), + ).toBe(false); + }); + + it('flags apps with no owner_user_id as external', async () => { + const user = await seedUser(); + const slug = Math.random().toString(36).slice(2, 8); + // createFromOrigin bootstraps an app with owner_user_id = null. + const app = await server.stores.app.createFromOrigin( + `app-${slug}`, + `https://external-${slug}.example.com`, + ); + await grantInstalled(app!.id as number, user.id as number); + + const { res, captured } = makeRes(); + await runWithContext( + { actor: { user: { uuid: user.uuid, id: user.id as number } } }, + () => handleInstalledApps(makeReq({}), res), + ); + + const list = captured.body as Array>; + expect(list).toHaveLength(1); + expect(list[0].external).toBe(true); + expect( + Object.prototype.hasOwnProperty.call(list[0], 'owner_user_id'), + ).toBe(false); }); it('clamps page/limit to safe ranges (page>=1, 1<=limit<=100)', async () => { diff --git a/extensions/installedApps.ts b/extensions/installedApps.ts index 62feae22f..8cd75213b 100644 --- a/extensions/installedApps.ts +++ b/extensions/installedApps.ts @@ -50,6 +50,7 @@ export const handleInstalledApps = async ( apps.description, apps.icon, apps.index_url, + apps.owner_user_id, MIN(perm.dt) AS installed_at FROM apps LEFT JOIN user_to_app_permissions AS perm ON apps.id = perm.app_id @@ -63,10 +64,18 @@ export const handleInstalledApps = async ( const apiBaseUrl = extension.config.api_base_url as string | undefined; res.json( - installedApps.map((app) => ({ - ...app, - iconUrl: getAppIconUrl(app, { apiBaseUrl }), - })), + installedApps.map((app) => { + // An app with no owner_user_id (null/empty) isn't owned by a Puter + // user — it's an "external" app. Derive a flag and don't leak the + // raw owner id to the client. + const { owner_user_id, ...rest } = app; + const external = owner_user_id == null || owner_user_id === ''; + return { + ...rest, + iconUrl: getAppIconUrl(app, { apiBaseUrl }), + external, + }; + }), ); }; diff --git a/src/backend/controllers/fs/LegacyFSController.ts b/src/backend/controllers/fs/LegacyFSController.ts index a0df0057b..1178adfe5 100644 --- a/src/backend/controllers/fs/LegacyFSController.ts +++ b/src/backend/controllers/fs/LegacyFSController.ts @@ -158,8 +158,7 @@ export class LegacyFSController extends PuterController { router.get('/get-launch-apps', apiOptions, async (req, res) => { const recommendedSvc = this.services.recommendedApps as unknown as - | { getRecommendedApps?: () => Promise } - | undefined; + { getRecommendedApps?: () => Promise } | undefined; const recommended = recommendedSvc?.getRecommendedApps ? await recommendedSvc.getRecommendedApps() : []; @@ -194,6 +193,11 @@ export class LegacyFSController extends PuterController { godmode: Boolean(app.godmode), maximize_on_start: Boolean(app.maximize_on_start), index_url: app.index_url, + // An app with no owner isn't owned by a Puter user — + // it's an "external" (origin-bootstrapped) app. + external: + app.owner_user_id == null || + app.owner_user_id === '', }); } } @@ -555,9 +559,7 @@ export class LegacyFSController extends PuterController { // Trash, and `null`/`{}` when restoring. See // `src/gui/src/helpers.js` → `window.move_items`. newMetadata: (body.new_metadata ?? undefined) as - | Record - | null - | undefined, + Record | null | undefined, }); const oldPath = source.path; await this.#emitGuiEvent('outer.gui.item.moved', moved, { @@ -973,8 +975,7 @@ export class LegacyFSController extends PuterController { } type SignedOrEmpty = - | (SignedFile & { path?: string }) - | Record; + (SignedFile & { path?: string }) | Record; const result: { signatures: SignedOrEmpty[]; token?: string } = { signatures: [], }; @@ -1515,10 +1516,7 @@ export class LegacyFSController extends PuterController { const subjectRef = body.subject; const appRef = body.app; const mode = (getString(body, 'mode') ?? 'read') as - | 'see' - | 'list' - | 'read' - | 'write'; + 'see' | 'list' | 'read' | 'write'; if (!subjectRef || !appRef) throw new HttpError(400, '`subject` and `app` are required', { legacyCode: 'bad_request', diff --git a/src/gui/src/UI/Dashboard/TabApps.js b/src/gui/src/UI/Dashboard/TabApps.js index 5f6471452..d4c3ca784 100644 --- a/src/gui/src/UI/Dashboard/TabApps.js +++ b/src/gui/src/UI/Dashboard/TabApps.js @@ -47,16 +47,10 @@ function buildAppsGrid (apps) { for ( const app of apps ) { let title = (app.title || app.name || '').trim(); - // Anonymous apps report an opaque id (uuid === name === title, all - // starting with 'app-'); show the hostname of index_url instead, - // matching the Home tab. - const appUid = app.uid || app.uuid; - if ( - app.name === app.title && - app.name === appUid && - app.name?.startsWith('app-') && - app.index_url - ) { + // External apps (not owned by a Puter user) report an opaque app-… id + // as their title; show the hostname of index_url instead, matching the + // Home tab. + if (app.external && app.index_url) { title = new URL(app.index_url).hostname; } @@ -307,6 +301,7 @@ const TabApps = { title: app.title, uid: app.uuid || app.uid || null, index_url: app.index_url || null, + external: app.external ?? false, iconUrl: app.iconUrl || app.icon || null, })); diff --git a/src/gui/src/UI/Dashboard/TabHome.js b/src/gui/src/UI/Dashboard/TabHome.js index 0fd33de80..7243d476d 100644 --- a/src/gui/src/UI/Dashboard/TabHome.js +++ b/src/gui/src/UI/Dashboard/TabHome.js @@ -28,14 +28,9 @@ function buildRecentAppsHTML() { // Show up to 6 recent apps (2 columns x 3 rows) const recentApps = window.launch_apps.recent.slice(0, 6); for (const app_info of recentApps) { - // if title, name and uuid are the same and start with 'app-' and index_url - // is set, then show the hostname of index_url instead of the opaque app id - if ( - app_info.name === app_info.title && - app_info.name === app_info.uuid && - app_info.name?.startsWith('app-') && - app_info.index_url - ) { + // External apps (not owned by a Puter user) report an opaque app-… id + // as their title; show the hostname of index_url instead. + if (app_info.external && app_info.index_url) { app_info.title = new URL(app_info.index_url).hostname; app_info.target_link = app_info.index_url; }