mirror of
https://github.com/HeyPuter/puter.git
synced 2026-08-23 22:47:19 +00:00
feat: support dekstop app linking again (#3511)
This commit is contained in:
@@ -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(/<!DOCTYPE html>/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(/<!DOCTYPE html>/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)}`;
|
||||
|
||||
@@ -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 --
|
||||
|
||||
|
||||
@@ -350,6 +350,26 @@ describe('OIDCController GET /auth/oidc/:provider/start', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('bakes a whitelisted desktop app landing (/desktop/app/<name>) 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();
|
||||
|
||||
@@ -48,13 +48,14 @@ const ALLOWED_ERRORS = [
|
||||
] as const;
|
||||
|
||||
// GUI pages an OIDC flow may return to: /desktop, /dashboard, and direct app
|
||||
// landings (/app/<name>, mirroring APP_NAME_REGEX in AppDriver). Strict
|
||||
// whitelist — never a client-supplied URL (no open redirect).
|
||||
// landings (/app/<name> and its desktop-booted twin /desktop/app/<name>,
|
||||
// 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)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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/<name>`), so OIDC login started from an app
|
||||
* landing comes back to the app.
|
||||
* and direct app landings (`/app/<name>`, plus the desktop-booted
|
||||
* `/desktop/app/<name>`), 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;
|
||||
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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/<name>`) 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,
|
||||
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
/**
|
||||
* 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/<name>` yields `['app', '<name>']` — the app landing
|
||||
* every route check downstream already understands — and the only difference
|
||||
* from `/app/<name>` 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;
|
||||
};
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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/<name>`)
|
||||
// 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/<name>` 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/<name>` opens the app on the desktop the same way
|
||||
// `/app/<name>` opens it in the dashboard.
|
||||
const url_paths = parse_url_paths(window.location.pathname);
|
||||
window.url_paths = url_paths;
|
||||
|
||||
// GET query params provided
|
||||
|
||||
Reference in New Issue
Block a user