mirror of
https://github.com/HeyPuter/puter.git
synced 2026-09-22 21:26:04 +00:00
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
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:
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
};
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user