From 784847b1b1404677feaf2e82cc1b3567338f08bd Mon Sep 17 00:00:00 2001 From: Daniel Salazar Date: Thu, 26 Feb 2026 23:42:32 -0800 Subject: [PATCH] feat: enforce private app hosting access gate (#2557) Add private app access gating in PuterSiteMiddleware with entitlement event checks, bootstrap/private cookie token flow, and camelCase helper/test updates. --- src/backend/src/CoreModule.js | 2 +- src/backend/src/config.js | 4 +- src/backend/src/routers/_default.js | 2 +- src/backend/src/routers/hosting/puter-site.js | 654 ------------- .../src/routers/hosting/puter-site.test.js | 233 ----- .../routers/hosting/puterSiteMiddleware.js | 887 ++++++++++++++++++ .../hosting/puterSiteMiddleware.test.js | 409 ++++++++ src/backend/src/services/ServeGUIService.js | 25 +- src/backend/src/services/auth/AuthService.js | 1 - .../AuthService.privateAssetToken.test.ts | 4 +- 10 files changed, 1316 insertions(+), 905 deletions(-) delete mode 100644 src/backend/src/routers/hosting/puter-site.js delete mode 100644 src/backend/src/routers/hosting/puter-site.test.js create mode 100644 src/backend/src/routers/hosting/puterSiteMiddleware.js create mode 100644 src/backend/src/routers/hosting/puterSiteMiddleware.test.js diff --git a/src/backend/src/CoreModule.js b/src/backend/src/CoreModule.js index cea0be5df..8d017d4f1 100644 --- a/src/backend/src/CoreModule.js +++ b/src/backend/src/CoreModule.js @@ -149,7 +149,7 @@ const install = async ({ context, services, app, useapi, modapi }) => { const { ConfigurableCountingService } = require('./services/ConfigurableCountingService'); const { FSLockService } = require('./services/fs/FSLockService'); const FilesystemAPIService = require('./services/FilesystemAPIService'); - const ServeGUIService = require('./services/ServeGUIService'); + const { ServeGUIService } = require('./services/ServeGUIService'); const PuterAPIService = require('./services/PuterAPIService'); const { RefreshAssociationsService } = require('./services/RefreshAssociationsService'); // Service names beginning with '__' aren't called by other services; diff --git a/src/backend/src/config.js b/src/backend/src/config.js index 3e2bc7065..bb2af7f6c 100644 --- a/src/backend/src/config.js +++ b/src/backend/src/config.js @@ -171,8 +171,8 @@ const computed_defaults = { static_hosting_domain: config => `site.${ config.domain }${ maybe_port(config)}`, // Hostname-only fallback helps host matching code paths that compare against req.hostname. static_hosting_domain_alt: (config) => `site.${ config.domain }`, - private_app_hosting_domain: config => `apps.${ config.domain }${maybe_port(config)}`, - private_app_hosting_domain_alt: config => `apps.${ config.domain }`, // Hostname-only fallback helps host matching code paths that compare against req.hostname. + private_app_hosting_domain: config => `app.${ config.domain }${maybe_port(config)}`, + private_app_hosting_domain_alt: config => `app.${ config.domain }`, // Hostname-only fallback helps host matching code paths that compare against req.hostname. }; diff --git a/src/backend/src/routers/_default.js b/src/backend/src/routers/_default.js index b01240ef3..c1ce46b2e 100644 --- a/src/backend/src/routers/_default.js +++ b/src/backend/src/routers/_default.js @@ -496,4 +496,4 @@ router.all('*', async function (req, res, next) { } }); -module.exports = router; +module.exports.catchAllRouter = router; diff --git a/src/backend/src/routers/hosting/puter-site.js b/src/backend/src/routers/hosting/puter-site.js deleted file mode 100644 index 57ec2002c..000000000 --- a/src/backend/src/routers/hosting/puter-site.js +++ /dev/null @@ -1,654 +0,0 @@ -/* - * 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 . - */ -const { AdvancedBase } = require('@heyputer/putility'); -const api_error_handler = require('../../modules/web/lib/api_error_handler'); -const config = require('../../config'); -const { get_user, get_app } = require('../../helpers'); -const { Context } = require('../../util/context'); -const { NodeInternalIDSelector, NodePathSelector } = require('../../filesystem/node/selectors'); -const { TYPE_DIRECTORY } = require('../../filesystem/FSNodeContext'); -const { LLRead } = require('../../filesystem/ll_operations/ll_read'); -const { stream_to_buffer: streamToBuffer } = require('../../util/streamutil'); -const { Actor, UserActorType, SiteActorType } = require('../../services/auth/Actor'); -const APIError = require('../../api/APIError'); -const { PermissionUtil } = require('../../services/auth/permissionUtils.mjs'); -const { default: dedent } = require('dedent'); -const { - parseSiteErrorConfig, - getSiteErrorRule, -} = require('./puter-site-config'); - -const AT_DIRECTORY_NAMESPACE = '4aa6dc52-34c1-4b8a-b63c-a62b27f727cf'; -const puterSiteConfigFilename = '.puter_site_config'; -const puterSiteConfigMaxSize = 256 * 1024; - -class PuterSiteMiddleware extends AdvancedBase { - static MODULES = { - path: require('path'), - mime: require('mime-types'), - uuidv5: require('uuid').v5, - }; - install (app) { - app.use(this.run.bind(this)); - } - /** - * function wraps run_ - * - * @param {import("express").Request} req - * @param {import("express").Response} res - * @param {any} next - * @returns - */ - async run (req, res, next) { - - !req.hostname.endsWith(config.static_hosting_domain) - && ( req.subdomains[0] !== 'devtest' ); - - const is_subdomain = - req.hostname.endsWith(config.static_hosting_domain) - || (config.static_hosting_domain_alt && req.hostname.endsWith(config.static_hosting_domain_alt)) - || req.subdomains[0] === 'devtest' - ; - - if ( !is_subdomain && !req.is_custom_domain ) return next(); - - res.setHeader('Access-Control-Allow-Origin', '*'); - - try { - const expected_ctx = req.ctx; - const received_ctx = Context.get(); - - if ( expected_ctx && !received_ctx ) { - await expected_ctx.arun(async () => { - await this.run_(req, res, next); - }); - } else await this.run_(req, res, next); - } catch ( e ) { - console.error('puter-site middleware error', e); - if ( !res.headersSent && req.__puterSiteRootPath ) { - try { - const handled = await this.respondSiteError({ - path: req.path, - req, - res, - next, - subdomainRootPath: req.__puterSiteRootPath, - }); - if ( handled ) return; - } catch ( site_error ) { - console.error('failed handling site error response', site_error); - } - } - api_error_handler(e, req, res, next); - } - } - /** - * Mega function which handles all requests to "*.puter.site" - * - * @param {import("express").Request} req - * @param {import("express").Response} res - * @param {any} next - * @returns - */ - async run_ (req, res, next) { - const subdomain = - req.is_custom_domain ? req.hostname : - req.subdomains[0] === 'devtest' ? 'devtest' : - req.hostname.split('.')[0]; - - let path = (req.baseUrl + req.path) || 'index.html'; - - const context = Context.get(); - const services = context.get('services'); - - const get_username_site = (async () => { - if ( ! subdomain.endsWith('.at') ) return; - const parts = subdomain.split('.'); - if ( parts.length !== 2 ) return; - const username = parts[0]; - if ( ! username.match(config.username_regex) ) { - return; - } - const svc_fs = services.get('filesystem'); - const index_node = await svc_fs.node(new NodePathSelector(`/${username}/Public/index.html`)); - const node = await svc_fs.node(new NodePathSelector(`/${username}/Public`)); - if ( ! await index_node.exists() ) return; - - return { - name: `${username }.at`, - uuid: this.modules.uuidv5(username, AT_DIRECTORY_NAMESPACE), - root_dir_id: await node.get('mysql-id'), - }; - }); - - if ( req.hostname === config.static_hosting_domain || req.hostname === config.static_hosting_domain_alt || subdomain === 'www' ) { - - // redirect to information page about static hosting - return res.redirect(config.static_hosting_base_domain_redirect); - } - - const site = - await get_username_site() || - await (async () => { - const svc_puterSite = services.get('puter-site'); - const site = await svc_puterSite.get_subdomain(subdomain, { - is_custom_domain: req.is_custom_domain, - }); - return site; - })(); - - if ( site === null ) { - return res.status(404).send('Subdomain not found'); - } - - const subdomain_owner = await get_user({ id: site.user_id }); - if ( subdomain_owner?.suspended ) { - // This used to be "401 Account suspended", but this implies - // the client user is suspended, which is not the case. - // Instead we simply return 404, indicating that this page - // doesn't exist without further specifying that the owner's - // account is suspended. (the client user doesn't need to know) - return res.status(404).send('Subdomain not found'); - } - - if ( - site.associated_app_id && - !req.query['puter.app_instance_id'] && - ( path === '' || path.endsWith('/') ) - ) { - const app = await get_app({ id: site.associated_app_id }); - return res.redirect(`${config.origin}/app/${app.name}/`); - } - - if ( path === '' ) path += '/index.html'; - else if ( path.endsWith('/') ) path += 'index.html'; - - const resolved_url_path = - this.modules.path.resolve('/', path); - - const svc_fs = services.get('filesystem'); - - let subdomainRootPath = ''; - if ( site.root_dir_id !== null && site.root_dir_id !== undefined ) { - const node = await svc_fs.node(new NodeInternalIDSelector('mysql', site.root_dir_id)); - if ( ! await node.exists() ) { - return res.status(502).send('subdomain is pointing to deleted directory'); - } - if ( await node.get('type') !== TYPE_DIRECTORY ) { - return res.status(502).send('subdomain is pointing to non-directory'); - } - - // Verify subdomain owner permission - const subdomain_actor = Actor.adapt(subdomain_owner); - const svc_acl = services.get('acl'); - if ( ! await svc_acl.check(subdomain_actor, node, 'read') ) { - res.status(502).send('subdomain owner does not have access to directory'); - return; - } - - subdomainRootPath = await node.get('path'); - } - - if ( ! subdomainRootPath ) { - return this.respond_html_error_({ - html: dedent(` - Subdomain or site is not pointing to a directory. - `), - }, req, res, next); - } - - if ( !subdomainRootPath || subdomainRootPath === '/' ) { - throw APIError.create('forbidden'); - } - - req.__puterSiteRootPath = subdomainRootPath; - - const filepath = subdomainRootPath + decodeURIComponent(resolved_url_path); - - const target_node = await svc_fs.node(new NodePathSelector(filepath)); - await target_node.fetchEntry(); - - if ( ! await target_node.exists() ) { - return await this.respond_404_({ path }, req, res, next, subdomainRootPath); - } - - const target_is_dir = await target_node.get('type') === TYPE_DIRECTORY; - - if ( target_is_dir && !resolved_url_path.endsWith('/') ) { - return res.redirect(`${resolved_url_path }/`); - } - - if ( target_is_dir ) { - return await this.respond_404_({ path }, req, res, next, subdomainRootPath); - } - - const contentType = this.modules.mime.contentType(await target_node.get('name')); - res.set('Content-Type', contentType); - - const acl_config = { - no_acl: true, - actor: null, - }; - - if ( site.protected ) { - const svc_auth = req.services.get('auth'); - - const get_site_actor_from_token = async () => { - const site_token = req.cookies['puter.site.token']; - if ( ! site_token ) return; - - let failed = false; - let site_actor; - try { - site_actor = - await svc_auth.authenticate_from_token(site_token); - } catch (e) { - failed = true; - } - - if ( failed ) return; - - if ( ! site_actor ) return; - - // security measure: if 'puter.site.token' is set - // to a different actor type, someone is likely - // trying to exploit the system. - if ( ! (site_actor.type instanceof SiteActorType) ) { - return; - } - - acl_config.actor = site_actor; - - // Refresh the token if it's been 30 seconds since - // the last request - if ( - (Date.now() - site_actor.type.iat * 1000) - > - 1000 * 30 - ) { - const site_token = svc_auth.get_site_app_token({ - site_uid: site.uuid, - }); - res.cookie('puter.site.token', site_token); - } - - return true; - }; - - const make_site_actor_from_app_token = async () => { - const token = req.query['puter.auth.token']; - - acl_config.no_acl = false; - - if ( ! token ) { - const e = APIError.create('token_missing'); - return this.respond_error_({ req, res, e }); - } - - const app_actor = - await svc_auth.authenticate_from_token(token); - - const user_actor = - app_actor.get_related_actor(UserActorType); - - const svc_permission = req.services.get('permission'); - const perm = await (async () => { - if ( user_actor.type.user.id === site.user_id ) { - return {}; - } - - const reading = await svc_permission.scan(user_actor, `site:uid#${site.uuid}:access`); - const options = PermissionUtil.reading_to_options(reading); - return options.length > 0; - })(); - - if ( ! perm ) { - const e = APIError.create('forbidden'); - this.respond_error_({ req, res, e }); - return false; - } - - const site_actor = await Actor.create(SiteActorType, { site }); - acl_config.actor = site_actor; - - // This subdomain is allowed to keep the site actor token, - // so we send it here as a cookie so other html files can - // also load. - const site_token = svc_auth.get_site_app_token({ - site_uid: site.uuid, - }); - res.cookie('puter.site.token', site_token); - return true; - }; - - let ok = await get_site_actor_from_token(); - if ( ! ok ) { - ok = await make_site_actor_from_app_token(); - } - if ( ! ok ) return; - - Object.freeze(acl_config); - } - - // Helper function to parse Range header - const parseRangeHeader = (rangeHeader) => { - // Check if this is a multipart range request - if ( rangeHeader.includes(',') ) { - // For now, we'll only serve the first range in multipart requests - // as the underlying storage layer doesn't support multipart responses - const firstRange = rangeHeader.split(',')[0].trim(); - const matches = firstRange.match(/bytes=(\d+)-(\d*)/); - if ( ! matches ) return null; - - const start = parseInt(matches[1], 10); - const end = matches[2] ? parseInt(matches[2], 10) : null; - - return { start, end, isMultipart: true }; - } - - // Single range request - const matches = rangeHeader.match(/bytes=(\d+)-(\d*)/); - if ( ! matches ) return null; - - const start = parseInt(matches[1], 10); - const end = matches[2] ? parseInt(matches[2], 10) : null; - - return { start, end, isMultipart: false }; - }; - if ( req.headers['range'] ) { - res.status(206); - - // Parse the Range header and set Content-Range - const rangeInfo = parseRangeHeader(req.headers['range']); - if ( rangeInfo ) { - const { start, end, isMultipart } = rangeInfo; - - // For open-ended ranges, we need to calculate the actual end byte - let actualEnd = end; - let fileSize = null; - - try { - fileSize = await target_node.get('size'); - if ( end === null ) { - actualEnd = fileSize - 1; // File size is 1-based, end byte is 0-based - } - } catch (e) { - // If we can't get file size, we'll let the storage layer handle it - // and not set Content-Range header - actualEnd = null; - fileSize = null; - } - - if ( actualEnd !== null ) { - const totalSize = fileSize !== null ? fileSize : '*'; - const contentRange = `bytes ${start}-${actualEnd}/${totalSize}`; - res.set('Content-Range', contentRange); - } - - // If this was a multipart request, modify the range header to only include the first range - if ( isMultipart ) { - req.headers['range'] = end !== null - ? `bytes=${start}-${end}` - : `bytes=${start}-`; - } - } - } else { - if ( target_node.entry.size ) { - res.set('x-expected-entity-length', target_node.entry.size); - } - } - res.set({ 'Accept-Ranges': 'bytes' }); - - const ll_read = new LLRead(); - // const actor = Actor.adapt(req.user); - const stream = await ll_read.run({ - no_acl: acl_config.no_acl, - actor: acl_config.actor, - fsNode: target_node, - ...(req.headers['range'] ? { range: req.headers['range'] } : { }), - }); - - // Destroy the stream if the client disconnects - req.on('close', () => { - stream.destroy(); - }); - - try { - return stream.pipe(res); - } catch (e) { - const handled = await this.respondSiteError({ - path, - req, - res, - next, - subdomainRootPath, - }); - if ( handled ) return; - return res.status(500).send(`Error reading file: ${ e.message}`); - } - } - - async respondSiteError ({ path, html, req, res, next, subdomainRootPath }) { - const handled = await this.maybeRespondWithSiteConfig({ - path, - html, - req, - res, - next, - subdomainRootPath, - errorStatus: 500, - }); - return handled; - } - - async getSiteErrorConfig (req, subdomainRootPath) { - if ( ! subdomainRootPath ) return null; - req.__puterSiteErrorConfigCache ??= Object.create(null); - - if ( req.__puterSiteErrorConfigCache[subdomainRootPath] !== undefined ) { - return req.__puterSiteErrorConfigCache[subdomainRootPath]; - } - - try { - const context = Context.get(); - const services = context.get('services'); - const svc_fs = services.get('filesystem'); - - const configPath = `${subdomainRootPath}/${puterSiteConfigFilename}`; - const configNode = await svc_fs.node(new NodePathSelector(configPath)); - await configNode.fetchEntry(); - - if ( ! await configNode.exists() ) { - req.__puterSiteErrorConfigCache[subdomainRootPath] = null; - return null; - } - if ( await configNode.get('type') === TYPE_DIRECTORY ) { - req.__puterSiteErrorConfigCache[subdomainRootPath] = null; - return null; - } - - const size = Number(await configNode.get('size') ?? 0); - if ( Number.isFinite(size) && size > puterSiteConfigMaxSize ) { - req.__puterSiteErrorConfigCache[subdomainRootPath] = null; - return null; - } - - const ll_read = new LLRead(); - const stream = await ll_read.run({ - no_acl: true, - actor: null, - fsNode: configNode, - }); - const buffer = await streamToBuffer(stream); - const text = buffer.toString('utf8'); - const parsed = parseSiteErrorConfig(text); - - req.__puterSiteErrorConfigCache[subdomainRootPath] = parsed; - return parsed; - } catch { - req.__puterSiteErrorConfigCache[subdomainRootPath] = null; - return null; - } - } - - async getSiteFileNode (subdomainRootPath, sitePath) { - const context = Context.get(); - const services = context.get('services'); - const svc_fs = services.get('filesystem'); - - const fullPath = `${subdomainRootPath}${sitePath}`; - const node = await svc_fs.node(new NodePathSelector(fullPath)); - await node.fetchEntry(); - if ( ! await node.exists() ) return null; - if ( await node.get('type') === TYPE_DIRECTORY ) return null; - return node; - } - - async maybeRespondWithSiteConfig ({ - path, - html, - req, - res, - next, - subdomainRootPath, - errorStatus, - }) { - if ( ! subdomainRootPath ) return false; - - const parsedConfig = await this.getSiteErrorConfig(req, subdomainRootPath); - if ( ! parsedConfig ) return false; - - const rule = getSiteErrorRule(parsedConfig, errorStatus); - if ( ! rule ) return false; - - const responseStatus = rule.status ?? errorStatus; - if ( rule.file ) { - const node = await this.getSiteFileNode(subdomainRootPath, rule.file); - if ( node ) { - await this.streamSiteFile({ - req, - res, - fsNode: node, - status: responseStatus, - }); - return true; - } - } - - if ( rule.status !== null && rule.status !== undefined ) { - this.respond_html_error_({ path, html, status: responseStatus }, req, res, next); - return true; - } - - return false; - } - - async streamSiteFile ({ req, res, fsNode, status }) { - res.status(status); - const contentType = - this.modules.mime.contentType(await fsNode.get('name')) || - 'application/octet-stream'; - res.set('Content-Type', contentType); - - const ll_read = new LLRead(); - const stream = await ll_read.run({ - no_acl: true, - actor: null, - fsNode, - }); - - req.on('close', () => { - stream.destroy(); - }); - - return stream.pipe(res); - } - - async respond_404_ ({ path, html }, req, res, next, subdomainRootPath) { - const handled = await this.maybeRespondWithSiteConfig({ - path, - html, - req, - res, - next, - subdomainRootPath, - errorStatus: 404, - }); - if ( handled ) return; - - if ( subdomainRootPath ) { - const custom404Node = await this.getSiteFileNode(subdomainRootPath, '/404.html'); - if ( custom404Node ) { - return this.streamSiteFile({ - req, - res, - fsNode: custom404Node, - status: 404, - }); - } - } - - return this.respond_html_error_({ path, html, status: 404 }, req, res, next); - } - - respond_html_error_ ({ path, html, status = 404 }, req, res, _next) { - res.status(status); - res.set('Content-Type', 'text/html; charset=UTF-8'); - res.write(`
`); - res.write(`

${status}

`); - res.write('

'); - if ( status === 404 && path ) { - if ( path === '/index.html' ) { - res.write('index.html Not Found'); - } else { - res.write('Not Found'); - } - } else { - res.write(html || 'Request failed'); - } - res.write('

'); - - res.write('
'); - - return res.end(); - } - - respond_error_ ({ req, res, e }) { - if ( ! (e instanceof APIError) ) { - // TODO: alarm here - e = APIError.create('unknown_error'); - } - - res.redirect(`${config.origin}?${e.querystringize({ - ...(req.query['puter.app_instance_id'] ? { - 'error_from_within_iframe': true, - } : {}), - })}`); - } -} - -module.exports = app => { - const mw = new PuterSiteMiddleware(); - mw.install(app); -}; diff --git a/src/backend/src/routers/hosting/puter-site.test.js b/src/backend/src/routers/hosting/puter-site.test.js deleted file mode 100644 index 558797021..000000000 --- a/src/backend/src/routers/hosting/puter-site.test.js +++ /dev/null @@ -1,233 +0,0 @@ -/* - * 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 { beforeEach, describe, expect, it, vi } from 'vitest'; - -// Mocks to test middleware logic with minimal integration complexity -// (I added region markers, so this can be collapsed for readability) - -// #region: mocks -vi.mock('../../config', () => ({ - default: { - static_hosting_domain: 'site.puter.localhost', - static_hosting_base_domain_redirect: 'https://developer.puter.com/static-hosting/', - username_regex: /^[a-z0-9_]+$/, - }, - static_hosting_domain: 'site.puter.localhost', - static_hosting_base_domain_redirect: 'https://developer.puter.com/static-hosting/', - username_regex: /^[a-z0-9_]+$/, -})); - -vi.mock('../../modules/web/lib/api_error_handler', () => ({ - default: vi.fn(), -})); - -vi.mock('../../helpers', () => ({ - get_user: vi.fn(), - get_app: vi.fn(), -})); - -// Mock Context to allow arun passthrough -const mockContextInstance = { - get: vi.fn(), - arun: vi.fn().mockImplementation(async (fn) => await fn()), -}; - -vi.mock('../../util/context', () => ({ - Context: { - get: vi.fn().mockReturnValue(mockContextInstance), - }, -})); - -vi.mock('../../filesystem/node/selectors', () => ({ - NodeInternalIDSelector: class { - }, - NodePathSelector: class { - }, -})); - -vi.mock('../../filesystem/FSNodeContext', () => ({ - TYPE_DIRECTORY: 'directory', -})); - -vi.mock('../../filesystem/ll_operations/ll_read', () => ({ - LLRead: class { - }, -})); - -vi.mock('../../services/auth/Actor', () => ({ - Actor: { adapt: vi.fn(), create: vi.fn() }, - UserActorType: class { - }, - SiteActorType: class { - }, -})); - -vi.mock('../../api/APIError', () => ({ - default: class APIError { - static create () { - return new this(); - } - }, -})); - -vi.mock('../../services/auth/permissionUtils.mjs', () => ({ - PermissionUtil: { - reading_to_options: vi.fn().mockReturnValue([]), - }, -})); - -vi.mock('dedent', () => ({ - default: (str) => str, -})); -// #endregion - -// Now import the module under test - this will use our mocks -const puterSiteModule = require('./puter-site'); -const config = require('../../config'); - -describe('PuterSiteMiddleware', () => { - describe('base domain redirect', () => { - let capturedMiddleware; - let mockApp; - - beforeEach(() => { - vi.clearAllMocks(); - - // Capture the middleware when it's installed - mockApp = { - use: vi.fn().mockImplementation((mw) => { - capturedMiddleware = mw; - }), - }; - - // Install the middleware - puterSiteModule(mockApp); - }); - - /** - * Creates a mock request for static hosting domain - */ - const createMockRequest = (subdomain) => { - const hostname = subdomain - ? `${subdomain}.${config.static_hosting_domain}` - : config.static_hosting_domain; - - return { - hostname, - subdomains: subdomain ? [subdomain] : [], - is_custom_domain: false, - baseUrl: '', - path: '/', - ctx: mockContextInstance, - }; - }; - - it('should redirect to info page when subdomain is empty (bare domain)', async () => { - const mockReq = createMockRequest(''); - const mockRes = { - redirect: vi.fn(), - setHeader: vi.fn(), - }; - const mockNext = vi.fn(); - - await capturedMiddleware(mockReq, mockRes, mockNext); - - expect(mockRes.redirect).toHaveBeenCalledWith('https://developer.puter.com/static-hosting/'); - expect(mockNext).not.toHaveBeenCalled(); - }); - - it('should redirect to info page when subdomain is www', async () => { - const mockReq = createMockRequest('www'); - const mockRes = { - redirect: vi.fn(), - setHeader: vi.fn(), - }; - const mockNext = vi.fn(); - - await capturedMiddleware(mockReq, mockRes, mockNext); - - expect(mockRes.redirect).toHaveBeenCalledWith('https://developer.puter.com/static-hosting/'); - expect(mockNext).not.toHaveBeenCalled(); - }); - - it('should NOT redirect when subdomain is a valid site name', async () => { - // Setup mock services for the "site not found" path - const mockServices = { - get: vi.fn().mockImplementation((svc) => { - if ( svc === 'puter-site' ) { - return { - get_subdomain: vi.fn().mockResolvedValue(null), - }; - } - if ( svc === 'filesystem' ) { - return { - node: vi.fn().mockResolvedValue({ - exists: vi.fn().mockResolvedValue(false), - }), - }; - } - return {}; - }), - }; - - mockContextInstance.get.mockImplementation((key) => { - if ( key === 'services' ) return mockServices; - return null; - }); - - const mockReq = createMockRequest('mysite'); - const mockRes = { - redirect: vi.fn(), - setHeader: vi.fn(), - status: vi.fn().mockReturnThis(), - send: vi.fn(), - }; - const mockNext = vi.fn(); - - // The middleware will error out further down (due to incomplete mocks) - // but the important thing is: did it try to redirect to the info page? - try { - await capturedMiddleware(mockReq, mockRes, mockNext); - } catch (e) { - // Expected - incomplete mocks cause errors after the redirect check - } - - // The key assertion: it should NOT have redirected to the info page - // because 'mysite' is a valid subdomain, not '' or 'www' - expect(mockRes.redirect).not.toHaveBeenCalledWith('https://developer.puter.com/static-hosting/'); - }); - - it('should use exactly the URL from config (not hardcoded)', async () => { - // This test verifies the middleware reads from config.static_hosting_base_domain_redirect - // If someone hardcodes a different URL, this assertion will catch that the - // redirect URL matches what is in the mocked config. - const mockReq = createMockRequest(''); - const mockRes = { - redirect: vi.fn(), - setHeader: vi.fn(), - }; - const mockNext = vi.fn(); - - await capturedMiddleware(mockReq, mockRes, mockNext); - - // Verify it uses the exact URL from the mocked config - expect(mockRes.redirect).toHaveBeenCalledWith(config.static_hosting_base_domain_redirect); - }); - }); -}); diff --git a/src/backend/src/routers/hosting/puterSiteMiddleware.js b/src/backend/src/routers/hosting/puterSiteMiddleware.js new file mode 100644 index 000000000..f9d3a43ef --- /dev/null +++ b/src/backend/src/routers/hosting/puterSiteMiddleware.js @@ -0,0 +1,887 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import dedent from 'dedent'; +import { contentType as contentTypeFromMime } from 'mime-types'; +import { resolve } from 'path'; +import { v5 as uuidv5 } from 'uuid'; +import APIError from '../../api/APIError.js'; +import config from '../../config.js'; +import fsNodeContext from '../../filesystem/FSNodeContext.js'; +import llReadModule from '../../filesystem/ll_operations/ll_read.js'; +import selectors from '../../filesystem/node/selectors.js'; +import { get_app, get_user } from '../../helpers.js'; +import api_error_handler from '../../modules/web/lib/api_error_handler.js'; +import { Actor, SiteActorType, UserActorType } from '../../services/auth/Actor.js'; +import { PermissionUtil } from '../../services/auth/permissionUtils.mjs'; +import { Context } from '../../util/context.js'; +import { stream_to_buffer as streamToBuffer } from '../../util/streamutil.js'; +import { + getSiteErrorRule, + parseSiteErrorConfig, +} from './puter-site-config.js'; + +const { + origin: originUrl, + cookie_name: cookieName, + private_app_hosting_domain: privateAppHostingDomain, + static_hosting_base_domain_redirect: staticHostingBaseDomainRedirect, + static_hosting_domain: staticHostingDomain, + static_hosting_domain_alt: staticHostingDomainAlt, + username_regex: usernameRegex, +} = config; +const { TYPE_DIRECTORY } = fsNodeContext; +const { LLRead } = llReadModule; +const { + NodeInternalIDSelector, + NodePathSelector, +} = selectors; + +const AT_DIRECTORY_NAMESPACE = '4aa6dc52-34c1-4b8a-b63c-a62b27f727cf'; +const puterSiteConfigFilename = '.puter_site_config'; +const puterSiteConfigMaxSize = 256 * 1024; + +function isPrivateApp (app) { + return Number(app?.is_private ?? 0) > 0; +} + +function hostMatchesPrivateDomain (hostname) { + const privateHostingDomain = `${privateAppHostingDomain ?? 'puter.app'}` + .trim() + .toLowerCase() + .replace(/^\./, ''); + if ( ! privateHostingDomain ) return false; + + const host = `${hostname ?? ''}`.trim().toLowerCase(); + if ( ! host ) return false; + + return host === privateHostingDomain || host.endsWith(`.${privateHostingDomain}`); +} + +function buildPrivateHostRedirectUrl (req, app) { + if ( !app?.index_url || typeof app.index_url !== 'string' ) { + return null; + } + + try { + const redirectUrl = new URL(req.originalUrl || '/', app.index_url); + return redirectUrl.toString(); + } catch { + return null; + } +} + +function getPrivateDeniedRedirectUrl (app, denyRedirectUrl) { + if ( typeof denyRedirectUrl === 'string' && denyRedirectUrl.trim() ) { + return denyRedirectUrl.trim(); + } + + const origin = `${originUrl ?? ''}`.trim().replace(/\/$/, ''); + if ( origin ) { + return `${origin}/app/app-center/?item=${encodeURIComponent(app?.uid ?? '')}`; + } + + return '/'; +} + +function getTokenFromAuthorizationHeader (req) { + const authorizationHeader = req.headers?.authorization; + if ( typeof authorizationHeader !== 'string' ) return null; + const match = authorizationHeader.match(/^Bearer\s+(.+)$/i); + return match?.[1]?.trim() || null; +} + +function getBootstrapTokenFromReferrer (req) { + const referrerHeader = req.headers?.referer ?? req.headers?.referrer; + if ( typeof referrerHeader !== 'string' || !referrerHeader.trim() ) { + return null; + } + + try { + const referrerUrl = new URL(referrerHeader); + return referrerUrl.searchParams.get('puter.auth.token') + || referrerUrl.searchParams.get('auth_token'); + } catch { + return null; + } +} + +function getBootstrapPrivateToken (req) { + const authorizationToken = getTokenFromAuthorizationHeader(req); + if ( authorizationToken ) return authorizationToken; + + const queryToken = req.query?.['puter.auth.token']; + if ( typeof queryToken === 'string' && queryToken.trim() ) { + return queryToken.trim(); + } + + const headerToken = req.headers?.['x-puter-auth-token']; + if ( typeof headerToken === 'string' && headerToken.trim() ) { + return headerToken.trim(); + } + + return getBootstrapTokenFromReferrer(req); +} + +function actorToPrivateIdentity (actor) { + if ( ! actor ) return null; + + let userActor = null; + if ( actor.type instanceof UserActorType ) { + userActor = actor; + } else { + try { + userActor = actor.get_related_actor(UserActorType); + } catch { + userActor = null; + } + } + + const userUid = userActor?.type?.user?.uuid; + if ( typeof userUid !== 'string' || !userUid ) { + return null; + } + + const sessionCandidate = actor.type?.session ?? userActor.type?.session; + const sessionUuid = typeof sessionCandidate === 'string' + ? sessionCandidate + : sessionCandidate?.uuid; + + return { + userUid, + sessionUuid: typeof sessionUuid === 'string' && sessionUuid ? sessionUuid : undefined, + }; +} + +async function resolvePrivateIdentity ({ req, services, appUid }) { + const authService = services.get('auth'); + const privateCookieName = authService.getPrivateAssetCookieName(); + const privateCookieToken = req.cookies?.[privateCookieName]; + + if ( typeof privateCookieToken === 'string' && privateCookieToken ) { + try { + const claims = authService.verifyPrivateAssetToken(privateCookieToken, { + expectedAppUid: appUid, + }); + return { + source: 'private-cookie', + userUid: claims.userUid, + sessionUuid: claims.sessionUuid, + hasValidPrivateCookie: true, + }; + } catch { + // fallback to next token source + } + } + + const sessionToken = req.cookies?.[cookieName]; + if ( typeof sessionToken === 'string' && sessionToken ) { + try { + const actor = await authService.authenticate_from_token(sessionToken); + const identity = actorToPrivateIdentity(actor); + if ( identity ) { + return { + source: 'session-cookie', + ...identity, + hasValidPrivateCookie: false, + }; + } + } catch { + // fallback to next token source + } + } + + const bootstrapToken = getBootstrapPrivateToken(req); + if ( typeof bootstrapToken === 'string' && bootstrapToken ) { + try { + const actor = await authService.authenticate_from_token(bootstrapToken); + const identity = actorToPrivateIdentity(actor); + if ( identity ) { + return { + source: 'bootstrap-token', + ...identity, + hasValidPrivateCookie: false, + }; + } + } catch { + // no valid identity from bootstrap token + } + } + + return { + source: 'none', + userUid: undefined, + sessionUuid: undefined, + hasValidPrivateCookie: false, + }; +} + +async function evaluatePrivateAppAccess ({ req, res, services, app, requestPath }) { + const eventService = services.get('event'); + const identity = await resolvePrivateIdentity({ + req, + services, + appUid: app.uid, + }); + + const accessCheckEvent = { + appUid: app.uid, + userUid: identity.userUid ?? null, + requestHost: req.hostname, + requestPath, + result: { + allowed: false, + }, + }; + + try { + await eventService.emit('app.privateAccess.check', accessCheckEvent); + } catch (e) { + console.error('private app access check failed', e); + } + + if ( ! accessCheckEvent.result.allowed ) { + const redirectUrl = getPrivateDeniedRedirectUrl( + app, + accessCheckEvent.result.redirectUrl, + ); + res.redirect(redirectUrl); + return false; + } + + if ( identity.userUid && !identity.hasValidPrivateCookie ) { + const authService = services.get('auth'); + const privateToken = authService.createPrivateAssetToken({ + appUid: app.uid, + userUid: identity.userUid, + sessionUuid: identity.sessionUuid, + }); + res.cookie( + authService.getPrivateAssetCookieName(), + privateToken, + authService.getPrivateAssetCookieOptions(), + ); + } + + return true; +} + +async function runInternal (req, res, next) { + const subdomain = + req.is_custom_domain ? req.hostname : + req.subdomains[0] === 'devtest' ? 'devtest' : + req.hostname.split('.')[0]; + + let path = (req.baseUrl + req.path) || 'index.html'; + + const context = Context.get(); + const services = context.get('services'); + + const getUsernameSite = (async () => { + if ( ! subdomain.endsWith('.at') ) return; + const parts = subdomain.split('.'); + if ( parts.length !== 2 ) return; + const username = parts[0]; + if ( ! username.match(usernameRegex) ) { + return; + } + const filesystemService = services.get('filesystem'); + const indexNode = await filesystemService.node(new NodePathSelector(`/${username}/Public/index.html`)); + const node = await filesystemService.node(new NodePathSelector(`/${username}/Public`)); + if ( ! await indexNode.exists() ) return; + + return { + name: `${username }.at`, + uuid: uuidv5(username, AT_DIRECTORY_NAMESPACE), + root_dir_id: await node.get('mysql-id'), + }; + }); + + if ( req.hostname === staticHostingDomain || req.hostname === staticHostingDomainAlt || subdomain === 'www' ) { + + // redirect to information page about static hosting + return res.redirect(staticHostingBaseDomainRedirect); + } + + const site = + await getUsernameSite() || + await (async () => { + const puterSiteService = services.get('puter-site'); + const site = await puterSiteService.get_subdomain(subdomain, { + is_custom_domain: req.is_custom_domain, + }); + return site; + })(); + + if ( site === null ) { + return res.status(404).send('Subdomain not found'); + } + + const subdomainOwner = await get_user({ id: site.user_id }); + if ( subdomainOwner?.suspended ) { + // This used to be "401 Account suspended", but this implies + // the client user is suspended, which is not the case. + // Instead we simply return 404, indicating that this page + // doesn't exist without further specifying that the owner's + // account is suspended. (the client user doesn't need to know) + return res.status(404).send('Subdomain not found'); + } + + const associatedApp = site.associated_app_id + ? await get_app({ id: site.associated_app_id }) + : null; + const privateAppEnabled = isPrivateApp(associatedApp); + + if ( privateAppEnabled && !hostMatchesPrivateDomain(req.hostname) ) { + const privateHostRedirect = buildPrivateHostRedirectUrl(req, associatedApp); + if ( privateHostRedirect ) { + return res.redirect(privateHostRedirect); + } + return res.status(403).send('Private app host mismatch'); + } + + if ( + site.associated_app_id && + !req.query['puter.app_instance_id'] && + ( path === '' || path.endsWith('/') ) + ) { + const app = associatedApp || await get_app({ id: site.associated_app_id }); + return res.redirect(`${originUrl}/app/${app.name}/`); + } + + if ( path === '' ) path += '/index.html'; + else if ( path.endsWith('/') ) path += 'index.html'; + + const resolvedUrlPath = + resolve('/', path); + + const filesystemService = services.get('filesystem'); + + let subdomainRootPath = ''; + if ( site.root_dir_id !== null && site.root_dir_id !== undefined ) { + const node = await filesystemService.node(new NodeInternalIDSelector('mysql', site.root_dir_id)); + if ( ! await node.exists() ) { + return res.status(502).send('subdomain is pointing to deleted directory'); + } + if ( await node.get('type') !== TYPE_DIRECTORY ) { + return res.status(502).send('subdomain is pointing to non-directory'); + } + + // Verify subdomain owner permission + const subdomainActor = Actor.adapt(subdomainOwner); + const aclService = services.get('acl'); + if ( ! await aclService.check(subdomainActor, node, 'read') ) { + res.status(502).send('subdomain owner does not have access to directory'); + return; + } + + subdomainRootPath = await node.get('path'); + } + + if ( ! subdomainRootPath ) { + return respondHtmlError({ + html: dedent(` + Subdomain or site is not pointing to a directory. + `), + }, req, res, next); + } + + if ( !subdomainRootPath || subdomainRootPath === '/' ) { + throw APIError.create('forbidden'); + } + + req.__puterSiteRootPath = subdomainRootPath; + + if ( privateAppEnabled ) { + const accessAllowed = await evaluatePrivateAppAccess({ + req, + res, + services, + app: associatedApp, + requestPath: req.path, + }); + if ( ! accessAllowed ) return; + } + + const filepath = subdomainRootPath + decodeURIComponent(resolvedUrlPath); + + const targetNode = await filesystemService.node(new NodePathSelector(filepath)); + await targetNode.fetchEntry(); + + if ( ! await targetNode.exists() ) { + return await respond404({ path }, req, res, next, subdomainRootPath); + } + + const targetIsDir = await targetNode.get('type') === TYPE_DIRECTORY; + + if ( targetIsDir && !resolvedUrlPath.endsWith('/') ) { + return res.redirect(`${resolvedUrlPath }/`); + } + + if ( targetIsDir ) { + return await respond404({ path }, req, res, next, subdomainRootPath); + } + + const contentType = contentTypeFromMime(await targetNode.get('name')); + res.set('Content-Type', contentType); + + const aclConfig = { + no_acl: true, + actor: null, + }; + + if ( site.protected ) { + const authService = req.services.get('auth'); + + const getSiteActorFromToken = async () => { + const siteToken = req.cookies['puter.site.token']; + if ( ! siteToken ) return; + + let failed = false; + let siteActor; + try { + siteActor = + await authService.authenticate_from_token(siteToken); + } catch (e) { + failed = true; + } + + if ( failed ) return; + + if ( ! siteActor ) return; + + // security measure: if 'puter.site.token' is set + // to a different actor type, someone is likely + // trying to exploit the system. + if ( ! (siteActor.type instanceof SiteActorType) ) { + return; + } + + aclConfig.actor = siteActor; + + // Refresh the token if it's been 30 seconds since + // the last request + if ( + (Date.now() - siteActor.type.iat * 1000) + > + 1000 * 30 + ) { + const siteToken = authService.get_site_app_token({ + site_uid: site.uuid, + }); + res.cookie('puter.site.token', siteToken); + } + + return true; + }; + + const makeSiteActorFromAppToken = async () => { + const token = req.query['puter.auth.token']; + + aclConfig.no_acl = false; + + if ( ! token ) { + const e = APIError.create('token_missing'); + return respondError({ req, res, e }); + } + + const appActor = + await authService.authenticate_from_token(token); + + const userActor = + appActor.get_related_actor(UserActorType); + + const permissionService = req.services.get('permission'); + const perm = await (async () => { + if ( userActor.type.user.id === site.user_id ) { + return {}; + } + + const reading = await permissionService.scan(userActor, `site:uid#${site.uuid}:access`); + const options = PermissionUtil.reading_to_options(reading); + return options.length > 0; + })(); + + if ( ! perm ) { + const e = APIError.create('forbidden'); + respondError({ req, res, e }); + return false; + } + + const siteActor = await Actor.create(SiteActorType, { site }); + aclConfig.actor = siteActor; + + // This subdomain is allowed to keep the site actor token, + // so we send it here as a cookie so other html files can + // also load. + const siteToken = authService.get_site_app_token({ + site_uid: site.uuid, + }); + res.cookie('puter.site.token', siteToken); + return true; + }; + + let ok = await getSiteActorFromToken(); + if ( ! ok ) { + ok = await makeSiteActorFromAppToken(); + } + if ( ! ok ) return; + + Object.freeze(aclConfig); + } + + // Helper function to parse Range header + const parseRangeHeader = (rangeHeader) => { + // Check if this is a multipart range request + if ( rangeHeader.includes(',') ) { + // For now, we'll only serve the first range in multipart requests + // as the underlying storage layer doesn't support multipart responses + const firstRange = rangeHeader.split(',')[0].trim(); + const matches = firstRange.match(/bytes=(\d+)-(\d*)/); + if ( ! matches ) return null; + + const start = parseInt(matches[1], 10); + const end = matches[2] ? parseInt(matches[2], 10) : null; + + return { start, end, isMultipart: true }; + } + + // Single range request + const matches = rangeHeader.match(/bytes=(\d+)-(\d*)/); + if ( ! matches ) return null; + + const start = parseInt(matches[1], 10); + const end = matches[2] ? parseInt(matches[2], 10) : null; + + return { start, end, isMultipart: false }; + }; + if ( req.headers['range'] ) { + res.status(206); + + // Parse the Range header and set Content-Range + const rangeInfo = parseRangeHeader(req.headers['range']); + if ( rangeInfo ) { + const { start, end, isMultipart } = rangeInfo; + + // For open-ended ranges, we need to calculate the actual end byte + let actualEnd = end; + let fileSize = null; + + try { + fileSize = await targetNode.get('size'); + if ( end === null ) { + actualEnd = fileSize - 1; // File size is 1-based, end byte is 0-based + } + } catch (e) { + // If we can't get file size, we'll let the storage layer handle it + // and not set Content-Range header + actualEnd = null; + fileSize = null; + } + + if ( actualEnd !== null ) { + const totalSize = fileSize !== null ? fileSize : '*'; + const contentRange = `bytes ${start}-${actualEnd}/${totalSize}`; + res.set('Content-Range', contentRange); + } + + // If this was a multipart request, modify the range header to only include the first range + if ( isMultipart ) { + req.headers['range'] = end !== null + ? `bytes=${start}-${end}` + : `bytes=${start}-`; + } + } + } else { + if ( targetNode.entry.size ) { + res.set('x-expected-entity-length', targetNode.entry.size); + } + } + res.set({ 'Accept-Ranges': 'bytes' }); + + const llRead = new LLRead(); + // const actor = Actor.adapt(req.user); + const stream = await llRead.run({ + no_acl: aclConfig.no_acl, + actor: aclConfig.actor, + fsNode: targetNode, + ...(req.headers['range'] ? { range: req.headers['range'] } : { }), + }); + + // Destroy the stream if the client disconnects + req.on('close', () => { + stream.destroy(); + }); + + try { + return stream.pipe(res); + } catch (e) { + const handled = await respondSiteError({ + path, + req, + res, + next, + subdomainRootPath, + }); + if ( handled ) return; + return res.status(500).send(`Error reading file: ${ e.message}`); + } +} + +async function respondSiteError ({ path, html, req, res, next, subdomainRootPath }) { + const handled = await maybeRespondWithSiteConfig({ + path, + html, + req, + res, + next, + subdomainRootPath, + errorStatus: 500, + }); + return handled; +} + +async function getSiteErrorConfig (req, subdomainRootPath) { + if ( ! subdomainRootPath ) return null; + req.__puterSiteErrorConfigCache ??= Object.create(null); + + if ( req.__puterSiteErrorConfigCache[subdomainRootPath] !== undefined ) { + return req.__puterSiteErrorConfigCache[subdomainRootPath]; + } + + try { + const context = Context.get(); + const services = context.get('services'); + const filesystemService = services.get('filesystem'); + + const configPath = `${subdomainRootPath}/${puterSiteConfigFilename}`; + const configNode = await filesystemService.node(new NodePathSelector(configPath)); + await configNode.fetchEntry(); + + if ( ! await configNode.exists() ) { + req.__puterSiteErrorConfigCache[subdomainRootPath] = null; + return null; + } + if ( await configNode.get('type') === TYPE_DIRECTORY ) { + req.__puterSiteErrorConfigCache[subdomainRootPath] = null; + return null; + } + + const size = Number(await configNode.get('size') ?? 0); + if ( Number.isFinite(size) && size > puterSiteConfigMaxSize ) { + req.__puterSiteErrorConfigCache[subdomainRootPath] = null; + return null; + } + + const llRead = new LLRead(); + const stream = await llRead.run({ + no_acl: true, + actor: null, + fsNode: configNode, + }); + const buffer = await streamToBuffer(stream); + const text = buffer.toString('utf8'); + const parsed = parseSiteErrorConfig(text); + + req.__puterSiteErrorConfigCache[subdomainRootPath] = parsed; + return parsed; + } catch { + req.__puterSiteErrorConfigCache[subdomainRootPath] = null; + return null; + } +} + +async function getSiteFileNode (subdomainRootPath, sitePath) { + const context = Context.get(); + const services = context.get('services'); + const filesystemService = services.get('filesystem'); + + const fullPath = `${subdomainRootPath}${sitePath}`; + const node = await filesystemService.node(new NodePathSelector(fullPath)); + await node.fetchEntry(); + if ( ! await node.exists() ) return null; + if ( await node.get('type') === TYPE_DIRECTORY ) return null; + return node; +} + +async function maybeRespondWithSiteConfig ({ + path, + html, + req, + res, + next, + subdomainRootPath, + errorStatus, +}) { + if ( ! subdomainRootPath ) return false; + + const parsedConfig = await getSiteErrorConfig(req, subdomainRootPath); + if ( ! parsedConfig ) return false; + + const rule = getSiteErrorRule(parsedConfig, errorStatus); + if ( ! rule ) return false; + + const responseStatus = rule.status ?? errorStatus; + if ( rule.file ) { + const node = await getSiteFileNode(subdomainRootPath, rule.file); + if ( node ) { + await streamSiteFile({ + req, + res, + fsNode: node, + status: responseStatus, + }); + return true; + } + } + + if ( rule.status !== null && rule.status !== undefined ) { + respondHtmlError({ path, html, status: responseStatus }, req, res, next); + return true; + } + + return false; +} + +async function streamSiteFile ({ req, res, fsNode, status }) { + res.status(status); + const contentType = + contentTypeFromMime(await fsNode.get('name')) || + 'application/octet-stream'; + res.set('Content-Type', contentType); + + const llRead = new LLRead(); + const stream = await llRead.run({ + no_acl: true, + actor: null, + fsNode, + }); + + req.on('close', () => { + stream.destroy(); + }); + + return stream.pipe(res); +} + +async function respond404 ({ path, html }, req, res, next, subdomainRootPath) { + const handled = await maybeRespondWithSiteConfig({ + path, + html, + req, + res, + next, + subdomainRootPath, + errorStatus: 404, + }); + if ( handled ) return; + + if ( subdomainRootPath ) { + const custom404Node = await getSiteFileNode(subdomainRootPath, '/404.html'); + if ( custom404Node ) { + return streamSiteFile({ + req, + res, + fsNode: custom404Node, + status: 404, + }); + } + } + + return respondHtmlError({ path, html, status: 404 }, req, res, next); +} + +function respondHtmlError ({ path, html, status = 404 }, req, res, _next) { + res.status(status); + res.set('Content-Type', 'text/html; charset=UTF-8'); + res.write(`
`); + res.write(`

${status}

`); + res.write('

'); + if ( status === 404 && path ) { + if ( path === '/index.html' ) { + res.write('index.html Not Found'); + } else { + res.write('Not Found'); + } + } else { + res.write(html || 'Request failed'); + } + res.write('

'); + + res.write('
'); + + return res.end(); +} + +function respondError ({ req, res, e }) { + if ( ! (e instanceof APIError) ) { + // TODO: alarm here + e = APIError.create('unknown_error'); + } + + res.redirect(`${originUrl}?${e.querystringize({ + ...(req.query['puter.app_instance_id'] ? { + 'error_from_within_iframe': true, + } : {}), + })}`); +} + +export async function puterSiteMiddleware (req, res, next) { + const isSubdomain = + req.hostname.endsWith(staticHostingDomain) + || (staticHostingDomainAlt && req.hostname.endsWith(staticHostingDomainAlt)) + || req.subdomains[0] === 'devtest' + ; + + if ( !isSubdomain && !req.is_custom_domain ) return next(); + + res.setHeader('Access-Control-Allow-Origin', '*'); + + try { + const expectedCtx = req.ctx; + const receivedCtx = Context.get(); + + if ( expectedCtx && !receivedCtx ) { + await expectedCtx.arun(async () => { + await runInternal(req, res, next); + }); + } else await runInternal(req, res, next); + } catch ( e ) { + console.error('puter-site middleware error', e); + if ( !res.headersSent && req.__puterSiteRootPath ) { + try { + const handled = await respondSiteError({ + path: req.path, + req, + res, + next, + subdomainRootPath: req.__puterSiteRootPath, + }); + if ( handled ) return; + } catch ( siteError ) { + console.error('failed handling site error response', siteError); + } + } + api_error_handler(e, req, res, next); + } +} diff --git a/src/backend/src/routers/hosting/puterSiteMiddleware.test.js b/src/backend/src/routers/hosting/puterSiteMiddleware.test.js new file mode 100644 index 000000000..ad6cc0035 --- /dev/null +++ b/src/backend/src/routers/hosting/puterSiteMiddleware.test.js @@ -0,0 +1,409 @@ +/* + * 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 { beforeEach, describe, expect, it, vi } from 'vitest'; +import { puterSiteMiddleware } from './puterSiteMiddleware'; +import config from '../../config.js'; +import { Context } from '../../util/context.js'; + +// Mocks to test middleware logic with minimal integration complexity +// (I added region markers, so this can be collapsed for readability) + +// #region: mocks +let getUserMockImpl = async () => null; +let getAppMockImpl = async () => null; + +vi.mock('../../config.js', () => ({ + default: { + static_hosting_domain: 'site.puter.localhost', + static_hosting_base_domain_redirect: 'https://developer.puter.com/static-hosting/', + private_app_hosting_domain: 'puter.app', + origin: 'https://puter.com', + cookie_name: 'puter.session.token', + username_regex: /^[a-z0-9_]+$/, + }, + static_hosting_domain: 'site.puter.localhost', + static_hosting_base_domain_redirect: 'https://developer.puter.com/static-hosting/', + private_app_hosting_domain: 'puter.app', + origin: 'https://puter.com', + cookie_name: 'puter.session.token', + username_regex: /^[a-z0-9_]+$/, +})); + +vi.mock('../../modules/web/lib/api_error_handler.js', () => ({ + default: vi.fn(), +})); + +vi.mock('../../helpers.js', () => ({ + get_user: vi.fn((...args) => getUserMockImpl(...args)), + get_app: vi.fn((...args) => getAppMockImpl(...args)), +})); + +vi.mock('../../util/context.js', () => ({ + Context: { + get: vi.fn(), + }, +})); + +// Mock Context to allow arun passthrough +const mockContextInstance = { + get: vi.fn(), + arun: vi.fn().mockImplementation(async (fn) => await fn()), +}; + +vi.mock('../../filesystem/node/selectors.js', () => ({ + default: { + NodeInternalIDSelector: class { + }, + NodePathSelector: class { + }, + }, + NodeInternalIDSelector: class { + }, + NodePathSelector: class { + }, +})); + +vi.mock('../../filesystem/FSNodeContext.js', () => ({ + default: { + TYPE_DIRECTORY: 'directory', + }, + TYPE_DIRECTORY: 'directory', +})); + +vi.mock('../../filesystem/ll_operations/ll_read.js', () => ({ + default: { + LLRead: class { + }, + }, + LLRead: class { + }, +})); + +vi.mock('../../services/auth/Actor.js', () => ({ + Actor: { adapt: vi.fn(), create: vi.fn() }, + UserActorType: class { + }, + SiteActorType: class { + }, +})); + +vi.mock('../../api/APIError.js', () => ({ + default: class APIError { + static create () { + return new this(); + } + }, +})); + +vi.mock('../../services/auth/permissionUtils.mjs', () => ({ + PermissionUtil: { + reading_to_options: vi.fn().mockReturnValue([]), + }, +})); + +vi.mock('dedent', () => ({ + default: (str) => str, +})); +// #endregion + +// Now import the module under test - this will use our mocks +describe('PuterSiteMiddleware', () => { + describe('base domain redirect', () => { + let capturedMiddleware; + + beforeEach(() => { + vi.clearAllMocks(); + Context.get = vi.fn().mockReturnValue(mockContextInstance); + getUserMockImpl = async () => null; + getAppMockImpl = async () => null; + capturedMiddleware = puterSiteMiddleware; + }); + + /** + * Creates a mock request for static hosting domain + */ + const createMockRequest = (subdomain) => { + const hostname = subdomain + ? `${subdomain}.${config.static_hosting_domain}` + : config.static_hosting_domain; + + return { + hostname, + subdomains: subdomain ? [subdomain] : [], + is_custom_domain: false, + baseUrl: '', + path: '/', + ctx: mockContextInstance, + }; + }; + + it('should redirect to info page when subdomain is empty (bare domain)', async () => { + const mockReq = createMockRequest(''); + const mockRes = { + redirect: vi.fn(), + setHeader: vi.fn(), + }; + const mockNext = vi.fn(); + + await capturedMiddleware(mockReq, mockRes, mockNext); + + expect(mockRes.redirect).toHaveBeenCalledWith('https://developer.puter.com/static-hosting/'); + expect(mockNext).not.toHaveBeenCalled(); + }); + + it('should redirect to info page when subdomain is www', async () => { + const mockReq = createMockRequest('www'); + const mockRes = { + redirect: vi.fn(), + setHeader: vi.fn(), + }; + const mockNext = vi.fn(); + + await capturedMiddleware(mockReq, mockRes, mockNext); + + expect(mockRes.redirect).toHaveBeenCalledWith('https://developer.puter.com/static-hosting/'); + expect(mockNext).not.toHaveBeenCalled(); + }); + + it('should NOT redirect when subdomain is a valid site name', async () => { + // Setup mock services for the "site not found" path + const mockServices = { + get: vi.fn().mockImplementation((svc) => { + if ( svc === 'puter-site' ) { + return { + get_subdomain: vi.fn().mockResolvedValue(null), + }; + } + if ( svc === 'filesystem' ) { + return { + node: vi.fn().mockResolvedValue({ + exists: vi.fn().mockResolvedValue(false), + }), + }; + } + return {}; + }), + }; + + mockContextInstance.get.mockImplementation((key) => { + if ( key === 'services' ) return mockServices; + return null; + }); + + const mockReq = createMockRequest('mysite'); + const mockRes = { + redirect: vi.fn(), + setHeader: vi.fn(), + status: vi.fn().mockReturnThis(), + send: vi.fn(), + }; + const mockNext = vi.fn(); + + // The middleware will error out further down (due to incomplete mocks) + // but the important thing is: did it try to redirect to the info page? + try { + await capturedMiddleware(mockReq, mockRes, mockNext); + } catch (e) { + // Expected - incomplete mocks cause errors after the redirect check + } + + // The key assertion: it should NOT have redirected to the info page + // because 'mysite' is a valid subdomain, not '' or 'www' + expect(mockRes.redirect).not.toHaveBeenCalledWith('https://developer.puter.com/static-hosting/'); + }); + + it('should use exactly the URL from config (not hardcoded)', async () => { + // This test verifies the middleware reads from config.static_hosting_base_domain_redirect + // If someone hardcodes a different URL, this assertion will catch that the + // redirect URL matches what is in the mocked config. + const mockReq = createMockRequest(''); + const mockRes = { + redirect: vi.fn(), + setHeader: vi.fn(), + }; + const mockNext = vi.fn(); + + await capturedMiddleware(mockReq, mockRes, mockNext); + + // Verify it uses the exact URL from the mocked config + expect(mockRes.redirect).toHaveBeenCalledWith(config.static_hosting_base_domain_redirect); + }); + }); + + describe('private app access gate', () => { + let capturedMiddleware; + + beforeEach(() => { + vi.clearAllMocks(); + Context.get = vi.fn().mockReturnValue(mockContextInstance); + getUserMockImpl = async () => null; + getAppMockImpl = async () => null; + capturedMiddleware = puterSiteMiddleware; + }); + + it('redirects private app assets to puter.app host', async () => { + 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: null, + }), + }; + } + 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.app/', + }); + + const mockReq = { + hostname: 'paid.site.puter.localhost', + subdomains: ['paid'], + is_custom_domain: false, + baseUrl: '', + path: '/asset.js', + originalUrl: '/asset.js?foo=1', + query: {}, + cookies: {}, + headers: {}, + ctx: mockContextInstance, + }; + const mockRes = { + redirect: vi.fn(), + setHeader: vi.fn(), + status: vi.fn().mockReturnThis(), + send: vi.fn(), + }; + const mockNext = vi.fn(); + + await capturedMiddleware(mockReq, mockRes, mockNext); + + expect(mockRes.redirect).toHaveBeenCalledWith('https://paid.puter.app/asset.js?foo=1'); + expect(mockNext).not.toHaveBeenCalled(); + }); + + it('denies private app access and redirects using entitlement response', async () => { + const eventEmit = vi.fn().mockImplementation(async (_eventName, event) => { + event.result.allowed = false; + event.result.redirectUrl = 'https://puter.com/app/app-center/?item=app-11111111-1111-1111-1111-111111111111'; + }); + 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 === '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.app/', + }); + + const mockReq = { + hostname: 'paid.puter.app', + subdomains: [], + is_custom_domain: true, + baseUrl: '', + path: '/index.html', + originalUrl: '/index.html', + cookies: {}, + headers: {}, + query: {}, + ctx: mockContextInstance, + }; + const mockRes = { + redirect: vi.fn(), + cookie: vi.fn(), + setHeader: vi.fn(), + status: vi.fn().mockReturnThis(), + send: vi.fn(), + }; + const mockNext = vi.fn(); + + await capturedMiddleware(mockReq, mockRes, mockNext); + + expect(eventEmit).toHaveBeenCalledWith( + 'app.privateAccess.check', + expect.objectContaining({ + appUid: 'app-11111111-1111-1111-1111-111111111111', + userUid: null, + }), + ); + expect(mockRes.redirect).toHaveBeenCalledWith('https://puter.com/app/app-center/?item=app-11111111-1111-1111-1111-111111111111'); + expect(mockRes.cookie).not.toHaveBeenCalled(); + expect(mockNext).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/backend/src/services/ServeGUIService.js b/src/backend/src/services/ServeGUIService.js index 9d5467a5b..1910e26d5 100644 --- a/src/backend/src/services/ServeGUIService.js +++ b/src/backend/src/services/ServeGUIService.js @@ -16,17 +16,19 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -const BaseService = require('./BaseService'); - -const express = require('express'); -const _path = require('path'); - +import { static as static_ } from 'express'; +import { join } from 'path'; +import { catchAllRouter } from '../routers/_default.js'; +import { puterSiteMiddleware } from '../routers/hosting/puterSiteMiddleware.js'; +import BaseService from './BaseService.js'; +import { fileURLToPath } from 'url'; +import { dirname } from 'path'; /** * Class representing the ServeGUIService, which extends the BaseService. * This service is responsible for setting up the GUI-related routes * and serving static files for the Puter application. */ -class ServeGUIService extends BaseService { +export class ServeGUIService extends BaseService { /** * Handles the installation of GUI-related routes for the web server. * This method sets up the routing for Puter site domains and other cases, @@ -39,14 +41,15 @@ class ServeGUIService extends BaseService { const { app } = this.services.get('web-server'); // is this a puter.site domain? - require('../routers/hosting/puter-site')(app); + app.use(puterSiteMiddleware); // Router for all other cases - app.use(require('../routers/_default')); + app.use(catchAllRouter); // Static files - app.use(express.static(_path.join(__dirname, '../../public'))); + const __filename = fileURLToPath(import.meta.url); + const __dirname = dirname(__filename); + + app.use(static_(join(__dirname, '../../public'))); } } - -module.exports = ServeGUIService; diff --git a/src/backend/src/services/auth/AuthService.js b/src/backend/src/services/auth/AuthService.js index 63a25084a..5efd39c76 100644 --- a/src/backend/src/services/auth/AuthService.js +++ b/src/backend/src/services/auth/AuthService.js @@ -308,7 +308,6 @@ class AuthService extends BaseService { if ( privateHostingDomain && privateHostingDomain !== 'localhost' && - !privateHostingDomain.endsWith('.localhost') && !privateHostingDomain.includes(':') ) { cookieOptions.domain = `.${privateHostingDomain}`; diff --git a/src/backend/src/services/auth/AuthService.privateAssetToken.test.ts b/src/backend/src/services/auth/AuthService.privateAssetToken.test.ts index 18ee3d927..bc59cf41d 100644 --- a/src/backend/src/services/auth/AuthService.privateAssetToken.test.ts +++ b/src/backend/src/services/auth/AuthService.privateAssetToken.test.ts @@ -27,7 +27,7 @@ const createAuthService = (): AuthServiceForPrivateTokenTests => { jwt_secret: 'private-asset-test-secret', private_app_asset_token_ttl_seconds: 3600, private_app_asset_cookie_name: 'puter.private.asset.token', - private_app_hosting_domain: 'puter.app', + private_app_hosting_domain: 'app.puter.localhost', }; authService.modules = { jwt: { @@ -105,6 +105,6 @@ describe('AuthService private asset token helpers', () => { expect(options.httpOnly).toBe(true); expect(options.path).toBe('/'); expect(options.maxAge).toBe(3_600_000); - expect(options.domain).toBe('.puter.app'); + expect(options.domain).toBe('.app.puter.localhost'); }); });