feat: enforce private app hosting access gate (#2557)
Docker Image CI / build-and-push-image (push) Has been cancelled
Maintain Release Merge PR / update-release-pr (push) Has been cancelled
release-please / release-please (push) Has been cancelled
test / test-backend (24.x) (push) Has been cancelled
test / API tests (node env, api-test) (24.x) (push) Has been cancelled
test / puterjs (node env, vitest) (24.x) (push) Has been cancelled

Add private app access gating in PuterSiteMiddleware with entitlement event checks, bootstrap/private cookie token flow, and camelCase helper/test updates.
This commit is contained in:
Daniel Salazar
2026-02-26 23:42:32 -08:00
committed by GitHub
parent 23089901dd
commit 784847b1b1
10 changed files with 1316 additions and 905 deletions
+1 -1
View File
@@ -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;
+2 -2
View File
@@ -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.
};
+1 -1
View File
@@ -496,4 +496,4 @@ router.all('*', async function (req, res, next) {
}
});
module.exports = router;
module.exports.catchAllRouter = router;
@@ -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 <https://www.gnu.org/licenses/>.
*/
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(`<div style="font-size: 20px;
text-align: center;
height: calc(100vh);
display: flex;
justify-content: center;
flex-direction: column;">`);
res.write(`<h1 style="margin:0; color:#727272;">${status}</h1>`);
res.write('<p style="margin-top:10px;">');
if ( status === 404 && path ) {
if ( path === '/index.html' ) {
res.write('<code>index.html</code> Not Found');
} else {
res.write('Not Found');
}
} else {
res.write(html || 'Request failed');
}
res.write('</p>');
res.write('</div>');
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);
};
@@ -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 <https://www.gnu.org/licenses/>.
*/
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);
});
});
});
@@ -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 <https://www.gnu.org/licenses/>.
*/
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(`<div style="font-size: 20px;
text-align: center;
height: calc(100vh);
display: flex;
justify-content: center;
flex-direction: column;">`);
res.write(`<h1 style="margin:0; color:#727272;">${status}</h1>`);
res.write('<p style="margin-top:10px;">');
if ( status === 404 && path ) {
if ( path === '/index.html' ) {
res.write('<code>index.html</code> Not Found');
} else {
res.write('Not Found');
}
} else {
res.write(html || 'Request failed');
}
res.write('</p>');
res.write('</div>');
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);
}
}
@@ -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 <https://www.gnu.org/licenses/>.
*/
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();
});
});
});
+14 -11
View File
@@ -16,17 +16,19 @@
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
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;
@@ -308,7 +308,6 @@ class AuthService extends BaseService {
if (
privateHostingDomain &&
privateHostingDomain !== 'localhost' &&
!privateHostingDomain.endsWith('.localhost') &&
!privateHostingDomain.includes(':')
) {
cookieOptions.domain = `.${privateHostingDomain}`;
@@ -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');
});
});