mirror of
https://github.com/HeyPuter/puter.git
synced 2026-05-04 00:20:45 +00:00
feat: private app config to use app urls + app routing (#2587)
Docker Image CI / build-and-push-image (push) Has been cancelled
Maintain Release Merge PR / update-release-pr (push) Has been cancelled
release-please / release-please (push) Has been cancelled
test / test-backend (24.x) (push) Has been cancelled
test / API tests (node env, api-test) (24.x) (push) Has been cancelled
test / puterjs (node env, vitest) (24.x) (push) Has been cancelled
Docker Image CI / build-and-push-image (push) Has been cancelled
Maintain Release Merge PR / update-release-pr (push) Has been cancelled
release-please / release-please (push) Has been cancelled
test / test-backend (24.x) (push) Has been cancelled
test / API tests (node env, api-test) (24.x) (push) Has been cancelled
test / puterjs (node env, vitest) (24.x) (push) Has been cancelled
* feat: private app config to use app urls * fix: launch app * fix: cookie origin
This commit is contained in:
Vendored
+14
@@ -140,6 +140,20 @@ export interface ExtensionEventTypeMap {
|
||||
checkedBy?: string;
|
||||
};
|
||||
};
|
||||
'app.privateAccess.resolveLaunch': {
|
||||
appUid: string;
|
||||
appName?: string;
|
||||
userUid?: string | null;
|
||||
source?: string;
|
||||
args?: Record<string, unknown>;
|
||||
result: {
|
||||
hasAccess: boolean;
|
||||
fallbackAppName?: string;
|
||||
fallbackArgs?: Record<string, unknown>;
|
||||
reason?: string;
|
||||
checkedBy?: string;
|
||||
};
|
||||
};
|
||||
'ai.prompt.validate': {
|
||||
actor: Actor;
|
||||
actor,
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
/*
|
||||
* Copyright (C) 2026-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 { UserActorType } from '../../services/auth/Actor.js';
|
||||
|
||||
const DEFAULT_FALLBACK_APP_NAME = 'app-center';
|
||||
|
||||
function isPrivateApp (app) {
|
||||
return Number(app?.is_private ?? 0) > 0;
|
||||
}
|
||||
|
||||
function buildFallbackPath (appName) {
|
||||
if ( typeof appName !== 'string' || !appName.trim() ) {
|
||||
return '/app';
|
||||
}
|
||||
return `/app/${encodeURIComponent(appName.trim())}`;
|
||||
}
|
||||
|
||||
function buildDefaultDeniedDecision (appName, reason) {
|
||||
return {
|
||||
hasAccess: false,
|
||||
fallbackAppName: DEFAULT_FALLBACK_APP_NAME,
|
||||
fallbackArgs: {
|
||||
path: buildFallbackPath(appName),
|
||||
},
|
||||
reason: reason ?? 'private-access-required',
|
||||
checkedBy: 'core/private-launch-access',
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeLaunchDecision (decision, appName) {
|
||||
if ( !decision || typeof decision !== 'object' ) {
|
||||
return buildDefaultDeniedDecision(appName, 'invalid-private-access-result');
|
||||
}
|
||||
|
||||
const hasAccess = !!decision.hasAccess;
|
||||
if ( hasAccess ) {
|
||||
return {
|
||||
hasAccess: true,
|
||||
reason: typeof decision.reason === 'string'
|
||||
? decision.reason
|
||||
: undefined,
|
||||
checkedBy: typeof decision.checkedBy === 'string'
|
||||
? decision.checkedBy
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const fallbackAppName = typeof decision.fallbackAppName === 'string'
|
||||
&& decision.fallbackAppName.trim()
|
||||
? decision.fallbackAppName.trim()
|
||||
: DEFAULT_FALLBACK_APP_NAME;
|
||||
const fallbackPath = decision.fallbackArgs?.path;
|
||||
const fallbackArgs = typeof fallbackPath === 'string' && fallbackPath.trim()
|
||||
? { path: fallbackPath.trim() }
|
||||
: { path: buildFallbackPath(appName) };
|
||||
|
||||
return {
|
||||
hasAccess: false,
|
||||
fallbackAppName,
|
||||
fallbackArgs,
|
||||
reason: typeof decision.reason === 'string'
|
||||
? decision.reason
|
||||
: undefined,
|
||||
checkedBy: typeof decision.checkedBy === 'string'
|
||||
? decision.checkedBy
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function getActorUserUid (actor) {
|
||||
if ( ! actor ) return null;
|
||||
|
||||
if ( actor.type instanceof UserActorType ) {
|
||||
const userUid = actor.type?.user?.uuid;
|
||||
return typeof userUid === 'string' && userUid ? userUid : null;
|
||||
}
|
||||
|
||||
if ( typeof actor.get_related_actor === 'function' ) {
|
||||
try {
|
||||
const userActor = actor.get_related_actor(UserActorType);
|
||||
const userUid = userActor?.type?.user?.uuid;
|
||||
return typeof userUid === 'string' && userUid ? userUid : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function resolvePrivateLaunchAccess ({
|
||||
app,
|
||||
services,
|
||||
userUid,
|
||||
source,
|
||||
args,
|
||||
}) {
|
||||
if ( ! isPrivateApp(app) ) {
|
||||
return {
|
||||
hasAccess: true,
|
||||
checkedBy: 'core/public-app',
|
||||
};
|
||||
}
|
||||
|
||||
const deniedDecision = buildDefaultDeniedDecision(
|
||||
app?.name,
|
||||
'private-access-required',
|
||||
);
|
||||
|
||||
const eventService = services?.get?.('event');
|
||||
if ( ! eventService ) {
|
||||
return {
|
||||
...deniedDecision,
|
||||
reason: 'private-access-event-service-unavailable',
|
||||
};
|
||||
}
|
||||
|
||||
const eventPayload = {
|
||||
appUid: app?.uid,
|
||||
appName: app?.name,
|
||||
userUid: typeof userUid === 'string' && userUid ? userUid : null,
|
||||
source: source ?? 'unknown',
|
||||
args: args ?? {},
|
||||
result: { ...deniedDecision },
|
||||
};
|
||||
|
||||
try {
|
||||
await eventService.emit('app.privateAccess.resolveLaunch', eventPayload);
|
||||
} catch {
|
||||
return {
|
||||
...deniedDecision,
|
||||
reason: 'private-access-check-error',
|
||||
};
|
||||
}
|
||||
|
||||
return normalizeLaunchDecision(eventPayload.result, app?.name);
|
||||
}
|
||||
|
||||
export {
|
||||
getActorUserUid,
|
||||
isPrivateApp,
|
||||
resolvePrivateLaunchAccess,
|
||||
};
|
||||
@@ -29,6 +29,13 @@ const { Eq, Like, Or, And } = require('../query/query');
|
||||
const { BaseES } = require('./BaseES');
|
||||
|
||||
const uuidv4 = require('uuid').v4;
|
||||
let privateLaunchAccessModulePromise;
|
||||
const getPrivateLaunchAccessModule = async () => {
|
||||
if ( ! privateLaunchAccessModulePromise ) {
|
||||
privateLaunchAccessModulePromise = import('../../modules/apps/privateLaunchAccess.js');
|
||||
}
|
||||
return privateLaunchAccessModulePromise;
|
||||
};
|
||||
|
||||
class AppES extends BaseES {
|
||||
static METHODS = {
|
||||
@@ -159,8 +166,10 @@ class AppES extends BaseES {
|
||||
|
||||
// Remove old file associations (if applicable)
|
||||
if ( extra.old_entity ) {
|
||||
await this.db.write('DELETE FROM app_filetype_association WHERE app_id = ?',
|
||||
[insert_id]);
|
||||
await this.db.write(
|
||||
'DELETE FROM app_filetype_association WHERE app_id = ?',
|
||||
[insert_id],
|
||||
);
|
||||
}
|
||||
|
||||
// Add file associations (if applicable)
|
||||
@@ -199,8 +208,10 @@ class AppES extends BaseES {
|
||||
};
|
||||
await svc_event.emit('app.new-icon', event);
|
||||
if ( typeof event.url === 'string' && event.url ) {
|
||||
this.db.write('UPDATE apps SET icon = ? WHERE id = ? LIMIT 1',
|
||||
[event.url, insert_id]);
|
||||
this.db.write(
|
||||
'UPDATE apps SET icon = ? WHERE id = ? LIMIT 1',
|
||||
[event.url, insert_id],
|
||||
);
|
||||
await entity.set('icon', event.url);
|
||||
}
|
||||
}
|
||||
@@ -222,8 +233,10 @@ class AppES extends BaseES {
|
||||
|
||||
// Associate app with subdomain (if applicable)
|
||||
if ( subdomain_id ) {
|
||||
await this.db.write('UPDATE subdomains SET associated_app_id = ? WHERE id = ?',
|
||||
[insert_id, subdomain_id]);
|
||||
await this.db.write(
|
||||
'UPDATE subdomains SET associated_app_id = ? WHERE id = ?',
|
||||
[insert_id, subdomain_id],
|
||||
);
|
||||
}
|
||||
if ( extra.old_entity ) {
|
||||
const svc_event = this.context.get('services').get('event');
|
||||
@@ -291,8 +304,10 @@ class AppES extends BaseES {
|
||||
await svc_event.emit('app.new-icon', event);
|
||||
if ( typeof event.url !== 'string' || !event.url ) return;
|
||||
|
||||
await this.db.write('UPDATE apps SET icon = ? WHERE uid = ? LIMIT 1',
|
||||
[event.url, app_uid]);
|
||||
await this.db.write(
|
||||
'UPDATE apps SET icon = ? WHERE uid = ? LIMIT 1',
|
||||
[event.url, app_uid],
|
||||
);
|
||||
}).catch(e => {
|
||||
const svc_error = this.context.get('services').get('error-service');
|
||||
svc_error.report('AppES:queue_icon_migration', { source: e });
|
||||
@@ -306,31 +321,77 @@ class AppES extends BaseES {
|
||||
* @param {Object} entity - App entity to transform
|
||||
*/
|
||||
async read_transform (entity) {
|
||||
// Add file associations
|
||||
const rows = await this.db.read('SELECT type FROM app_filetype_association WHERE app_id = ?',
|
||||
[entity.private_meta.mysql_id]);
|
||||
entity.set('filetype_associations', rows.map(row => row.type));
|
||||
const {
|
||||
getActorUserUid,
|
||||
resolvePrivateLaunchAccess,
|
||||
} = await getPrivateLaunchAccessModule();
|
||||
const services = this.context.get('services');
|
||||
const actor = Context.get('actor');
|
||||
const esParams = Context.get('es_params') ?? {};
|
||||
const appUid = await entity.get('uid');
|
||||
const appName = await entity.get('name');
|
||||
const appIndexUrl = await entity.get('index_url');
|
||||
const appCreatedAt = await entity.get('created_at');
|
||||
const appIsPrivate = await entity.get('is_private');
|
||||
|
||||
const svc_appInformation = this.context.get('services').get('app-information');
|
||||
const stats = await svc_appInformation.get_stats(await entity.get('uid'), { period: Context.get('es_params')?.stats_period, grouping: Context.get('es_params')?.stats_grouping, created_at: await entity.get('created_at') });
|
||||
entity.set('stats', stats);
|
||||
const appInformationService = services.get('app-information');
|
||||
const authService = services.get('auth');
|
||||
const statsPromise = appInformationService
|
||||
? appInformationService.get_stats(appUid, {
|
||||
period: esParams.stats_period,
|
||||
grouping: esParams.stats_grouping,
|
||||
created_at: appCreatedAt,
|
||||
})
|
||||
: Promise.resolve(undefined);
|
||||
const fileAssociationsPromise = this.db.read(
|
||||
'SELECT type FROM app_filetype_association WHERE app_id = ?',
|
||||
[entity.private_meta.mysql_id],
|
||||
);
|
||||
const createdFromOriginPromise = (async () => {
|
||||
if ( ! authService ) return null;
|
||||
try {
|
||||
const origin = origin_from_url(appIndexUrl);
|
||||
const expectedUid = await authService.app_uid_from_origin(origin);
|
||||
return expectedUid === appUid ? origin : null;
|
||||
} catch {
|
||||
// This happens when index_url is not a valid URL.
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
const privateAccessPromise = resolvePrivateLaunchAccess({
|
||||
app: {
|
||||
uid: appUid,
|
||||
name: appName,
|
||||
is_private: appIsPrivate,
|
||||
},
|
||||
services,
|
||||
userUid: getActorUserUid(actor),
|
||||
source: 'driverRead',
|
||||
args: esParams,
|
||||
});
|
||||
|
||||
const [
|
||||
fileAssociationRows,
|
||||
stats,
|
||||
createdFromOrigin,
|
||||
privateAccess,
|
||||
] = await Promise.all([
|
||||
fileAssociationsPromise,
|
||||
statsPromise,
|
||||
createdFromOriginPromise,
|
||||
privateAccessPromise,
|
||||
]);
|
||||
await entity.set(
|
||||
'filetype_associations',
|
||||
fileAssociationRows.map(row => row.type),
|
||||
);
|
||||
await entity.set('stats', stats);
|
||||
await entity.set('created_from_origin', createdFromOrigin);
|
||||
await entity.set('privateAccess', privateAccess);
|
||||
|
||||
// Migrate b64 icons to the filesystem-backed icon flow without blocking reads.
|
||||
this.queueIconMigration(entity);
|
||||
|
||||
entity.set('created_from_origin', await (async () => {
|
||||
const svc_auth = this.context.get('services').get('auth');
|
||||
try {
|
||||
const origin = origin_from_url(await entity.get('index_url'));
|
||||
const expected_uid = await svc_auth.app_uid_from_origin(origin);
|
||||
return expected_uid === await entity.get('uid')
|
||||
? origin : null ;
|
||||
} catch (e) {
|
||||
// This happens when the index_url is not a valid URL
|
||||
return null;
|
||||
}
|
||||
})());
|
||||
|
||||
// Check if the user is the owner
|
||||
const is_owner = await (async () => {
|
||||
let owner = await entity.get('owner');
|
||||
@@ -386,22 +447,24 @@ class AppES extends BaseES {
|
||||
).fetchEntry();
|
||||
const subdomain = await entity.get('subdomain');
|
||||
const user = Context.get('user');
|
||||
let subdomain_res = await this.db.write(`INSERT ${this.db.case({
|
||||
mysql: 'IGNORE',
|
||||
sqlite: 'OR IGNORE',
|
||||
})} INTO subdomains
|
||||
let subdomain_res = await this.db.write(
|
||||
`INSERT ${this.db.case({
|
||||
mysql: 'IGNORE',
|
||||
sqlite: 'OR IGNORE',
|
||||
})} INTO subdomains
|
||||
(subdomain, user_id, root_dir_id, uuid) VALUES
|
||||
( ?, ?, ?, ?)`,
|
||||
[
|
||||
[
|
||||
//subdomain
|
||||
subdomain,
|
||||
//user_id
|
||||
user.id,
|
||||
//root_dir_id
|
||||
(await entity.get('source_directory')).mysql_id,
|
||||
//uuid, `sd` stands for subdomain
|
||||
`sd-${ uuidv4()}`,
|
||||
]);
|
||||
subdomain,
|
||||
//user_id
|
||||
user.id,
|
||||
//root_dir_id
|
||||
(await entity.get('source_directory')).mysql_id,
|
||||
//uuid, `sd` stands for subdomain
|
||||
`sd-${ uuidv4()}`,
|
||||
],
|
||||
);
|
||||
subdomain_id = subdomain_res.insertId;
|
||||
}
|
||||
|
||||
|
||||
@@ -96,6 +96,10 @@ module.exports = {
|
||||
type: 'json',
|
||||
sql: { ignore: true },
|
||||
},
|
||||
privateAccess: {
|
||||
type: 'json',
|
||||
sql: { ignore: true },
|
||||
},
|
||||
created_from_origin: {
|
||||
type: 'string',
|
||||
sql: { ignore: true },
|
||||
|
||||
+114
-80
@@ -24,108 +24,142 @@ const config = require('../config');
|
||||
const { get_apps } = require('../helpers');
|
||||
const { DB_READ } = require('../services/database/consts.js');
|
||||
const subdomain = require('../middleware/subdomain.js');
|
||||
let privateLaunchAccessModulePromise;
|
||||
const getPrivateLaunchAccessModule = async () => {
|
||||
if ( ! privateLaunchAccessModulePromise ) {
|
||||
privateLaunchAccessModulePromise = import('../modules/apps/privateLaunchAccess.js');
|
||||
}
|
||||
return privateLaunchAccessModulePromise;
|
||||
};
|
||||
|
||||
// -----------------------------------------------------------------------//
|
||||
// GET /apps
|
||||
// -----------------------------------------------------------------------//
|
||||
router.get('/apps',
|
||||
subdomain('api'),
|
||||
auth,
|
||||
express.json({ limit: '50mb' }),
|
||||
async (req, res) => {
|
||||
// /!\ open brace on end of previous line
|
||||
router.get(
|
||||
'/apps',
|
||||
subdomain('api'),
|
||||
auth,
|
||||
express.json({ limit: '50mb' }),
|
||||
async (req, res) => {
|
||||
// /!\ open brace on end of previous line
|
||||
|
||||
// check if user is verified
|
||||
if ( (config.strict_email_verification_required || req.user.requires_email_confirmation) && !req.user.email_confirmed )
|
||||
{
|
||||
return res.status(400).send({ code: 'account_is_not_verified', message: 'Account is not verified' });
|
||||
}
|
||||
// check if user is verified
|
||||
if ( (config.strict_email_verification_required || req.user.requires_email_confirmation) && !req.user.email_confirmed )
|
||||
{
|
||||
return res.status(400).send({ code: 'account_is_not_verified', message: 'Account is not verified' });
|
||||
}
|
||||
|
||||
const db = req.services.get('database').get(DB_READ, 'apps');
|
||||
const db = req.services.get('database').get(DB_READ, 'apps');
|
||||
|
||||
let apps_res = await db.read('SELECT * FROM apps WHERE owner_user_id = ? ORDER BY timestamp DESC',
|
||||
[req.user.id]);
|
||||
let apps_res = await db.read(
|
||||
'SELECT * FROM apps WHERE owner_user_id = ? ORDER BY timestamp DESC',
|
||||
[req.user.id],
|
||||
);
|
||||
|
||||
const svc_appInformation = req.services.get('app-information');
|
||||
const svc_appInformation = req.services.get('app-information');
|
||||
|
||||
let apps = [];
|
||||
let apps = [];
|
||||
|
||||
if ( apps_res.length > 0 ) {
|
||||
for ( let i = 0; i < apps_res.length; i++ ) {
|
||||
// filetype associations
|
||||
let ftassocs = await db.read('SELECT * FROM app_filetype_association WHERE app_id = ?',
|
||||
[apps_res[i].id]);
|
||||
if ( apps_res.length > 0 ) {
|
||||
for ( let i = 0; i < apps_res.length; i++ ) {
|
||||
// filetype associations
|
||||
let ftassocs = await db.read(
|
||||
'SELECT * FROM app_filetype_association WHERE app_id = ?',
|
||||
[apps_res[i].id],
|
||||
);
|
||||
|
||||
let filetype_associations = [];
|
||||
if ( ftassocs.length > 0 ) {
|
||||
ftassocs.forEach(ftassoc => {
|
||||
filetype_associations.push(ftassoc.type);
|
||||
});
|
||||
}
|
||||
let filetype_associations = [];
|
||||
if ( ftassocs.length > 0 ) {
|
||||
ftassocs.forEach(ftassoc => {
|
||||
filetype_associations.push(ftassoc.type);
|
||||
});
|
||||
}
|
||||
|
||||
const stats = await svc_appInformation.get_stats(apps_res[i].uid);
|
||||
const stats = await svc_appInformation.get_stats(apps_res[i].uid);
|
||||
|
||||
apps.push({
|
||||
uid: apps_res[i].uid,
|
||||
name: apps_res[i].name,
|
||||
description: apps_res[i].description,
|
||||
title: apps_res[i].title,
|
||||
icon: apps_res[i].icon,
|
||||
index_url: apps_res[i].index_url,
|
||||
godmode: apps_res[i].godmode,
|
||||
background: apps_res[i].background,
|
||||
maximize_on_start: apps_res[i].maximize_on_start,
|
||||
filetype_associations: filetype_associations,
|
||||
...stats,
|
||||
approved_for_incentive_program: apps_res[i].approved_for_incentive_program,
|
||||
created_at: apps_res[i].timestamp,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return res.send(apps);
|
||||
apps.push({
|
||||
uid: apps_res[i].uid,
|
||||
name: apps_res[i].name,
|
||||
description: apps_res[i].description,
|
||||
title: apps_res[i].title,
|
||||
icon: apps_res[i].icon,
|
||||
index_url: apps_res[i].index_url,
|
||||
godmode: apps_res[i].godmode,
|
||||
background: apps_res[i].background,
|
||||
maximize_on_start: apps_res[i].maximize_on_start,
|
||||
filetype_associations: filetype_associations,
|
||||
...stats,
|
||||
approved_for_incentive_program: apps_res[i].approved_for_incentive_program,
|
||||
created_at: apps_res[i].timestamp,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return res.send(apps);
|
||||
},
|
||||
);
|
||||
|
||||
// -----------------------------------------------------------------------//
|
||||
// GET /apps/:name(s)
|
||||
// -----------------------------------------------------------------------//
|
||||
router.get('/apps/:name',
|
||||
subdomain('api'),
|
||||
auth,
|
||||
express.json({ limit: '50mb' }),
|
||||
async (req, res, next) => {
|
||||
// /!\ open brace on end of previous line
|
||||
router.get(
|
||||
'/apps/:name',
|
||||
subdomain('api'),
|
||||
auth,
|
||||
express.json({ limit: '50mb' }),
|
||||
async (req, res, next) => {
|
||||
// /!\ open brace on end of previous line
|
||||
|
||||
// check subdomain
|
||||
if ( require('../helpers').subdomain(req) !== 'api' )
|
||||
{
|
||||
next();
|
||||
}
|
||||
// check subdomain
|
||||
if ( require('../helpers').subdomain(req) !== 'api' )
|
||||
{
|
||||
next();
|
||||
}
|
||||
|
||||
// check if user is verified
|
||||
if ( (config.strict_email_verification_required || req.user.requires_email_confirmation) && !req.user.email_confirmed )
|
||||
{
|
||||
return res.status(400).send({ code: 'account_is_not_verified', message: 'Account is not verified' });
|
||||
}
|
||||
// check if user is verified
|
||||
if ( (config.strict_email_verification_required || req.user.requires_email_confirmation) && !req.user.email_confirmed )
|
||||
{
|
||||
return res.status(400).send({ code: 'account_is_not_verified', message: 'Account is not verified' });
|
||||
}
|
||||
|
||||
let app_names = req.params.name.split('|');
|
||||
const apps = await get_apps(app_names.map(name => ({ name })));
|
||||
const {
|
||||
getActorUserUid,
|
||||
resolvePrivateLaunchAccess,
|
||||
} = await getPrivateLaunchAccessModule();
|
||||
let app_names = req.params.name.split('|');
|
||||
const apps = await get_apps(app_names.map(name => ({ name })));
|
||||
const actorUserUid = getActorUserUid(req.actor) || req.user?.uuid || null;
|
||||
const privateAccessDecisions = await Promise.all(apps.map(app => {
|
||||
if ( ! app ) return Promise.resolve(null);
|
||||
return resolvePrivateLaunchAccess({
|
||||
app,
|
||||
services: req.services,
|
||||
userUid: actorUserUid,
|
||||
source: 'appsRoute',
|
||||
args: req.query ?? {},
|
||||
});
|
||||
}));
|
||||
|
||||
const final_obj = apps.map((app) => {
|
||||
if ( ! app ) return null;
|
||||
return {
|
||||
uuid: app.uid,
|
||||
name: app.name,
|
||||
title: app.title,
|
||||
icon: app.icon,
|
||||
godmode: app.godmode,
|
||||
background: app.background,
|
||||
maximize_on_start: app.maximize_on_start,
|
||||
index_url: app.index_url,
|
||||
};
|
||||
}).filter(Boolean);
|
||||
const final_obj = apps.map((app, index) => {
|
||||
if ( ! app ) return null;
|
||||
return {
|
||||
uuid: app.uid,
|
||||
name: app.name,
|
||||
title: app.title,
|
||||
icon: app.icon,
|
||||
godmode: app.godmode,
|
||||
background: app.background,
|
||||
maximize_on_start: app.maximize_on_start,
|
||||
index_url: app.index_url,
|
||||
privateAccess: privateAccessDecisions[index] ?? {
|
||||
hasAccess: true,
|
||||
checkedBy: 'core/apps-route-default',
|
||||
},
|
||||
};
|
||||
}).filter(Boolean);
|
||||
|
||||
return res.send(final_obj);
|
||||
});
|
||||
return res.send(final_obj);
|
||||
},
|
||||
);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -41,6 +41,7 @@ const {
|
||||
origin: originUrl,
|
||||
cookie_name: cookieName,
|
||||
private_app_hosting_domain: privateAppHostingDomain,
|
||||
private_app_hosting_domain_alt: privateAppHostingDomainAlt,
|
||||
static_hosting_base_domain_redirect: staticHostingBaseDomainRedirect,
|
||||
static_hosting_domain: staticHostingDomain,
|
||||
static_hosting_domain_alt: staticHostingDomainAlt,
|
||||
@@ -61,29 +62,58 @@ function isPrivateApp (app) {
|
||||
return Number(app?.is_private ?? 0) > 0;
|
||||
}
|
||||
|
||||
function hostMatchesPrivateDomain (hostname) {
|
||||
const privateHostingDomain = `${privateAppHostingDomain ?? 'puter.dev'}`
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/^\./, '');
|
||||
if ( ! privateHostingDomain ) return false;
|
||||
function normalizeConfiguredHostname (hostValue) {
|
||||
if ( typeof hostValue !== 'string' ) return null;
|
||||
const normalizedHost = hostValue.trim().toLowerCase().replace(/^\./, '');
|
||||
if ( ! normalizedHost ) return null;
|
||||
try {
|
||||
return new URL(`http://${normalizedHost}`).hostname.toLowerCase();
|
||||
} catch {
|
||||
return normalizedHost.split(':')[0] || null;
|
||||
}
|
||||
}
|
||||
|
||||
const host = `${hostname ?? ''}`.trim().toLowerCase();
|
||||
function getPrivateHostingDomainsForMatch () {
|
||||
const domains = new Set();
|
||||
for ( const candidate of [
|
||||
privateAppHostingDomain,
|
||||
privateAppHostingDomainAlt,
|
||||
'puter.app',
|
||||
] ) {
|
||||
const normalizedCandidate = normalizeConfiguredHostname(candidate);
|
||||
if ( normalizedCandidate ) {
|
||||
domains.add(normalizedCandidate);
|
||||
}
|
||||
}
|
||||
return [...domains];
|
||||
}
|
||||
|
||||
function getPrivateHostingDomainForRedirect () {
|
||||
const primaryDomainCandidate = normalizeConfiguredHost(privateAppHostingDomain);
|
||||
if ( primaryDomainCandidate ) return primaryDomainCandidate;
|
||||
|
||||
const altDomainCandidate = normalizeConfiguredHost(privateAppHostingDomainAlt);
|
||||
if ( altDomainCandidate ) return altDomainCandidate;
|
||||
|
||||
return 'puter.app';
|
||||
}
|
||||
|
||||
function hostMatchesPrivateDomain (hostname) {
|
||||
const host = normalizeConfiguredHostname(hostname);
|
||||
if ( ! host ) return false;
|
||||
|
||||
return host === privateHostingDomain || host.endsWith(`.${privateHostingDomain}`);
|
||||
const privateHostingDomains = getPrivateHostingDomainsForMatch();
|
||||
return privateHostingDomains.some(privateHostingDomain =>
|
||||
host === privateHostingDomain || host.endsWith(`.${privateHostingDomain}`));
|
||||
}
|
||||
|
||||
function getSubdomainFromHostedRequest (req) {
|
||||
const host = `${req.hostname ?? ''}`.trim().toLowerCase();
|
||||
const host = normalizeConfiguredHostname(req.hostname);
|
||||
if ( ! host ) return '';
|
||||
|
||||
const privateHostingDomain = `${privateAppHostingDomain ?? 'puter.dev'}`
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/^\./, '');
|
||||
|
||||
if ( privateHostingDomain ) {
|
||||
const privateHostingDomains = getPrivateHostingDomainsForMatch()
|
||||
.sort((a, b) => b.length - a.length);
|
||||
for ( const privateHostingDomain of privateHostingDomains ) {
|
||||
const privateDomainSuffix = `.${privateHostingDomain}`;
|
||||
if ( host === privateHostingDomain ) {
|
||||
return '';
|
||||
@@ -103,10 +133,7 @@ function buildPrivateHostRedirectUrl (req, app) {
|
||||
}
|
||||
|
||||
try {
|
||||
const privateHostingDomain = `${privateAppHostingDomain ?? 'puter.dev'}`
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/^\./, '');
|
||||
const privateHostingDomain = getPrivateHostingDomainForRedirect();
|
||||
if ( ! privateHostingDomain ) {
|
||||
return null;
|
||||
}
|
||||
@@ -167,6 +194,7 @@ function buildPrivateAppIndexUrlCandidates (req) {
|
||||
const staticHostingDomainCandidate = normalizeConfiguredHost(staticHostingDomain);
|
||||
const staticHostingDomainAltCandidate = normalizeConfiguredHost(staticHostingDomainAlt);
|
||||
const privateHostingDomainCandidate = normalizeConfiguredHost(privateAppHostingDomain);
|
||||
const privateHostingDomainAltCandidate = normalizeConfiguredHost(privateAppHostingDomainAlt);
|
||||
|
||||
if ( staticHostingDomainCandidate ) {
|
||||
hostCandidates.add(`${hostedSubdomain}.${staticHostingDomainCandidate}`);
|
||||
@@ -177,6 +205,9 @@ function buildPrivateAppIndexUrlCandidates (req) {
|
||||
if ( privateHostingDomainCandidate ) {
|
||||
hostCandidates.add(`${hostedSubdomain}.${privateHostingDomainCandidate}`);
|
||||
}
|
||||
if ( privateHostingDomainAltCandidate ) {
|
||||
hostCandidates.add(`${hostedSubdomain}.${privateHostingDomainAltCandidate}`);
|
||||
}
|
||||
}
|
||||
|
||||
const candidates = [];
|
||||
@@ -230,6 +261,42 @@ function getPrivateDeniedRedirectUrl (app, denyRedirectUrl) {
|
||||
return '/';
|
||||
}
|
||||
|
||||
function getMarketplaceAppUrl (app) {
|
||||
const appName = typeof app?.name === 'string'
|
||||
? app.name.trim()
|
||||
: '';
|
||||
if ( ! appName ) return null;
|
||||
|
||||
const origin = `${originUrl ?? ''}`.trim().replace(/\/$/, '');
|
||||
if ( ! origin ) return null;
|
||||
|
||||
return `${origin}/app/${encodeURIComponent(appName)}/`;
|
||||
}
|
||||
|
||||
function appendLinkHeader (res, linkValue) {
|
||||
if ( ! linkValue ) return;
|
||||
const existingValue = typeof res.get === 'function'
|
||||
? res.get('Link')
|
||||
: (
|
||||
typeof res.getHeader === 'function'
|
||||
? res.getHeader('Link')
|
||||
: undefined
|
||||
);
|
||||
const setHeader = typeof res.set === 'function'
|
||||
? (value) => res.set('Link', value)
|
||||
: (
|
||||
typeof res.setHeader === 'function'
|
||||
? (value) => res.setHeader('Link', value)
|
||||
: null
|
||||
);
|
||||
if ( ! setHeader ) return;
|
||||
if ( ! existingValue ) {
|
||||
setHeader(linkValue);
|
||||
return;
|
||||
}
|
||||
setHeader(`${existingValue}, ${linkValue}`);
|
||||
}
|
||||
|
||||
function isPrivateAccessGateEnabled () {
|
||||
return config.enable_private_app_access_gate !== false;
|
||||
}
|
||||
@@ -435,7 +502,21 @@ function respondPrivateLoginBootstrap ({ res, app }) {
|
||||
typeof app?.name === 'string' && app.name.trim()
|
||||
? app.name.trim()
|
||||
: 'this app';
|
||||
const appTitle = typeof app?.title === 'string' && app.title.trim()
|
||||
? app.title.trim()
|
||||
: appName;
|
||||
const appDescription = typeof app?.description === 'string' && app.description.trim()
|
||||
? app.description.trim()
|
||||
: `${appTitle} requires Puter authentication before private files can load.`;
|
||||
const appIcon = typeof app?.icon === 'string' && app.icon.trim()
|
||||
? app.icon.trim()
|
||||
: null;
|
||||
const marketplaceAppUrl = getMarketplaceAppUrl(app);
|
||||
const safeAppName = escapeHtml(appName);
|
||||
const safeAppTitle = escapeHtml(appTitle);
|
||||
const safeAppDescription = escapeHtml(appDescription);
|
||||
const safeMarketplaceAppUrl = escapeHtml(marketplaceAppUrl ?? '');
|
||||
const safeAppIcon = escapeHtml(appIcon ?? '');
|
||||
|
||||
const loginHtml = dedent(`
|
||||
<!doctype html>
|
||||
@@ -443,7 +524,19 @@ function respondPrivateLoginBootstrap ({ res, app }) {
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Sign In Required</title>
|
||||
<title>Sign In Required | ${safeAppTitle}</title>
|
||||
<meta name="description" content="${safeAppDescription}" />
|
||||
<meta name="robots" content="noindex,nofollow" />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:title" content="Sign In Required | ${safeAppTitle}" />
|
||||
<meta property="og:description" content="${safeAppDescription}" />
|
||||
${safeMarketplaceAppUrl ? `<meta property="og:url" content="${safeMarketplaceAppUrl}" />` : ''}
|
||||
${safeAppIcon ? `<meta property="og:image" content="${safeAppIcon}" />` : ''}
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content="Sign In Required | ${safeAppTitle}" />
|
||||
<meta name="twitter:description" content="${safeAppDescription}" />
|
||||
${safeAppIcon ? `<meta name="twitter:image" content="${safeAppIcon}" />` : ''}
|
||||
${safeMarketplaceAppUrl ? `<link rel="canonical" href="${safeMarketplaceAppUrl}" />` : ''}
|
||||
<style>
|
||||
:root { color-scheme: light; }
|
||||
body {
|
||||
@@ -599,6 +692,11 @@ function respondPrivateLoginBootstrap ({ res, app }) {
|
||||
|
||||
res.status(200);
|
||||
res.set('Cache-Control', 'no-store');
|
||||
res.set('X-Robots-Tag', 'noindex, nofollow');
|
||||
appendLinkHeader(
|
||||
res,
|
||||
marketplaceAppUrl ? `<${marketplaceAppUrl}>; rel="canonical"` : null,
|
||||
);
|
||||
res.set('Content-Type', 'text/html; charset=UTF-8');
|
||||
return res.send(loginHtml);
|
||||
}
|
||||
@@ -665,6 +763,11 @@ async function evaluatePrivateAppAccess ({ req, res, services, app, requestPath
|
||||
hasPrivateCookie: identity.hasPrivateCookie,
|
||||
hasInvalidPrivateCookie: identity.hasInvalidPrivateCookie,
|
||||
});
|
||||
const marketplaceAppUrl = getMarketplaceAppUrl(app);
|
||||
appendLinkHeader(
|
||||
res,
|
||||
marketplaceAppUrl ? `<${marketplaceAppUrl}>; rel="alternate"` : null,
|
||||
);
|
||||
res.redirect(redirectUrl);
|
||||
return false;
|
||||
}
|
||||
@@ -680,7 +783,9 @@ async function evaluatePrivateAppAccess ({ req, res, services, app, requestPath
|
||||
res.cookie(
|
||||
authService.getPrivateAssetCookieName(),
|
||||
privateToken,
|
||||
authService.getPrivateAssetCookieOptions(),
|
||||
authService.getPrivateAssetCookieOptions({
|
||||
requestHostname: req.hostname,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -784,6 +889,13 @@ async function runInternal (req, res, next) {
|
||||
requestPath: req.path,
|
||||
redirectUrl: privateHostRedirect,
|
||||
});
|
||||
const marketplaceAppUrl = getMarketplaceAppUrl(privateApp);
|
||||
appendLinkHeader(
|
||||
res,
|
||||
marketplaceAppUrl
|
||||
? `<${marketplaceAppUrl}>; rel="alternate"`
|
||||
: null,
|
||||
);
|
||||
return res.redirect(privateHostRedirect);
|
||||
}
|
||||
return res.status(403).send('Private app host mismatch');
|
||||
|
||||
@@ -33,6 +33,7 @@ vi.mock('../../config.js', () => ({
|
||||
static_hosting_domain: 'site.puter.localhost',
|
||||
static_hosting_base_domain_redirect: 'https://developer.puter.com/static-hosting/',
|
||||
private_app_hosting_domain: 'puter.dev',
|
||||
private_app_hosting_domain_alt: 'puter.dev',
|
||||
enable_private_app_access_gate: true,
|
||||
origin: 'https://puter.com',
|
||||
cookie_name: 'puter.session.token',
|
||||
@@ -41,6 +42,7 @@ vi.mock('../../config.js', () => ({
|
||||
static_hosting_domain: 'site.puter.localhost',
|
||||
static_hosting_base_domain_redirect: 'https://developer.puter.com/static-hosting/',
|
||||
private_app_hosting_domain: 'puter.dev',
|
||||
private_app_hosting_domain_alt: 'puter.dev',
|
||||
enable_private_app_access_gate: true,
|
||||
origin: 'https://puter.com',
|
||||
cookie_name: 'puter.session.token',
|
||||
@@ -132,6 +134,8 @@ describe('PuterSiteMiddleware', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
config.enable_private_app_access_gate = true;
|
||||
config.private_app_hosting_domain = 'puter.dev';
|
||||
config.private_app_hosting_domain_alt = 'puter.dev';
|
||||
Context.get = vi.fn().mockReturnValue(mockContextInstance);
|
||||
getUserMockImpl = async () => null;
|
||||
getAppMockImpl = async () => null;
|
||||
@@ -314,6 +318,96 @@ describe('PuterSiteMiddleware', () => {
|
||||
expect(mockNext).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('accepts private app host matching the configured alt private domain', async () => {
|
||||
config.private_app_hosting_domain = 'app.puter.localhost:4100';
|
||||
config.private_app_hosting_domain_alt = 'puter.dev';
|
||||
|
||||
const authService = {
|
||||
getPrivateAssetCookieName: vi.fn().mockReturnValue('puter.private.asset.token'),
|
||||
verifyPrivateAssetToken: vi.fn().mockImplementation(() => {
|
||||
throw new Error('invalid');
|
||||
}),
|
||||
authenticate_from_token: vi.fn().mockImplementation(() => {
|
||||
throw new Error('invalid');
|
||||
}),
|
||||
createPrivateAssetToken: vi.fn().mockReturnValue('private-token'),
|
||||
getPrivateAssetCookieOptions: vi.fn().mockReturnValue({}),
|
||||
};
|
||||
const mockServices = {
|
||||
get: vi.fn().mockImplementation((serviceName) => {
|
||||
if ( serviceName === 'puter-site' ) {
|
||||
return {
|
||||
get_subdomain: vi.fn().mockResolvedValue({
|
||||
user_id: 101,
|
||||
associated_app_id: 202,
|
||||
root_dir_id: 303,
|
||||
}),
|
||||
};
|
||||
}
|
||||
if ( serviceName === 'filesystem' ) {
|
||||
return {
|
||||
node: vi.fn().mockResolvedValue({
|
||||
exists: vi.fn().mockResolvedValue(true),
|
||||
get: vi.fn().mockImplementation(async (fieldName) => {
|
||||
if ( fieldName === 'type' ) return 'directory';
|
||||
if ( fieldName === 'path' ) return '/alice/Public';
|
||||
return null;
|
||||
}),
|
||||
}),
|
||||
};
|
||||
}
|
||||
if ( serviceName === 'acl' ) {
|
||||
return {
|
||||
check: vi.fn().mockResolvedValue(true),
|
||||
};
|
||||
}
|
||||
if ( serviceName === 'auth' ) return authService;
|
||||
return {};
|
||||
}),
|
||||
};
|
||||
mockContextInstance.get.mockImplementation((key) => {
|
||||
if ( key === 'services' ) return mockServices;
|
||||
return null;
|
||||
});
|
||||
getUserMockImpl = async () => ({ id: 101, suspended: false });
|
||||
getAppMockImpl = async () => ({
|
||||
uid: 'app-11111111-1111-1111-1111-111111111111',
|
||||
name: 'paid-app',
|
||||
is_private: 1,
|
||||
index_url: 'https://paid.puter.dev/',
|
||||
});
|
||||
|
||||
const mockReq = {
|
||||
hostname: 'paid.puter.dev',
|
||||
subdomains: ['paid'],
|
||||
is_custom_domain: false,
|
||||
baseUrl: '',
|
||||
path: '/index.html',
|
||||
originalUrl: '/index.html',
|
||||
query: {},
|
||||
cookies: {},
|
||||
headers: {},
|
||||
ctx: mockContextInstance,
|
||||
};
|
||||
const mockRes = {
|
||||
redirect: vi.fn(),
|
||||
set: vi.fn().mockReturnThis(),
|
||||
setHeader: vi.fn(),
|
||||
status: vi.fn().mockReturnThis(),
|
||||
send: vi.fn(),
|
||||
};
|
||||
const mockNext = vi.fn();
|
||||
|
||||
await capturedMiddleware(mockReq, mockRes, mockNext);
|
||||
|
||||
expect(mockRes.redirect).not.toHaveBeenCalledWith(
|
||||
expect.stringContaining('app.puter.localhost:4100'),
|
||||
);
|
||||
expect(mockRes.status).toHaveBeenCalledWith(200);
|
||||
expect(mockRes.send).toHaveBeenCalledWith(expect.stringContaining('Sign in required'));
|
||||
expect(mockNext).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('serves login bootstrap html when private app identity is missing', async () => {
|
||||
const eventEmit = vi.fn().mockImplementation(async (_eventName, event) => {
|
||||
event.result.allowed = false;
|
||||
@@ -529,6 +623,8 @@ describe('PuterSiteMiddleware', () => {
|
||||
expect(mockRes.status).toHaveBeenCalledWith(200);
|
||||
expect(mockRes.send).toHaveBeenCalledWith(expect.stringContaining('https://js.puter.com/v2/'));
|
||||
expect(mockRes.send).toHaveBeenCalledWith(expect.stringContaining('puter.auth.signIn()'));
|
||||
expect(mockRes.send).toHaveBeenCalledWith(expect.stringContaining('meta property="og:title"'));
|
||||
expect(mockRes.send).toHaveBeenCalledWith(expect.stringContaining('/app/paid-app/'));
|
||||
expect(mockNext).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -742,6 +838,124 @@ describe('PuterSiteMiddleware', () => {
|
||||
expect(mockNext).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('passes request hostname to private asset cookie options on allow', async () => {
|
||||
const eventEmit = vi.fn().mockImplementation(async (_eventName, event) => {
|
||||
event.result.allowed = true;
|
||||
});
|
||||
const rootDirectoryNode = {
|
||||
fetchEntry: vi.fn().mockResolvedValue(undefined),
|
||||
exists: vi.fn().mockResolvedValue(true),
|
||||
get: vi.fn().mockImplementation(async (fieldName) => {
|
||||
if ( fieldName === 'type' ) return 'directory';
|
||||
if ( fieldName === 'path' ) return '/alice/Public';
|
||||
return null;
|
||||
}),
|
||||
};
|
||||
const missingFileNode = {
|
||||
fetchEntry: vi.fn().mockResolvedValue(undefined),
|
||||
exists: vi.fn().mockResolvedValue(false),
|
||||
get: vi.fn().mockResolvedValue(null),
|
||||
};
|
||||
let filesystemNodeCallCount = 0;
|
||||
const authService = {
|
||||
getPrivateAssetCookieName: vi.fn().mockReturnValue('puter.private.asset.token'),
|
||||
verifyPrivateAssetToken: vi.fn().mockImplementation(() => {
|
||||
throw new Error('invalid');
|
||||
}),
|
||||
authenticate_from_token: vi.fn().mockResolvedValue({
|
||||
type: {},
|
||||
get_related_actor: vi.fn().mockReturnValue({
|
||||
type: {
|
||||
user: { uuid: 'user-allow-111' },
|
||||
session: 'session-allow-111',
|
||||
},
|
||||
}),
|
||||
}),
|
||||
createPrivateAssetToken: vi.fn().mockReturnValue('private-token'),
|
||||
getPrivateAssetCookieOptions: vi.fn().mockReturnValue({ sameSite: 'none' }),
|
||||
};
|
||||
const mockServices = {
|
||||
get: vi.fn().mockImplementation((serviceName) => {
|
||||
if ( serviceName === 'puter-site' ) {
|
||||
return {
|
||||
get_subdomain: vi.fn().mockResolvedValue({
|
||||
user_id: 101,
|
||||
associated_app_id: 202,
|
||||
root_dir_id: 303,
|
||||
}),
|
||||
};
|
||||
}
|
||||
if ( serviceName === 'filesystem' ) {
|
||||
return {
|
||||
node: vi.fn().mockImplementation(async () => {
|
||||
filesystemNodeCallCount += 1;
|
||||
return filesystemNodeCallCount === 1
|
||||
? rootDirectoryNode
|
||||
: missingFileNode;
|
||||
}),
|
||||
};
|
||||
}
|
||||
if ( serviceName === 'acl' ) {
|
||||
return {
|
||||
check: vi.fn().mockResolvedValue(true),
|
||||
};
|
||||
}
|
||||
if ( serviceName === 'event' ) return { emit: eventEmit };
|
||||
if ( serviceName === 'auth' ) return authService;
|
||||
return {};
|
||||
}),
|
||||
};
|
||||
mockContextInstance.get.mockImplementation((key) => {
|
||||
if ( key === 'services' ) return mockServices;
|
||||
return null;
|
||||
});
|
||||
getUserMockImpl = async () => ({ id: 101, suspended: false });
|
||||
getAppMockImpl = async () => ({
|
||||
uid: 'app-11111111-1111-1111-1111-111111111111',
|
||||
name: 'paid-app',
|
||||
is_private: 1,
|
||||
index_url: 'https://paid.puter.dev/',
|
||||
});
|
||||
|
||||
const mockReq = {
|
||||
hostname: 'paid.puter.dev',
|
||||
subdomains: [],
|
||||
is_custom_domain: false,
|
||||
baseUrl: '',
|
||||
path: '/asset.js',
|
||||
originalUrl: '/asset.js',
|
||||
cookies: {
|
||||
'puter.session.token': 'session-token',
|
||||
},
|
||||
headers: {},
|
||||
query: {},
|
||||
ctx: mockContextInstance,
|
||||
};
|
||||
const mockRes = {
|
||||
redirect: vi.fn(),
|
||||
cookie: vi.fn(),
|
||||
setHeader: vi.fn(),
|
||||
set: vi.fn().mockReturnThis(),
|
||||
status: vi.fn().mockReturnThis(),
|
||||
send: vi.fn(),
|
||||
write: vi.fn(),
|
||||
end: vi.fn(),
|
||||
};
|
||||
const mockNext = vi.fn();
|
||||
|
||||
await capturedMiddleware(mockReq, mockRes, mockNext);
|
||||
|
||||
expect(authService.getPrivateAssetCookieOptions).toHaveBeenCalledWith({
|
||||
requestHostname: 'paid.puter.dev',
|
||||
});
|
||||
expect(mockRes.cookie).toHaveBeenCalledWith(
|
||||
'puter.private.asset.token',
|
||||
'private-token',
|
||||
{ sameSite: 'none' },
|
||||
);
|
||||
expect(mockNext).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('accepts nested query token key for bootstrap auth', async () => {
|
||||
const eventEmit = vi.fn().mockImplementation(async (_eventName, event) => {
|
||||
event.result.allowed = false;
|
||||
|
||||
@@ -287,7 +287,66 @@ class AuthService extends BaseService {
|
||||
return DEFAULT_PRIVATE_APP_ASSET_COOKIE_NAME;
|
||||
}
|
||||
|
||||
getPrivateAssetCookieOptions ({ ttlSeconds } = {}) {
|
||||
normalizeHostnameForCookieDomain (hostnameValue) {
|
||||
if ( typeof hostnameValue !== 'string' ) return null;
|
||||
const trimmedHostname = hostnameValue.trim().toLowerCase().replace(/^\./, '');
|
||||
if ( ! trimmedHostname ) return null;
|
||||
try {
|
||||
return new URL(`http://${trimmedHostname}`).hostname.toLowerCase();
|
||||
} catch {
|
||||
return trimmedHostname.split(':')[0] || null;
|
||||
}
|
||||
}
|
||||
|
||||
isCookieDomainHostEligible (hostnameValue) {
|
||||
if ( typeof hostnameValue !== 'string' || !hostnameValue ) return false;
|
||||
if ( hostnameValue === 'localhost' ) return false;
|
||||
if ( hostnameValue.includes(':') ) return false;
|
||||
if ( ! hostnameValue.includes('.') ) return false;
|
||||
if ( /^\d{1,3}(?:\.\d{1,3}){3}$/.test(hostnameValue) ) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
getConfiguredPrivateCookieDomains () {
|
||||
const configuredDomains = [];
|
||||
for ( const configuredDomainCandidate of [
|
||||
this.global_config.private_app_hosting_domain,
|
||||
this.global_config.private_app_hosting_domain_alt,
|
||||
] ) {
|
||||
const normalizedDomain = this.normalizeHostnameForCookieDomain(configuredDomainCandidate);
|
||||
if ( normalizedDomain ) {
|
||||
configuredDomains.push(normalizedDomain);
|
||||
}
|
||||
}
|
||||
return [...new Set(configuredDomains)];
|
||||
}
|
||||
|
||||
resolvePrivateAssetCookieDomain ({ requestHostname } = {}) {
|
||||
const configuredDomains = this.getConfiguredPrivateCookieDomains();
|
||||
const normalizedRequestHost = this.normalizeHostnameForCookieDomain(requestHostname);
|
||||
|
||||
if ( normalizedRequestHost ) {
|
||||
const matchedConfiguredDomain = configuredDomains
|
||||
.sort((domainA, domainB) => domainB.length - domainA.length)
|
||||
.find(configuredDomain =>
|
||||
normalizedRequestHost === configuredDomain ||
|
||||
normalizedRequestHost.endsWith(`.${configuredDomain}`));
|
||||
if ( this.isCookieDomainHostEligible(matchedConfiguredDomain) ) {
|
||||
return `.${matchedConfiguredDomain}`;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const normalizedConfiguredPrimaryDomain = this.normalizeHostnameForCookieDomain(
|
||||
this.global_config.private_app_hosting_domain,
|
||||
);
|
||||
if ( this.isCookieDomainHostEligible(normalizedConfiguredPrimaryDomain) ) {
|
||||
return `.${normalizedConfiguredPrimaryDomain}`;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
getPrivateAssetCookieOptions ({ ttlSeconds, requestHostname } = {}) {
|
||||
const effectiveTtlSeconds = this.resolvePositiveInteger(
|
||||
ttlSeconds,
|
||||
this.getPrivateAssetTokenTtlSeconds(),
|
||||
@@ -301,16 +360,9 @@ class AuthService extends BaseService {
|
||||
maxAge: effectiveTtlSeconds * 1000,
|
||||
};
|
||||
|
||||
const privateHostingDomain = `${this.global_config.private_app_hosting_domain ?? ''}`
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/^\./, '');
|
||||
if (
|
||||
privateHostingDomain &&
|
||||
privateHostingDomain !== 'localhost' &&
|
||||
!privateHostingDomain.includes(':')
|
||||
) {
|
||||
cookieOptions.domain = `.${privateHostingDomain}`;
|
||||
const cookieDomain = this.resolvePrivateAssetCookieDomain({ requestHostname });
|
||||
if ( cookieDomain ) {
|
||||
cookieOptions.domain = cookieDomain;
|
||||
}
|
||||
|
||||
return cookieOptions;
|
||||
|
||||
@@ -8,6 +8,7 @@ type AuthServiceForPrivateTokenTests = AuthService & {
|
||||
private_app_asset_token_ttl_seconds: number;
|
||||
private_app_asset_cookie_name: string;
|
||||
private_app_hosting_domain: string;
|
||||
private_app_hosting_domain_alt?: string;
|
||||
};
|
||||
modules: {
|
||||
jwt: {
|
||||
@@ -28,6 +29,7 @@ const createAuthService = (): AuthServiceForPrivateTokenTests => {
|
||||
private_app_asset_token_ttl_seconds: 3600,
|
||||
private_app_asset_cookie_name: 'puter.private.asset.token',
|
||||
private_app_hosting_domain: 'app.puter.localhost',
|
||||
private_app_hosting_domain_alt: 'puter.dev',
|
||||
};
|
||||
authService.modules = {
|
||||
jwt: {
|
||||
@@ -110,6 +112,30 @@ describe('AuthService private asset token helpers', () => {
|
||||
expect(options.domain).toBe('.app.puter.localhost');
|
||||
});
|
||||
|
||||
it('uses the matched request host private domain when provided', () => {
|
||||
const authService = createAuthService();
|
||||
authService.global_config.private_app_hosting_domain = 'app.puter.localhost';
|
||||
authService.global_config.private_app_hosting_domain_alt = 'puter.dev';
|
||||
|
||||
const options = authService.getPrivateAssetCookieOptions({
|
||||
requestHostname: 'beans.puter.dev',
|
||||
});
|
||||
|
||||
expect(options.domain).toBe('.puter.dev');
|
||||
});
|
||||
|
||||
it('omits domain when request host does not match configured private domains', () => {
|
||||
const authService = createAuthService();
|
||||
authService.global_config.private_app_hosting_domain = 'puter.app';
|
||||
authService.global_config.private_app_hosting_domain_alt = 'puter.app';
|
||||
|
||||
const options = authService.getPrivateAssetCookieOptions({
|
||||
requestHostname: 'beans.puter.dev',
|
||||
});
|
||||
|
||||
expect(options.domain).toBeUndefined();
|
||||
});
|
||||
|
||||
it('resolves bootstrap identity from app-under-user token without app lookup', async () => {
|
||||
const authService = createAuthService();
|
||||
const userUid = '4b0cecf8-dd6a-4eb5-bcc4-c76cc7e8d7f0';
|
||||
|
||||
@@ -32,6 +32,13 @@ Arguments to pass to the app.
|
||||
## Return value
|
||||
A `Promise` that will resolve to an [`AppConnection`](/Objects/AppConnection) once the app is launched.
|
||||
|
||||
When private-access routing applies, the resolved connection may include
|
||||
`connection.response.launchResult` with fields such as:
|
||||
- `requestedAppName`
|
||||
- `openedAppName`
|
||||
- `redirectedToFallback`
|
||||
- `deniedPrivateAccess`
|
||||
|
||||
## Examples
|
||||
|
||||
```html
|
||||
|
||||
@@ -21,6 +21,43 @@ import path from '../lib/path.js';
|
||||
import { PROCESS_IPC_ATTACHED, PROCESS_RUNNING, PortalProcess, PseudoProcess } from '../definitions.js';
|
||||
import UIWindow from '../UI/UIWindow.js';
|
||||
|
||||
const normalizePrivateAccessDecision = (privateAccess) => {
|
||||
if ( !privateAccess || typeof privateAccess !== 'object' ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
hasAccess: !!privateAccess.hasAccess,
|
||||
fallbackAppName: typeof privateAccess.fallbackAppName === 'string'
|
||||
? privateAccess.fallbackAppName.trim()
|
||||
: '',
|
||||
fallbackArgs: privateAccess.fallbackArgs &&
|
||||
typeof privateAccess.fallbackArgs === 'object' &&
|
||||
!Array.isArray(privateAccess.fallbackArgs)
|
||||
? privateAccess.fallbackArgs
|
||||
: {},
|
||||
reason: typeof privateAccess.reason === 'string'
|
||||
? privateAccess.reason
|
||||
: undefined,
|
||||
};
|
||||
};
|
||||
|
||||
const getLaunchResult = (launchOutcome) => {
|
||||
if ( !launchOutcome || typeof launchOutcome !== 'object' ) {
|
||||
return null;
|
||||
}
|
||||
if ( launchOutcome.launchResult && typeof launchOutcome.launchResult === 'object' ) {
|
||||
return launchOutcome.launchResult;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const endLaunchTransaction = (transaction) => {
|
||||
if ( transaction ) {
|
||||
transaction.end();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Launches an app.
|
||||
*
|
||||
@@ -39,6 +76,7 @@ const launch_app = async (options) => {
|
||||
const uuid = options.uuid ?? window.uuidv4();
|
||||
let icon, title, file_signature;
|
||||
const window_options = options.window_options ?? {};
|
||||
const privateLaunchRedirectDepth = Number(options.privateLaunchRedirectDepth ?? 0);
|
||||
|
||||
if ( options.parent_instance_id ) {
|
||||
window_options.parent_instance_id = options.parent_instance_id;
|
||||
@@ -66,6 +104,70 @@ const launch_app = async (options) => {
|
||||
|
||||
// If no `options.name` is provided, use the app name from the app_info
|
||||
options.name = options.name ?? app_info.name;
|
||||
const requestedAppName = options.privateLaunchRequestedAppName ?? options.name ?? app_info.name ?? null;
|
||||
const privateAccessDecision = normalizePrivateAccessDecision(app_info.privateAccess);
|
||||
|
||||
if ( privateAccessDecision && privateAccessDecision.hasAccess === false ) {
|
||||
const fallbackAppName = privateAccessDecision.fallbackAppName;
|
||||
const fallbackArgs = privateAccessDecision.fallbackArgs ?? {};
|
||||
|
||||
if ( fallbackAppName && privateLaunchRedirectDepth < 1 && fallbackAppName !== options.name ) {
|
||||
const fallbackLaunchOutcome = await launch_app({
|
||||
...options,
|
||||
name: fallbackAppName,
|
||||
args: fallbackArgs,
|
||||
app_obj: undefined,
|
||||
privateLaunchRequestedAppName: requestedAppName,
|
||||
privateLaunchRedirectDepth: privateLaunchRedirectDepth + 1,
|
||||
});
|
||||
|
||||
const fallbackLaunchResult = getLaunchResult(fallbackLaunchOutcome) ?? {
|
||||
launched: true,
|
||||
requestedAppName,
|
||||
openedAppName: fallbackAppName,
|
||||
redirectedToFallback: true,
|
||||
deniedPrivateAccess: true,
|
||||
privateAccess: privateAccessDecision,
|
||||
};
|
||||
const redirectedLaunchResult = {
|
||||
...fallbackLaunchResult,
|
||||
requestedAppName,
|
||||
redirectedToFallback: true,
|
||||
deniedPrivateAccess: true,
|
||||
privateAccess: privateAccessDecision,
|
||||
};
|
||||
|
||||
if ( fallbackLaunchOutcome && typeof fallbackLaunchOutcome === 'object' ) {
|
||||
fallbackLaunchOutcome.launchResult = redirectedLaunchResult;
|
||||
}
|
||||
|
||||
endLaunchTransaction(transaction);
|
||||
return fallbackLaunchOutcome ?? { launchResult: redirectedLaunchResult };
|
||||
}
|
||||
|
||||
const deniedAppTitle = app_info.title ?? app_info.name ?? options.name ?? 'this app';
|
||||
const safeDeniedAppTitle = window.html_encode
|
||||
? window.html_encode(deniedAppTitle)
|
||||
: deniedAppTitle;
|
||||
if ( typeof window.UIAlert === 'function' ) {
|
||||
await window.UIAlert(`You don't have access to ${safeDeniedAppTitle}.`);
|
||||
} else {
|
||||
window.alert(`You don't have access to ${deniedAppTitle}.`);
|
||||
}
|
||||
|
||||
const deniedLaunchResult = {
|
||||
launched: false,
|
||||
requestedAppName,
|
||||
openedAppName: null,
|
||||
appInstanceID: null,
|
||||
appUid: null,
|
||||
redirectedToFallback: false,
|
||||
deniedPrivateAccess: true,
|
||||
privateAccess: privateAccessDecision,
|
||||
};
|
||||
endLaunchTransaction(transaction);
|
||||
return { launchResult: deniedLaunchResult };
|
||||
}
|
||||
|
||||
//-----------------------------------
|
||||
// icon
|
||||
@@ -527,11 +629,21 @@ const launch_app = async (options) => {
|
||||
svc_process.unregister(process.uuid);
|
||||
});
|
||||
|
||||
process.launchResult = {
|
||||
launched: true,
|
||||
requestedAppName,
|
||||
openedAppName: (options.name === 'trash') ? 'explorer' : options.name,
|
||||
appInstanceID: process.uuid ?? uuid,
|
||||
appUid: (options.name === 'explorer' || options.name === 'trash')
|
||||
? null
|
||||
: (app_info.uuid ?? app_info.uid ?? null),
|
||||
redirectedToFallback: privateLaunchRedirectDepth > 0,
|
||||
deniedPrivateAccess: false,
|
||||
privateAccess: privateAccessDecision ?? undefined,
|
||||
};
|
||||
|
||||
// end the transaction
|
||||
if ( transaction )
|
||||
{
|
||||
transaction.end();
|
||||
}
|
||||
endLaunchTransaction(transaction);
|
||||
|
||||
return process;
|
||||
};
|
||||
|
||||
@@ -61,8 +61,6 @@ export class ExecService extends Service {
|
||||
target: child_instance_id,
|
||||
}) : undefined;
|
||||
|
||||
this.log.info('launchApp connection', connection);
|
||||
|
||||
const params = {};
|
||||
for ( const provider of this.param_providers ) {
|
||||
Object.assign(params, provider());
|
||||
@@ -116,15 +114,12 @@ export class ExecService extends Service {
|
||||
// Check if file_paths are provided and caller has godmode permissions
|
||||
if ( file_paths && Array.isArray(file_paths) && file_paths.length > 0 && process ) {
|
||||
try {
|
||||
console.log('file_paths', file_paths);
|
||||
// Get caller app info to check godmode status
|
||||
const caller_app_name = process.name;
|
||||
const caller_app_info = await window.get_apps(caller_app_name);
|
||||
|
||||
// Check if caller is in godmode
|
||||
if ( caller_app_info && caller_app_info.godmode === 1 ) {
|
||||
this.log.info(`⚠️ GODMODE app ${caller_app_name} launching ${app_name} with files:`, file_paths);
|
||||
|
||||
// Get target app info to create file signatures
|
||||
const target_app_info = await puter.apps.get(app_name);
|
||||
|
||||
@@ -162,18 +157,27 @@ export class ExecService extends Service {
|
||||
}
|
||||
}
|
||||
|
||||
} else {
|
||||
console.log(`⚠️ App ${caller_app_name} attempted to launch ${app_name} with files but does not have godmode permissions`);
|
||||
// Continue with normal launch, ignoring file_paths
|
||||
}
|
||||
} catch ( error ) {
|
||||
console.log('Error checking godmode permissions:', error);
|
||||
this.log.warn('Error checking godmode permissions', error);
|
||||
// Continue with normal launch
|
||||
}
|
||||
}
|
||||
|
||||
// The "body" of this method is in a separate file
|
||||
const child_process = await launch_app(launch_options);
|
||||
const child_launch_outcome = await launch_app(launch_options);
|
||||
const launchResult = child_launch_outcome?.launchResult;
|
||||
const child_process = child_launch_outcome?.references?.iframe
|
||||
? child_launch_outcome
|
||||
: null;
|
||||
|
||||
if ( ! child_process ) {
|
||||
return {
|
||||
appInstanceID: launchResult?.appInstanceID ?? null,
|
||||
usesSDK: false,
|
||||
...(launchResult ? { response: { launchResult } } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
const send_child_launched_msg = (...a) => {
|
||||
if ( ! process ) return;
|
||||
@@ -223,6 +227,7 @@ export class ExecService extends Service {
|
||||
appInstanceID: connection ?
|
||||
connection.forward.uuid : child_instance_id,
|
||||
usesSDK: true,
|
||||
...(launchResult ? { response: { launchResult } } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Vendored
+19
-1
@@ -79,11 +79,29 @@ export interface AppConnectionCloseEvent {
|
||||
statusCode?: number;
|
||||
}
|
||||
|
||||
export interface LaunchAppResult {
|
||||
launched: boolean;
|
||||
requestedAppName?: string | null;
|
||||
openedAppName?: string | null;
|
||||
appInstanceID?: string | null;
|
||||
appUid?: string | null;
|
||||
redirectedToFallback?: boolean;
|
||||
deniedPrivateAccess?: boolean;
|
||||
privateAccess?: {
|
||||
hasAccess: boolean;
|
||||
fallbackAppName?: string;
|
||||
fallbackArgs?: Record<string, unknown>;
|
||||
reason?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export type CancelAwarePromise<T> = Promise<T> & { undefinedOnCancel?: Promise<T | undefined> };
|
||||
|
||||
export class AppConnection {
|
||||
readonly usesSDK: boolean;
|
||||
readonly response?: Record<string, unknown>;
|
||||
readonly response?: Record<string, unknown> & {
|
||||
launchResult?: LaunchAppResult;
|
||||
};
|
||||
|
||||
on (eventName: 'message', handler: (message: unknown) => void): void;
|
||||
on (eventName: 'close', handler: (data: AppConnectionCloseEvent) => void): void;
|
||||
|
||||
Reference in New Issue
Block a user