diff --git a/src/backend/controllers/homepage/HomepageController.test.ts b/src/backend/controllers/homepage/HomepageController.test.ts index 0123c464e..5e076647f 100644 --- a/src/backend/controllers/homepage/HomepageController.test.ts +++ b/src/backend/controllers/homepage/HomepageController.test.ts @@ -315,6 +315,49 @@ describe('HomepageController GET /app/:name', () => { expect(html).not.toContain('owner_user_id'); }); + it('serves the same app shell under /desktop/app/:name', async () => { + const { userId } = await makeUser(); + const name = `desk-${Math.random().toString(36).slice(2, 10)}`; + await server.stores.app.create( + { + name, + title: 'Desktop App', + description: 'opens on the desktop instead of the dashboard', + index_url: `https://example.com/${name}/`, + approved_for_listing: 1, + }, + { ownerUserId: userId }, + ); + + const { res, captured } = makeRes(); + await callRoute( + 'get', + '/desktop/app/:name', + makeReq({ params: { name }, path: `/desktop/app/${name}` }), + res, + ); + + expect(captured.statusCode).toBe(200); + const html = String(captured.body); + expect(html).toMatch(//i); + expect(html).toContain('Desktop App'); + }); + + it('returns 404 under /desktop/app/:name when the app is unknown', async () => { + const { res, captured } = makeRes(); + await callRoute( + 'get', + '/desktop/app/:name', + makeReq({ + params: { name: 'no-such-app' }, + path: '/desktop/app/no-such-app', + }), + res, + ); + expect(captured.statusCode).toBe(404); + expect(String(captured.body)).toMatch(//i); + }); + it('omits index_url even for a public app — the shell is not the launch authority', async () => { const { userId } = await makeUser(); const name = `pub-${Math.random().toString(36).slice(2, 10)}`; diff --git a/src/backend/controllers/homepage/HomepageController.ts b/src/backend/controllers/homepage/HomepageController.ts index a53e37f9d..e9e3a3b87 100644 --- a/src/backend/controllers/homepage/HomepageController.ts +++ b/src/backend/controllers/homepage/HomepageController.ts @@ -91,8 +91,14 @@ export class HomepageController extends PuterController { router.get('/@:username', {}, (req, res) => sendShell(req, res)); // -- /app/:name - app metadata baked into the shell ---------- + // `/desktop/app/:name` is the same landing booted on the desktop + // instead of the dashboard; the GUI strips the prefix and treats the + // rest of the path identically, so both render the same shell. - router.get('/app/:name', {}, async (req, res) => { + const sendAppShell = async ( + req: express.Request, + res: express.Response, + ) => { const name = String(req.params.name ?? ''); const app = name ? await this.stores.app.getByName(name) : null; @@ -130,7 +136,10 @@ export class HomepageController extends PuterController { ? name.charAt(0).toUpperCase() + name.slice(1) : 'Puter', }); - }); + }; + + router.get('/app/:name', {}, sendAppShell); + router.get('/desktop/app/:name', {}, sendAppShell); // -- /show/* - launch explorer with the requested file path -- diff --git a/src/backend/controllers/oidc/OIDCController.test.ts b/src/backend/controllers/oidc/OIDCController.test.ts index e90c85e75..8a6295d27 100644 --- a/src/backend/controllers/oidc/OIDCController.test.ts +++ b/src/backend/controllers/oidc/OIDCController.test.ts @@ -350,6 +350,26 @@ describe('OIDCController GET /auth/oidc/:provider/start', () => { ); }); + it('bakes a whitelisted desktop app landing (/desktop/app/) into the state redirect_uri', async () => { + const { res, captured } = makeRes(); + await callRoute( + 'get', + '/auth/oidc/:provider/start', + makeReq({ + params: { provider: 'custom' }, + query: { return_to: '/desktop/app/my-App_2' }, + }), + res, + ); + const state = new URL(captured.redirectUrl ?? '').searchParams.get( + 'state', + ); + const decoded = oidc().verifyState(state!); + expect(String(decoded?.redirect_uri)).toBe( + `${TEST_ORIGIN}/desktop/app/my-App_2`, + ); + }); + it('ignores a non-whitelisted return_to', async () => { const bad_values = [ '/app/evil/extra', @@ -358,6 +378,9 @@ describe('OIDCController GET /auth/oidc/:provider/start', () => { '//evil.test', '/settings', `/app/${'a'.repeat(101)}`, + '/desktop/app/evil/extra', + '/desktop/app/', + '/dashboard/app/name', ]; for (const return_to of bad_values) { const { res, captured } = makeRes(); diff --git a/src/backend/controllers/oidc/OIDCController.ts b/src/backend/controllers/oidc/OIDCController.ts index e3f8879ab..7c56f5777 100644 --- a/src/backend/controllers/oidc/OIDCController.ts +++ b/src/backend/controllers/oidc/OIDCController.ts @@ -48,13 +48,14 @@ const ALLOWED_ERRORS = [ ] as const; // GUI pages an OIDC flow may return to: /desktop, /dashboard, and direct app -// landings (/app/, mirroring APP_NAME_REGEX in AppDriver). Strict -// whitelist — never a client-supplied URL (no open redirect). +// landings (/app/ and its desktop-booted twin /desktop/app/, +// mirroring APP_NAME_REGEX in AppDriver). Strict whitelist — never a +// client-supplied URL (no open redirect). function isWhitelistedReturnPath(path: string): boolean { return ( path === '/desktop' || path === '/dashboard' || - /^\/app\/[a-zA-Z0-9_-]{1,100}$/.test(path) + /^(\/desktop)?\/app\/[a-zA-Z0-9_-]{1,100}$/.test(path) ); } diff --git a/src/gui/dev-server.js b/src/gui/dev-server.js index ba63e932c..bb933800b 100644 --- a/src/gui/dev-server.js +++ b/src/gui/dev-server.js @@ -144,7 +144,7 @@ try { startServer(1); -app.get(['/', '/app/*splat', '/action/*splat', '/desktop', '/dashboard'], (req, res) => { +app.get(['/', '/app/*splat', '/action/*splat', '/desktop', '/desktop/app/*splat', '/dashboard'], (req, res) => { res.send(generateDevHtml({ env: env, api_origin: apiOrigin, diff --git a/src/gui/src/helpers/auth_redirect.js b/src/gui/src/helpers/auth_redirect.js index b4efa7983..af13730ef 100644 --- a/src/gui/src/helpers/auth_redirect.js +++ b/src/gui/src/helpers/auth_redirect.js @@ -52,8 +52,9 @@ export const get_auth_redirect_url = () => { * The `return_to` path to send along when starting an OIDC flow, or null if * the current page isn't one the backend will return to. The backend strictly * whitelists these (never a client-supplied URL): `/desktop`, `/dashboard`, - * and direct app landings (`/app/`), so OIDC login started from an app - * landing comes back to the app. + * and direct app landings (`/app/`, plus the desktop-booted + * `/desktop/app/`), so OIDC login started from an app landing comes back + * to the app — and to the same interface it was opened in. * * @returns {string|null} whitelistable pathname, or null */ @@ -63,7 +64,7 @@ export const get_oidc_return_to = () => { return pathname; } // app landing: normalize away a trailing slash to match the backend whitelist - if ( /^\/app\/[^/]+\/?$/.test(pathname) ) { + if ( /^(\/desktop)?\/app\/[^/]+\/?$/.test(pathname) ) { return pathname.replace(/\/$/, ''); } return null; diff --git a/src/gui/src/helpers/auth_redirect.test.js b/src/gui/src/helpers/auth_redirect.test.js new file mode 100644 index 000000000..691d8db61 --- /dev/null +++ b/src/gui/src/helpers/auth_redirect.test.js @@ -0,0 +1,56 @@ +/* + * 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 . + */ + +import { describe, it, expect, afterEach } from 'vitest'; +import { get_oidc_return_to } from './auth_redirect.js'; + +const at = (pathname) => { + globalThis.window = { location: { pathname } }; + return get_oidc_return_to(); +}; + +afterEach(() => { + delete globalThis.window; +}); + +describe('get_oidc_return_to', () => { + it('returns the interface pages the backend whitelists', () => { + expect(at('/desktop')).toBe('/desktop'); + expect(at('/dashboard')).toBe('/dashboard'); + }); + + it('returns app landings, normalizing a trailing slash', () => { + expect(at('/app/editor')).toBe('/app/editor'); + expect(at('/app/editor/')).toBe('/app/editor'); + }); + + it('keeps a desktop app landing on the desktop', () => { + expect(at('/desktop/app/editor')).toBe('/desktop/app/editor'); + expect(at('/desktop/app/editor/')).toBe('/desktop/app/editor'); + }); + + it('returns null for anything the backend would reject', () => { + expect(at('/')).toBe(null); + expect(at('/settings')).toBe(null); + expect(at('/action/login')).toBe(null); + expect(at('/app/editor/extra')).toBe(null); + expect(at('/desktop/app/editor/extra')).toBe(null); + expect(at('/dashboard/app/editor')).toBe(null); + }); +}); diff --git a/src/gui/src/helpers/launch_app.js b/src/gui/src/helpers/launch_app.js index 2e68ce83a..6d46ae755 100644 --- a/src/gui/src/helpers/launch_app.js +++ b/src/gui/src/helpers/launch_app.js @@ -131,9 +131,12 @@ const launch_app = async (options) => { const launchAttributes = { 'launch.app': options?.name ?? options?.app_obj?.name ?? 'unknown', 'launch.dashboard_mode': !! window.is_dashboard_mode, + // url_paths has the `/desktop` prefix stripped, so this counts the + // desktop-booted landing (`/desktop/app/`) as the app URL + // it is. 'launch.from_app_url': - typeof window.location?.pathname === 'string' - && window.location.pathname.startsWith('/app/'), + window.url_paths?.[0]?.toLocaleLowerCase() === 'app' + && !! window.url_paths?.[1], 'launch.has_app_obj': !! options?.app_obj, }; // Exec-service launches never get the IPC listener attached below, diff --git a/src/gui/src/helpers/url_paths.js b/src/gui/src/helpers/url_paths.js new file mode 100644 index 000000000..2a1c9201c --- /dev/null +++ b/src/gui/src/helpers/url_paths.js @@ -0,0 +1,42 @@ +/* + * 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 . + */ + +/** + * Splits a GUI pathname into the route segments the boot code matches on + * (`window.url_paths`), dropping empty segments. + * + * A leading `desktop` segment is dropped with them: `/desktop` only picks the + * interface, and everything after it is the same route it would be at the + * root. So `/desktop/app/` yields `['app', '']` — the app landing + * every route check downstream already understands — and the only difference + * from `/app/` is that the dashboard-mode check (see initgui.js) doesn't + * claim the path, leaving the app to open on the desktop. + * + * @param {string} pathname - the pathname to parse, e.g. `/desktop/app/editor` + * @returns {string[]} route segments + */ +export const parse_url_paths = (pathname) => { + const paths = String(pathname ?? '') + .split('/') + .filter((element) => element); + if ( paths[0]?.toLocaleLowerCase() === 'desktop' ) { + paths.shift(); + } + return paths; +}; diff --git a/src/gui/src/helpers/url_paths.test.js b/src/gui/src/helpers/url_paths.test.js new file mode 100644 index 000000000..2977d612f --- /dev/null +++ b/src/gui/src/helpers/url_paths.test.js @@ -0,0 +1,56 @@ +/* + * 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 . + */ + +import { describe, it, expect } from 'vitest'; +import { parse_url_paths } from './url_paths.js'; + +describe('parse_url_paths', () => { + it('splits a pathname into non-empty segments', () => { + expect(parse_url_paths('/app/editor')).toEqual(['app', 'editor']); + expect(parse_url_paths('/app/editor/')).toEqual(['app', 'editor']); + expect(parse_url_paths('/')).toEqual([]); + expect(parse_url_paths('')).toEqual([]); + }); + + it('drops a leading desktop segment so the rest reads as a root route', () => { + expect(parse_url_paths('/desktop/app/editor')).toEqual([ + 'app', + 'editor', + ]); + expect(parse_url_paths('/DeskTop/app/editor')).toEqual([ + 'app', + 'editor', + ]); + expect(parse_url_paths('/desktop')).toEqual([]); + expect(parse_url_paths('/desktop/')).toEqual([]); + }); + + it('only drops the prefix, not a later or lookalike segment', () => { + expect(parse_url_paths('/app/desktop')).toEqual(['app', 'desktop']); + expect(parse_url_paths('/desktops/app/editor')).toEqual([ + 'desktops', + 'app', + 'editor', + ]); + expect(parse_url_paths('/desktop/desktop/app')).toEqual([ + 'desktop', + 'app', + ]); + }); +}); diff --git a/src/gui/src/initgui.js b/src/gui/src/initgui.js index dcd4e3554..682470a14 100644 --- a/src/gui/src/initgui.js +++ b/src/gui/src/initgui.js @@ -51,6 +51,7 @@ import { import init_device_signals from './helpers/device_signals.js'; import item_icon from './helpers/item_icon.js'; import launch_app from './helpers/launch_app.js'; +import { parse_url_paths } from './helpers/url_paths.js'; import update_last_touch_coordinates from './helpers/update_last_touch_coordinates.js'; import update_mouse_position from './helpers/update_mouse_position.js'; import update_title_based_on_uploads from './helpers/update_title_based_on_uploads.js'; @@ -780,6 +781,9 @@ if (jQuery) { // alias, and `/desktop` loads the desktop instead. Direct app landings (`/app/`) // open in the dashboard too: the app comes up maximized in-page with the dashboard // route slotted underneath (see postAuthActions), so Back minimizes to the dashboard. +// To land the same app on the desktop instead, prefix the path: +// `/desktop/app/` doesn't match the dashboard paths below, so it falls +// through to the desktop. // URLs that carry a desktop-only flow keep booting the desktop: auth popups // (`?embedded_in_popup=`), app deep links (`?app=`), direct downloads (`?download=`), // fullpage mode (`?puter.fullpage=`), and iframe embeds. App metadata like @@ -991,9 +995,11 @@ window.showTurnstileChallenge = function (options) { window.initgui = async function (options) { const url = new URL(window.location).href; window.url = url; - const url_paths = window.location.pathname - .split('/') - .filter((element) => element); + // Route segments with a leading `/desktop` dropped, so the route checks + // downstream (app landings, actions) never have to know about the prefix: + // `/desktop/app/` opens the app on the desktop the same way + // `/app/` opens it in the dashboard. + const url_paths = parse_url_paths(window.location.pathname); window.url_paths = url_paths; // GET query params provided