diff --git a/extensions/api.d.ts b/extensions/api.d.ts index 7cb8581d0..20d4bd38d 100644 --- a/extensions/api.d.ts +++ b/extensions/api.d.ts @@ -140,6 +140,20 @@ export interface ExtensionEventTypeMap { checkedBy?: string; }; }; + 'app.privateAccess.resolveLaunch': { + appUid: string; + appName?: string; + userUid?: string | null; + source?: string; + args?: Record; + result: { + hasAccess: boolean; + fallbackAppName?: string; + fallbackArgs?: Record; + reason?: string; + checkedBy?: string; + }; + }; 'ai.prompt.validate': { actor: Actor; actor, diff --git a/src/backend/src/modules/apps/privateLaunchAccess.js b/src/backend/src/modules/apps/privateLaunchAccess.js new file mode 100644 index 000000000..951437833 --- /dev/null +++ b/src/backend/src/modules/apps/privateLaunchAccess.js @@ -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 . + */ + +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, +}; diff --git a/src/backend/src/om/entitystorage/AppES.js b/src/backend/src/om/entitystorage/AppES.js index 2973b4450..43d303fd0 100644 --- a/src/backend/src/om/entitystorage/AppES.js +++ b/src/backend/src/om/entitystorage/AppES.js @@ -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; } diff --git a/src/backend/src/om/mappings/app.js b/src/backend/src/om/mappings/app.js index 8716b306d..bb5643988 100644 --- a/src/backend/src/om/mappings/app.js +++ b/src/backend/src/om/mappings/app.js @@ -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 }, diff --git a/src/backend/src/routers/apps.js b/src/backend/src/routers/apps.js index 55ea3cdfe..ddd49cb36 100644 --- a/src/backend/src/routers/apps.js +++ b/src/backend/src/routers/apps.js @@ -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; diff --git a/src/backend/src/routers/hosting/puterSiteMiddleware.js b/src/backend/src/routers/hosting/puterSiteMiddleware.js index 0da172f6b..7f8c31969 100644 --- a/src/backend/src/routers/hosting/puterSiteMiddleware.js +++ b/src/backend/src/routers/hosting/puterSiteMiddleware.js @@ -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(` @@ -443,7 +524,19 @@ function respondPrivateLoginBootstrap ({ res, app }) { - Sign In Required + Sign In Required | ${safeAppTitle} + + + + + + ${safeMarketplaceAppUrl ? `` : ''} + ${safeAppIcon ? `` : ''} + + + + ${safeAppIcon ? `` : ''} + ${safeMarketplaceAppUrl ? `` : ''}