diff --git a/src/backend/src/CoreModule.js b/src/backend/src/CoreModule.js
index cea0be5df..8d017d4f1 100644
--- a/src/backend/src/CoreModule.js
+++ b/src/backend/src/CoreModule.js
@@ -149,7 +149,7 @@ const install = async ({ context, services, app, useapi, modapi }) => {
const { ConfigurableCountingService } = require('./services/ConfigurableCountingService');
const { FSLockService } = require('./services/fs/FSLockService');
const FilesystemAPIService = require('./services/FilesystemAPIService');
- const ServeGUIService = require('./services/ServeGUIService');
+ const { ServeGUIService } = require('./services/ServeGUIService');
const PuterAPIService = require('./services/PuterAPIService');
const { RefreshAssociationsService } = require('./services/RefreshAssociationsService');
// Service names beginning with '__' aren't called by other services;
diff --git a/src/backend/src/config.js b/src/backend/src/config.js
index 3e2bc7065..bb2af7f6c 100644
--- a/src/backend/src/config.js
+++ b/src/backend/src/config.js
@@ -171,8 +171,8 @@ const computed_defaults = {
static_hosting_domain: config => `site.${ config.domain }${ maybe_port(config)}`,
// Hostname-only fallback helps host matching code paths that compare against req.hostname.
static_hosting_domain_alt: (config) => `site.${ config.domain }`,
- private_app_hosting_domain: config => `apps.${ config.domain }${maybe_port(config)}`,
- private_app_hosting_domain_alt: config => `apps.${ config.domain }`, // Hostname-only fallback helps host matching code paths that compare against req.hostname.
+ private_app_hosting_domain: config => `app.${ config.domain }${maybe_port(config)}`,
+ private_app_hosting_domain_alt: config => `app.${ config.domain }`, // Hostname-only fallback helps host matching code paths that compare against req.hostname.
};
diff --git a/src/backend/src/routers/_default.js b/src/backend/src/routers/_default.js
index b01240ef3..c1ce46b2e 100644
--- a/src/backend/src/routers/_default.js
+++ b/src/backend/src/routers/_default.js
@@ -496,4 +496,4 @@ router.all('*', async function (req, res, next) {
}
});
-module.exports = router;
+module.exports.catchAllRouter = router;
diff --git a/src/backend/src/routers/hosting/puter-site.js b/src/backend/src/routers/hosting/puter-site.js
deleted file mode 100644
index 57ec2002c..000000000
--- a/src/backend/src/routers/hosting/puter-site.js
+++ /dev/null
@@ -1,654 +0,0 @@
-/*
- * Copyright (C) 2024-present Puter Technologies Inc.
- *
- * This file is part of Puter.
- *
- * Puter is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as published
- * by the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see .
- */
-const { AdvancedBase } = require('@heyputer/putility');
-const api_error_handler = require('../../modules/web/lib/api_error_handler');
-const config = require('../../config');
-const { get_user, get_app } = require('../../helpers');
-const { Context } = require('../../util/context');
-const { NodeInternalIDSelector, NodePathSelector } = require('../../filesystem/node/selectors');
-const { TYPE_DIRECTORY } = require('../../filesystem/FSNodeContext');
-const { LLRead } = require('../../filesystem/ll_operations/ll_read');
-const { stream_to_buffer: streamToBuffer } = require('../../util/streamutil');
-const { Actor, UserActorType, SiteActorType } = require('../../services/auth/Actor');
-const APIError = require('../../api/APIError');
-const { PermissionUtil } = require('../../services/auth/permissionUtils.mjs');
-const { default: dedent } = require('dedent');
-const {
- parseSiteErrorConfig,
- getSiteErrorRule,
-} = require('./puter-site-config');
-
-const AT_DIRECTORY_NAMESPACE = '4aa6dc52-34c1-4b8a-b63c-a62b27f727cf';
-const puterSiteConfigFilename = '.puter_site_config';
-const puterSiteConfigMaxSize = 256 * 1024;
-
-class PuterSiteMiddleware extends AdvancedBase {
- static MODULES = {
- path: require('path'),
- mime: require('mime-types'),
- uuidv5: require('uuid').v5,
- };
- install (app) {
- app.use(this.run.bind(this));
- }
- /**
- * function wraps run_
- *
- * @param {import("express").Request} req
- * @param {import("express").Response} res
- * @param {any} next
- * @returns
- */
- async run (req, res, next) {
-
- !req.hostname.endsWith(config.static_hosting_domain)
- && ( req.subdomains[0] !== 'devtest' );
-
- const is_subdomain =
- req.hostname.endsWith(config.static_hosting_domain)
- || (config.static_hosting_domain_alt && req.hostname.endsWith(config.static_hosting_domain_alt))
- || req.subdomains[0] === 'devtest'
- ;
-
- if ( !is_subdomain && !req.is_custom_domain ) return next();
-
- res.setHeader('Access-Control-Allow-Origin', '*');
-
- try {
- const expected_ctx = req.ctx;
- const received_ctx = Context.get();
-
- if ( expected_ctx && !received_ctx ) {
- await expected_ctx.arun(async () => {
- await this.run_(req, res, next);
- });
- } else await this.run_(req, res, next);
- } catch ( e ) {
- console.error('puter-site middleware error', e);
- if ( !res.headersSent && req.__puterSiteRootPath ) {
- try {
- const handled = await this.respondSiteError({
- path: req.path,
- req,
- res,
- next,
- subdomainRootPath: req.__puterSiteRootPath,
- });
- if ( handled ) return;
- } catch ( site_error ) {
- console.error('failed handling site error response', site_error);
- }
- }
- api_error_handler(e, req, res, next);
- }
- }
- /**
- * Mega function which handles all requests to "*.puter.site"
- *
- * @param {import("express").Request} req
- * @param {import("express").Response} res
- * @param {any} next
- * @returns
- */
- async run_ (req, res, next) {
- const subdomain =
- req.is_custom_domain ? req.hostname :
- req.subdomains[0] === 'devtest' ? 'devtest' :
- req.hostname.split('.')[0];
-
- let path = (req.baseUrl + req.path) || 'index.html';
-
- const context = Context.get();
- const services = context.get('services');
-
- const get_username_site = (async () => {
- if ( ! subdomain.endsWith('.at') ) return;
- const parts = subdomain.split('.');
- if ( parts.length !== 2 ) return;
- const username = parts[0];
- if ( ! username.match(config.username_regex) ) {
- return;
- }
- const svc_fs = services.get('filesystem');
- const index_node = await svc_fs.node(new NodePathSelector(`/${username}/Public/index.html`));
- const node = await svc_fs.node(new NodePathSelector(`/${username}/Public`));
- if ( ! await index_node.exists() ) return;
-
- return {
- name: `${username }.at`,
- uuid: this.modules.uuidv5(username, AT_DIRECTORY_NAMESPACE),
- root_dir_id: await node.get('mysql-id'),
- };
- });
-
- if ( req.hostname === config.static_hosting_domain || req.hostname === config.static_hosting_domain_alt || subdomain === 'www' ) {
-
- // redirect to information page about static hosting
- return res.redirect(config.static_hosting_base_domain_redirect);
- }
-
- const site =
- await get_username_site() ||
- await (async () => {
- const svc_puterSite = services.get('puter-site');
- const site = await svc_puterSite.get_subdomain(subdomain, {
- is_custom_domain: req.is_custom_domain,
- });
- return site;
- })();
-
- if ( site === null ) {
- return res.status(404).send('Subdomain not found');
- }
-
- const subdomain_owner = await get_user({ id: site.user_id });
- if ( subdomain_owner?.suspended ) {
- // This used to be "401 Account suspended", but this implies
- // the client user is suspended, which is not the case.
- // Instead we simply return 404, indicating that this page
- // doesn't exist without further specifying that the owner's
- // account is suspended. (the client user doesn't need to know)
- return res.status(404).send('Subdomain not found');
- }
-
- if (
- site.associated_app_id &&
- !req.query['puter.app_instance_id'] &&
- ( path === '' || path.endsWith('/') )
- ) {
- const app = await get_app({ id: site.associated_app_id });
- return res.redirect(`${config.origin}/app/${app.name}/`);
- }
-
- if ( path === '' ) path += '/index.html';
- else if ( path.endsWith('/') ) path += 'index.html';
-
- const resolved_url_path =
- this.modules.path.resolve('/', path);
-
- const svc_fs = services.get('filesystem');
-
- let subdomainRootPath = '';
- if ( site.root_dir_id !== null && site.root_dir_id !== undefined ) {
- const node = await svc_fs.node(new NodeInternalIDSelector('mysql', site.root_dir_id));
- if ( ! await node.exists() ) {
- return res.status(502).send('subdomain is pointing to deleted directory');
- }
- if ( await node.get('type') !== TYPE_DIRECTORY ) {
- return res.status(502).send('subdomain is pointing to non-directory');
- }
-
- // Verify subdomain owner permission
- const subdomain_actor = Actor.adapt(subdomain_owner);
- const svc_acl = services.get('acl');
- if ( ! await svc_acl.check(subdomain_actor, node, 'read') ) {
- res.status(502).send('subdomain owner does not have access to directory');
- return;
- }
-
- subdomainRootPath = await node.get('path');
- }
-
- if ( ! subdomainRootPath ) {
- return this.respond_html_error_({
- html: dedent(`
- Subdomain or site is not pointing to a directory.
- `),
- }, req, res, next);
- }
-
- if ( !subdomainRootPath || subdomainRootPath === '/' ) {
- throw APIError.create('forbidden');
- }
-
- req.__puterSiteRootPath = subdomainRootPath;
-
- const filepath = subdomainRootPath + decodeURIComponent(resolved_url_path);
-
- const target_node = await svc_fs.node(new NodePathSelector(filepath));
- await target_node.fetchEntry();
-
- if ( ! await target_node.exists() ) {
- return await this.respond_404_({ path }, req, res, next, subdomainRootPath);
- }
-
- const target_is_dir = await target_node.get('type') === TYPE_DIRECTORY;
-
- if ( target_is_dir && !resolved_url_path.endsWith('/') ) {
- return res.redirect(`${resolved_url_path }/`);
- }
-
- if ( target_is_dir ) {
- return await this.respond_404_({ path }, req, res, next, subdomainRootPath);
- }
-
- const contentType = this.modules.mime.contentType(await target_node.get('name'));
- res.set('Content-Type', contentType);
-
- const acl_config = {
- no_acl: true,
- actor: null,
- };
-
- if ( site.protected ) {
- const svc_auth = req.services.get('auth');
-
- const get_site_actor_from_token = async () => {
- const site_token = req.cookies['puter.site.token'];
- if ( ! site_token ) return;
-
- let failed = false;
- let site_actor;
- try {
- site_actor =
- await svc_auth.authenticate_from_token(site_token);
- } catch (e) {
- failed = true;
- }
-
- if ( failed ) return;
-
- if ( ! site_actor ) return;
-
- // security measure: if 'puter.site.token' is set
- // to a different actor type, someone is likely
- // trying to exploit the system.
- if ( ! (site_actor.type instanceof SiteActorType) ) {
- return;
- }
-
- acl_config.actor = site_actor;
-
- // Refresh the token if it's been 30 seconds since
- // the last request
- if (
- (Date.now() - site_actor.type.iat * 1000)
- >
- 1000 * 30
- ) {
- const site_token = svc_auth.get_site_app_token({
- site_uid: site.uuid,
- });
- res.cookie('puter.site.token', site_token);
- }
-
- return true;
- };
-
- const make_site_actor_from_app_token = async () => {
- const token = req.query['puter.auth.token'];
-
- acl_config.no_acl = false;
-
- if ( ! token ) {
- const e = APIError.create('token_missing');
- return this.respond_error_({ req, res, e });
- }
-
- const app_actor =
- await svc_auth.authenticate_from_token(token);
-
- const user_actor =
- app_actor.get_related_actor(UserActorType);
-
- const svc_permission = req.services.get('permission');
- const perm = await (async () => {
- if ( user_actor.type.user.id === site.user_id ) {
- return {};
- }
-
- const reading = await svc_permission.scan(user_actor, `site:uid#${site.uuid}:access`);
- const options = PermissionUtil.reading_to_options(reading);
- return options.length > 0;
- })();
-
- if ( ! perm ) {
- const e = APIError.create('forbidden');
- this.respond_error_({ req, res, e });
- return false;
- }
-
- const site_actor = await Actor.create(SiteActorType, { site });
- acl_config.actor = site_actor;
-
- // This subdomain is allowed to keep the site actor token,
- // so we send it here as a cookie so other html files can
- // also load.
- const site_token = svc_auth.get_site_app_token({
- site_uid: site.uuid,
- });
- res.cookie('puter.site.token', site_token);
- return true;
- };
-
- let ok = await get_site_actor_from_token();
- if ( ! ok ) {
- ok = await make_site_actor_from_app_token();
- }
- if ( ! ok ) return;
-
- Object.freeze(acl_config);
- }
-
- // Helper function to parse Range header
- const parseRangeHeader = (rangeHeader) => {
- // Check if this is a multipart range request
- if ( rangeHeader.includes(',') ) {
- // For now, we'll only serve the first range in multipart requests
- // as the underlying storage layer doesn't support multipart responses
- const firstRange = rangeHeader.split(',')[0].trim();
- const matches = firstRange.match(/bytes=(\d+)-(\d*)/);
- if ( ! matches ) return null;
-
- const start = parseInt(matches[1], 10);
- const end = matches[2] ? parseInt(matches[2], 10) : null;
-
- return { start, end, isMultipart: true };
- }
-
- // Single range request
- const matches = rangeHeader.match(/bytes=(\d+)-(\d*)/);
- if ( ! matches ) return null;
-
- const start = parseInt(matches[1], 10);
- const end = matches[2] ? parseInt(matches[2], 10) : null;
-
- return { start, end, isMultipart: false };
- };
- if ( req.headers['range'] ) {
- res.status(206);
-
- // Parse the Range header and set Content-Range
- const rangeInfo = parseRangeHeader(req.headers['range']);
- if ( rangeInfo ) {
- const { start, end, isMultipart } = rangeInfo;
-
- // For open-ended ranges, we need to calculate the actual end byte
- let actualEnd = end;
- let fileSize = null;
-
- try {
- fileSize = await target_node.get('size');
- if ( end === null ) {
- actualEnd = fileSize - 1; // File size is 1-based, end byte is 0-based
- }
- } catch (e) {
- // If we can't get file size, we'll let the storage layer handle it
- // and not set Content-Range header
- actualEnd = null;
- fileSize = null;
- }
-
- if ( actualEnd !== null ) {
- const totalSize = fileSize !== null ? fileSize : '*';
- const contentRange = `bytes ${start}-${actualEnd}/${totalSize}`;
- res.set('Content-Range', contentRange);
- }
-
- // If this was a multipart request, modify the range header to only include the first range
- if ( isMultipart ) {
- req.headers['range'] = end !== null
- ? `bytes=${start}-${end}`
- : `bytes=${start}-`;
- }
- }
- } else {
- if ( target_node.entry.size ) {
- res.set('x-expected-entity-length', target_node.entry.size);
- }
- }
- res.set({ 'Accept-Ranges': 'bytes' });
-
- const ll_read = new LLRead();
- // const actor = Actor.adapt(req.user);
- const stream = await ll_read.run({
- no_acl: acl_config.no_acl,
- actor: acl_config.actor,
- fsNode: target_node,
- ...(req.headers['range'] ? { range: req.headers['range'] } : { }),
- });
-
- // Destroy the stream if the client disconnects
- req.on('close', () => {
- stream.destroy();
- });
-
- try {
- return stream.pipe(res);
- } catch (e) {
- const handled = await this.respondSiteError({
- path,
- req,
- res,
- next,
- subdomainRootPath,
- });
- if ( handled ) return;
- return res.status(500).send(`Error reading file: ${ e.message}`);
- }
- }
-
- async respondSiteError ({ path, html, req, res, next, subdomainRootPath }) {
- const handled = await this.maybeRespondWithSiteConfig({
- path,
- html,
- req,
- res,
- next,
- subdomainRootPath,
- errorStatus: 500,
- });
- return handled;
- }
-
- async getSiteErrorConfig (req, subdomainRootPath) {
- if ( ! subdomainRootPath ) return null;
- req.__puterSiteErrorConfigCache ??= Object.create(null);
-
- if ( req.__puterSiteErrorConfigCache[subdomainRootPath] !== undefined ) {
- return req.__puterSiteErrorConfigCache[subdomainRootPath];
- }
-
- try {
- const context = Context.get();
- const services = context.get('services');
- const svc_fs = services.get('filesystem');
-
- const configPath = `${subdomainRootPath}/${puterSiteConfigFilename}`;
- const configNode = await svc_fs.node(new NodePathSelector(configPath));
- await configNode.fetchEntry();
-
- if ( ! await configNode.exists() ) {
- req.__puterSiteErrorConfigCache[subdomainRootPath] = null;
- return null;
- }
- if ( await configNode.get('type') === TYPE_DIRECTORY ) {
- req.__puterSiteErrorConfigCache[subdomainRootPath] = null;
- return null;
- }
-
- const size = Number(await configNode.get('size') ?? 0);
- if ( Number.isFinite(size) && size > puterSiteConfigMaxSize ) {
- req.__puterSiteErrorConfigCache[subdomainRootPath] = null;
- return null;
- }
-
- const ll_read = new LLRead();
- const stream = await ll_read.run({
- no_acl: true,
- actor: null,
- fsNode: configNode,
- });
- const buffer = await streamToBuffer(stream);
- const text = buffer.toString('utf8');
- const parsed = parseSiteErrorConfig(text);
-
- req.__puterSiteErrorConfigCache[subdomainRootPath] = parsed;
- return parsed;
- } catch {
- req.__puterSiteErrorConfigCache[subdomainRootPath] = null;
- return null;
- }
- }
-
- async getSiteFileNode (subdomainRootPath, sitePath) {
- const context = Context.get();
- const services = context.get('services');
- const svc_fs = services.get('filesystem');
-
- const fullPath = `${subdomainRootPath}${sitePath}`;
- const node = await svc_fs.node(new NodePathSelector(fullPath));
- await node.fetchEntry();
- if ( ! await node.exists() ) return null;
- if ( await node.get('type') === TYPE_DIRECTORY ) return null;
- return node;
- }
-
- async maybeRespondWithSiteConfig ({
- path,
- html,
- req,
- res,
- next,
- subdomainRootPath,
- errorStatus,
- }) {
- if ( ! subdomainRootPath ) return false;
-
- const parsedConfig = await this.getSiteErrorConfig(req, subdomainRootPath);
- if ( ! parsedConfig ) return false;
-
- const rule = getSiteErrorRule(parsedConfig, errorStatus);
- if ( ! rule ) return false;
-
- const responseStatus = rule.status ?? errorStatus;
- if ( rule.file ) {
- const node = await this.getSiteFileNode(subdomainRootPath, rule.file);
- if ( node ) {
- await this.streamSiteFile({
- req,
- res,
- fsNode: node,
- status: responseStatus,
- });
- return true;
- }
- }
-
- if ( rule.status !== null && rule.status !== undefined ) {
- this.respond_html_error_({ path, html, status: responseStatus }, req, res, next);
- return true;
- }
-
- return false;
- }
-
- async streamSiteFile ({ req, res, fsNode, status }) {
- res.status(status);
- const contentType =
- this.modules.mime.contentType(await fsNode.get('name')) ||
- 'application/octet-stream';
- res.set('Content-Type', contentType);
-
- const ll_read = new LLRead();
- const stream = await ll_read.run({
- no_acl: true,
- actor: null,
- fsNode,
- });
-
- req.on('close', () => {
- stream.destroy();
- });
-
- return stream.pipe(res);
- }
-
- async respond_404_ ({ path, html }, req, res, next, subdomainRootPath) {
- const handled = await this.maybeRespondWithSiteConfig({
- path,
- html,
- req,
- res,
- next,
- subdomainRootPath,
- errorStatus: 404,
- });
- if ( handled ) return;
-
- if ( subdomainRootPath ) {
- const custom404Node = await this.getSiteFileNode(subdomainRootPath, '/404.html');
- if ( custom404Node ) {
- return this.streamSiteFile({
- req,
- res,
- fsNode: custom404Node,
- status: 404,
- });
- }
- }
-
- return this.respond_html_error_({ path, html, status: 404 }, req, res, next);
- }
-
- respond_html_error_ ({ path, html, status = 404 }, req, res, _next) {
- res.status(status);
- res.set('Content-Type', 'text/html; charset=UTF-8');
- res.write(`
`);
- res.write(`
${status}
`);
- res.write('
');
- if ( status === 404 && path ) {
- if ( path === '/index.html' ) {
- res.write('index.html Not Found');
- } else {
- res.write('Not Found');
- }
- } else {
- res.write(html || 'Request failed');
- }
- res.write('
');
-
- res.write('
');
-
- return res.end();
- }
-
- respond_error_ ({ req, res, e }) {
- if ( ! (e instanceof APIError) ) {
- // TODO: alarm here
- e = APIError.create('unknown_error');
- }
-
- res.redirect(`${config.origin}?${e.querystringize({
- ...(req.query['puter.app_instance_id'] ? {
- 'error_from_within_iframe': true,
- } : {}),
- })}`);
- }
-}
-
-module.exports = app => {
- const mw = new PuterSiteMiddleware();
- mw.install(app);
-};
diff --git a/src/backend/src/routers/hosting/puter-site.test.js b/src/backend/src/routers/hosting/puter-site.test.js
deleted file mode 100644
index 558797021..000000000
--- a/src/backend/src/routers/hosting/puter-site.test.js
+++ /dev/null
@@ -1,233 +0,0 @@
-/*
- * Copyright (C) 2026-present Puter Technologies Inc.
- *
- * This file is part of Puter.
- *
- * Puter is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as published
- * by the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see .
- */
-import { beforeEach, describe, expect, it, vi } from 'vitest';
-
-// Mocks to test middleware logic with minimal integration complexity
-// (I added region markers, so this can be collapsed for readability)
-
-// #region: mocks
-vi.mock('../../config', () => ({
- default: {
- static_hosting_domain: 'site.puter.localhost',
- static_hosting_base_domain_redirect: 'https://developer.puter.com/static-hosting/',
- username_regex: /^[a-z0-9_]+$/,
- },
- static_hosting_domain: 'site.puter.localhost',
- static_hosting_base_domain_redirect: 'https://developer.puter.com/static-hosting/',
- username_regex: /^[a-z0-9_]+$/,
-}));
-
-vi.mock('../../modules/web/lib/api_error_handler', () => ({
- default: vi.fn(),
-}));
-
-vi.mock('../../helpers', () => ({
- get_user: vi.fn(),
- get_app: vi.fn(),
-}));
-
-// Mock Context to allow arun passthrough
-const mockContextInstance = {
- get: vi.fn(),
- arun: vi.fn().mockImplementation(async (fn) => await fn()),
-};
-
-vi.mock('../../util/context', () => ({
- Context: {
- get: vi.fn().mockReturnValue(mockContextInstance),
- },
-}));
-
-vi.mock('../../filesystem/node/selectors', () => ({
- NodeInternalIDSelector: class {
- },
- NodePathSelector: class {
- },
-}));
-
-vi.mock('../../filesystem/FSNodeContext', () => ({
- TYPE_DIRECTORY: 'directory',
-}));
-
-vi.mock('../../filesystem/ll_operations/ll_read', () => ({
- LLRead: class {
- },
-}));
-
-vi.mock('../../services/auth/Actor', () => ({
- Actor: { adapt: vi.fn(), create: vi.fn() },
- UserActorType: class {
- },
- SiteActorType: class {
- },
-}));
-
-vi.mock('../../api/APIError', () => ({
- default: class APIError {
- static create () {
- return new this();
- }
- },
-}));
-
-vi.mock('../../services/auth/permissionUtils.mjs', () => ({
- PermissionUtil: {
- reading_to_options: vi.fn().mockReturnValue([]),
- },
-}));
-
-vi.mock('dedent', () => ({
- default: (str) => str,
-}));
-// #endregion
-
-// Now import the module under test - this will use our mocks
-const puterSiteModule = require('./puter-site');
-const config = require('../../config');
-
-describe('PuterSiteMiddleware', () => {
- describe('base domain redirect', () => {
- let capturedMiddleware;
- let mockApp;
-
- beforeEach(() => {
- vi.clearAllMocks();
-
- // Capture the middleware when it's installed
- mockApp = {
- use: vi.fn().mockImplementation((mw) => {
- capturedMiddleware = mw;
- }),
- };
-
- // Install the middleware
- puterSiteModule(mockApp);
- });
-
- /**
- * Creates a mock request for static hosting domain
- */
- const createMockRequest = (subdomain) => {
- const hostname = subdomain
- ? `${subdomain}.${config.static_hosting_domain}`
- : config.static_hosting_domain;
-
- return {
- hostname,
- subdomains: subdomain ? [subdomain] : [],
- is_custom_domain: false,
- baseUrl: '',
- path: '/',
- ctx: mockContextInstance,
- };
- };
-
- it('should redirect to info page when subdomain is empty (bare domain)', async () => {
- const mockReq = createMockRequest('');
- const mockRes = {
- redirect: vi.fn(),
- setHeader: vi.fn(),
- };
- const mockNext = vi.fn();
-
- await capturedMiddleware(mockReq, mockRes, mockNext);
-
- expect(mockRes.redirect).toHaveBeenCalledWith('https://developer.puter.com/static-hosting/');
- expect(mockNext).not.toHaveBeenCalled();
- });
-
- it('should redirect to info page when subdomain is www', async () => {
- const mockReq = createMockRequest('www');
- const mockRes = {
- redirect: vi.fn(),
- setHeader: vi.fn(),
- };
- const mockNext = vi.fn();
-
- await capturedMiddleware(mockReq, mockRes, mockNext);
-
- expect(mockRes.redirect).toHaveBeenCalledWith('https://developer.puter.com/static-hosting/');
- expect(mockNext).not.toHaveBeenCalled();
- });
-
- it('should NOT redirect when subdomain is a valid site name', async () => {
- // Setup mock services for the "site not found" path
- const mockServices = {
- get: vi.fn().mockImplementation((svc) => {
- if ( svc === 'puter-site' ) {
- return {
- get_subdomain: vi.fn().mockResolvedValue(null),
- };
- }
- if ( svc === 'filesystem' ) {
- return {
- node: vi.fn().mockResolvedValue({
- exists: vi.fn().mockResolvedValue(false),
- }),
- };
- }
- return {};
- }),
- };
-
- mockContextInstance.get.mockImplementation((key) => {
- if ( key === 'services' ) return mockServices;
- return null;
- });
-
- const mockReq = createMockRequest('mysite');
- const mockRes = {
- redirect: vi.fn(),
- setHeader: vi.fn(),
- status: vi.fn().mockReturnThis(),
- send: vi.fn(),
- };
- const mockNext = vi.fn();
-
- // The middleware will error out further down (due to incomplete mocks)
- // but the important thing is: did it try to redirect to the info page?
- try {
- await capturedMiddleware(mockReq, mockRes, mockNext);
- } catch (e) {
- // Expected - incomplete mocks cause errors after the redirect check
- }
-
- // The key assertion: it should NOT have redirected to the info page
- // because 'mysite' is a valid subdomain, not '' or 'www'
- expect(mockRes.redirect).not.toHaveBeenCalledWith('https://developer.puter.com/static-hosting/');
- });
-
- it('should use exactly the URL from config (not hardcoded)', async () => {
- // This test verifies the middleware reads from config.static_hosting_base_domain_redirect
- // If someone hardcodes a different URL, this assertion will catch that the
- // redirect URL matches what is in the mocked config.
- const mockReq = createMockRequest('');
- const mockRes = {
- redirect: vi.fn(),
- setHeader: vi.fn(),
- };
- const mockNext = vi.fn();
-
- await capturedMiddleware(mockReq, mockRes, mockNext);
-
- // Verify it uses the exact URL from the mocked config
- expect(mockRes.redirect).toHaveBeenCalledWith(config.static_hosting_base_domain_redirect);
- });
- });
-});
diff --git a/src/backend/src/routers/hosting/puterSiteMiddleware.js b/src/backend/src/routers/hosting/puterSiteMiddleware.js
new file mode 100644
index 000000000..f9d3a43ef
--- /dev/null
+++ b/src/backend/src/routers/hosting/puterSiteMiddleware.js
@@ -0,0 +1,887 @@
+/*
+ * Copyright (C) 2024-present Puter Technologies Inc.
+ *
+ * This file is part of Puter.
+ *
+ * Puter is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+import dedent from 'dedent';
+import { contentType as contentTypeFromMime } from 'mime-types';
+import { resolve } from 'path';
+import { v5 as uuidv5 } from 'uuid';
+import APIError from '../../api/APIError.js';
+import config from '../../config.js';
+import fsNodeContext from '../../filesystem/FSNodeContext.js';
+import llReadModule from '../../filesystem/ll_operations/ll_read.js';
+import selectors from '../../filesystem/node/selectors.js';
+import { get_app, get_user } from '../../helpers.js';
+import api_error_handler from '../../modules/web/lib/api_error_handler.js';
+import { Actor, SiteActorType, UserActorType } from '../../services/auth/Actor.js';
+import { PermissionUtil } from '../../services/auth/permissionUtils.mjs';
+import { Context } from '../../util/context.js';
+import { stream_to_buffer as streamToBuffer } from '../../util/streamutil.js';
+import {
+ getSiteErrorRule,
+ parseSiteErrorConfig,
+} from './puter-site-config.js';
+
+const {
+ origin: originUrl,
+ cookie_name: cookieName,
+ private_app_hosting_domain: privateAppHostingDomain,
+ static_hosting_base_domain_redirect: staticHostingBaseDomainRedirect,
+ static_hosting_domain: staticHostingDomain,
+ static_hosting_domain_alt: staticHostingDomainAlt,
+ username_regex: usernameRegex,
+} = config;
+const { TYPE_DIRECTORY } = fsNodeContext;
+const { LLRead } = llReadModule;
+const {
+ NodeInternalIDSelector,
+ NodePathSelector,
+} = selectors;
+
+const AT_DIRECTORY_NAMESPACE = '4aa6dc52-34c1-4b8a-b63c-a62b27f727cf';
+const puterSiteConfigFilename = '.puter_site_config';
+const puterSiteConfigMaxSize = 256 * 1024;
+
+function isPrivateApp (app) {
+ return Number(app?.is_private ?? 0) > 0;
+}
+
+function hostMatchesPrivateDomain (hostname) {
+ const privateHostingDomain = `${privateAppHostingDomain ?? 'puter.app'}`
+ .trim()
+ .toLowerCase()
+ .replace(/^\./, '');
+ if ( ! privateHostingDomain ) return false;
+
+ const host = `${hostname ?? ''}`.trim().toLowerCase();
+ if ( ! host ) return false;
+
+ return host === privateHostingDomain || host.endsWith(`.${privateHostingDomain}`);
+}
+
+function buildPrivateHostRedirectUrl (req, app) {
+ if ( !app?.index_url || typeof app.index_url !== 'string' ) {
+ return null;
+ }
+
+ try {
+ const redirectUrl = new URL(req.originalUrl || '/', app.index_url);
+ return redirectUrl.toString();
+ } catch {
+ return null;
+ }
+}
+
+function getPrivateDeniedRedirectUrl (app, denyRedirectUrl) {
+ if ( typeof denyRedirectUrl === 'string' && denyRedirectUrl.trim() ) {
+ return denyRedirectUrl.trim();
+ }
+
+ const origin = `${originUrl ?? ''}`.trim().replace(/\/$/, '');
+ if ( origin ) {
+ return `${origin}/app/app-center/?item=${encodeURIComponent(app?.uid ?? '')}`;
+ }
+
+ return '/';
+}
+
+function getTokenFromAuthorizationHeader (req) {
+ const authorizationHeader = req.headers?.authorization;
+ if ( typeof authorizationHeader !== 'string' ) return null;
+ const match = authorizationHeader.match(/^Bearer\s+(.+)$/i);
+ return match?.[1]?.trim() || null;
+}
+
+function getBootstrapTokenFromReferrer (req) {
+ const referrerHeader = req.headers?.referer ?? req.headers?.referrer;
+ if ( typeof referrerHeader !== 'string' || !referrerHeader.trim() ) {
+ return null;
+ }
+
+ try {
+ const referrerUrl = new URL(referrerHeader);
+ return referrerUrl.searchParams.get('puter.auth.token')
+ || referrerUrl.searchParams.get('auth_token');
+ } catch {
+ return null;
+ }
+}
+
+function getBootstrapPrivateToken (req) {
+ const authorizationToken = getTokenFromAuthorizationHeader(req);
+ if ( authorizationToken ) return authorizationToken;
+
+ const queryToken = req.query?.['puter.auth.token'];
+ if ( typeof queryToken === 'string' && queryToken.trim() ) {
+ return queryToken.trim();
+ }
+
+ const headerToken = req.headers?.['x-puter-auth-token'];
+ if ( typeof headerToken === 'string' && headerToken.trim() ) {
+ return headerToken.trim();
+ }
+
+ return getBootstrapTokenFromReferrer(req);
+}
+
+function actorToPrivateIdentity (actor) {
+ if ( ! actor ) return null;
+
+ let userActor = null;
+ if ( actor.type instanceof UserActorType ) {
+ userActor = actor;
+ } else {
+ try {
+ userActor = actor.get_related_actor(UserActorType);
+ } catch {
+ userActor = null;
+ }
+ }
+
+ const userUid = userActor?.type?.user?.uuid;
+ if ( typeof userUid !== 'string' || !userUid ) {
+ return null;
+ }
+
+ const sessionCandidate = actor.type?.session ?? userActor.type?.session;
+ const sessionUuid = typeof sessionCandidate === 'string'
+ ? sessionCandidate
+ : sessionCandidate?.uuid;
+
+ return {
+ userUid,
+ sessionUuid: typeof sessionUuid === 'string' && sessionUuid ? sessionUuid : undefined,
+ };
+}
+
+async function resolvePrivateIdentity ({ req, services, appUid }) {
+ const authService = services.get('auth');
+ const privateCookieName = authService.getPrivateAssetCookieName();
+ const privateCookieToken = req.cookies?.[privateCookieName];
+
+ if ( typeof privateCookieToken === 'string' && privateCookieToken ) {
+ try {
+ const claims = authService.verifyPrivateAssetToken(privateCookieToken, {
+ expectedAppUid: appUid,
+ });
+ return {
+ source: 'private-cookie',
+ userUid: claims.userUid,
+ sessionUuid: claims.sessionUuid,
+ hasValidPrivateCookie: true,
+ };
+ } catch {
+ // fallback to next token source
+ }
+ }
+
+ const sessionToken = req.cookies?.[cookieName];
+ if ( typeof sessionToken === 'string' && sessionToken ) {
+ try {
+ const actor = await authService.authenticate_from_token(sessionToken);
+ const identity = actorToPrivateIdentity(actor);
+ if ( identity ) {
+ return {
+ source: 'session-cookie',
+ ...identity,
+ hasValidPrivateCookie: false,
+ };
+ }
+ } catch {
+ // fallback to next token source
+ }
+ }
+
+ const bootstrapToken = getBootstrapPrivateToken(req);
+ if ( typeof bootstrapToken === 'string' && bootstrapToken ) {
+ try {
+ const actor = await authService.authenticate_from_token(bootstrapToken);
+ const identity = actorToPrivateIdentity(actor);
+ if ( identity ) {
+ return {
+ source: 'bootstrap-token',
+ ...identity,
+ hasValidPrivateCookie: false,
+ };
+ }
+ } catch {
+ // no valid identity from bootstrap token
+ }
+ }
+
+ return {
+ source: 'none',
+ userUid: undefined,
+ sessionUuid: undefined,
+ hasValidPrivateCookie: false,
+ };
+}
+
+async function evaluatePrivateAppAccess ({ req, res, services, app, requestPath }) {
+ const eventService = services.get('event');
+ const identity = await resolvePrivateIdentity({
+ req,
+ services,
+ appUid: app.uid,
+ });
+
+ const accessCheckEvent = {
+ appUid: app.uid,
+ userUid: identity.userUid ?? null,
+ requestHost: req.hostname,
+ requestPath,
+ result: {
+ allowed: false,
+ },
+ };
+
+ try {
+ await eventService.emit('app.privateAccess.check', accessCheckEvent);
+ } catch (e) {
+ console.error('private app access check failed', e);
+ }
+
+ if ( ! accessCheckEvent.result.allowed ) {
+ const redirectUrl = getPrivateDeniedRedirectUrl(
+ app,
+ accessCheckEvent.result.redirectUrl,
+ );
+ res.redirect(redirectUrl);
+ return false;
+ }
+
+ if ( identity.userUid && !identity.hasValidPrivateCookie ) {
+ const authService = services.get('auth');
+ const privateToken = authService.createPrivateAssetToken({
+ appUid: app.uid,
+ userUid: identity.userUid,
+ sessionUuid: identity.sessionUuid,
+ });
+ res.cookie(
+ authService.getPrivateAssetCookieName(),
+ privateToken,
+ authService.getPrivateAssetCookieOptions(),
+ );
+ }
+
+ return true;
+}
+
+async function runInternal (req, res, next) {
+ const subdomain =
+ req.is_custom_domain ? req.hostname :
+ req.subdomains[0] === 'devtest' ? 'devtest' :
+ req.hostname.split('.')[0];
+
+ let path = (req.baseUrl + req.path) || 'index.html';
+
+ const context = Context.get();
+ const services = context.get('services');
+
+ const getUsernameSite = (async () => {
+ if ( ! subdomain.endsWith('.at') ) return;
+ const parts = subdomain.split('.');
+ if ( parts.length !== 2 ) return;
+ const username = parts[0];
+ if ( ! username.match(usernameRegex) ) {
+ return;
+ }
+ const filesystemService = services.get('filesystem');
+ const indexNode = await filesystemService.node(new NodePathSelector(`/${username}/Public/index.html`));
+ const node = await filesystemService.node(new NodePathSelector(`/${username}/Public`));
+ if ( ! await indexNode.exists() ) return;
+
+ return {
+ name: `${username }.at`,
+ uuid: uuidv5(username, AT_DIRECTORY_NAMESPACE),
+ root_dir_id: await node.get('mysql-id'),
+ };
+ });
+
+ if ( req.hostname === staticHostingDomain || req.hostname === staticHostingDomainAlt || subdomain === 'www' ) {
+
+ // redirect to information page about static hosting
+ return res.redirect(staticHostingBaseDomainRedirect);
+ }
+
+ const site =
+ await getUsernameSite() ||
+ await (async () => {
+ const puterSiteService = services.get('puter-site');
+ const site = await puterSiteService.get_subdomain(subdomain, {
+ is_custom_domain: req.is_custom_domain,
+ });
+ return site;
+ })();
+
+ if ( site === null ) {
+ return res.status(404).send('Subdomain not found');
+ }
+
+ const subdomainOwner = await get_user({ id: site.user_id });
+ if ( subdomainOwner?.suspended ) {
+ // This used to be "401 Account suspended", but this implies
+ // the client user is suspended, which is not the case.
+ // Instead we simply return 404, indicating that this page
+ // doesn't exist without further specifying that the owner's
+ // account is suspended. (the client user doesn't need to know)
+ return res.status(404).send('Subdomain not found');
+ }
+
+ const associatedApp = site.associated_app_id
+ ? await get_app({ id: site.associated_app_id })
+ : null;
+ const privateAppEnabled = isPrivateApp(associatedApp);
+
+ if ( privateAppEnabled && !hostMatchesPrivateDomain(req.hostname) ) {
+ const privateHostRedirect = buildPrivateHostRedirectUrl(req, associatedApp);
+ if ( privateHostRedirect ) {
+ return res.redirect(privateHostRedirect);
+ }
+ return res.status(403).send('Private app host mismatch');
+ }
+
+ if (
+ site.associated_app_id &&
+ !req.query['puter.app_instance_id'] &&
+ ( path === '' || path.endsWith('/') )
+ ) {
+ const app = associatedApp || await get_app({ id: site.associated_app_id });
+ return res.redirect(`${originUrl}/app/${app.name}/`);
+ }
+
+ if ( path === '' ) path += '/index.html';
+ else if ( path.endsWith('/') ) path += 'index.html';
+
+ const resolvedUrlPath =
+ resolve('/', path);
+
+ const filesystemService = services.get('filesystem');
+
+ let subdomainRootPath = '';
+ if ( site.root_dir_id !== null && site.root_dir_id !== undefined ) {
+ const node = await filesystemService.node(new NodeInternalIDSelector('mysql', site.root_dir_id));
+ if ( ! await node.exists() ) {
+ return res.status(502).send('subdomain is pointing to deleted directory');
+ }
+ if ( await node.get('type') !== TYPE_DIRECTORY ) {
+ return res.status(502).send('subdomain is pointing to non-directory');
+ }
+
+ // Verify subdomain owner permission
+ const subdomainActor = Actor.adapt(subdomainOwner);
+ const aclService = services.get('acl');
+ if ( ! await aclService.check(subdomainActor, node, 'read') ) {
+ res.status(502).send('subdomain owner does not have access to directory');
+ return;
+ }
+
+ subdomainRootPath = await node.get('path');
+ }
+
+ if ( ! subdomainRootPath ) {
+ return respondHtmlError({
+ html: dedent(`
+ Subdomain or site is not pointing to a directory.
+ `),
+ }, req, res, next);
+ }
+
+ if ( !subdomainRootPath || subdomainRootPath === '/' ) {
+ throw APIError.create('forbidden');
+ }
+
+ req.__puterSiteRootPath = subdomainRootPath;
+
+ if ( privateAppEnabled ) {
+ const accessAllowed = await evaluatePrivateAppAccess({
+ req,
+ res,
+ services,
+ app: associatedApp,
+ requestPath: req.path,
+ });
+ if ( ! accessAllowed ) return;
+ }
+
+ const filepath = subdomainRootPath + decodeURIComponent(resolvedUrlPath);
+
+ const targetNode = await filesystemService.node(new NodePathSelector(filepath));
+ await targetNode.fetchEntry();
+
+ if ( ! await targetNode.exists() ) {
+ return await respond404({ path }, req, res, next, subdomainRootPath);
+ }
+
+ const targetIsDir = await targetNode.get('type') === TYPE_DIRECTORY;
+
+ if ( targetIsDir && !resolvedUrlPath.endsWith('/') ) {
+ return res.redirect(`${resolvedUrlPath }/`);
+ }
+
+ if ( targetIsDir ) {
+ return await respond404({ path }, req, res, next, subdomainRootPath);
+ }
+
+ const contentType = contentTypeFromMime(await targetNode.get('name'));
+ res.set('Content-Type', contentType);
+
+ const aclConfig = {
+ no_acl: true,
+ actor: null,
+ };
+
+ if ( site.protected ) {
+ const authService = req.services.get('auth');
+
+ const getSiteActorFromToken = async () => {
+ const siteToken = req.cookies['puter.site.token'];
+ if ( ! siteToken ) return;
+
+ let failed = false;
+ let siteActor;
+ try {
+ siteActor =
+ await authService.authenticate_from_token(siteToken);
+ } catch (e) {
+ failed = true;
+ }
+
+ if ( failed ) return;
+
+ if ( ! siteActor ) return;
+
+ // security measure: if 'puter.site.token' is set
+ // to a different actor type, someone is likely
+ // trying to exploit the system.
+ if ( ! (siteActor.type instanceof SiteActorType) ) {
+ return;
+ }
+
+ aclConfig.actor = siteActor;
+
+ // Refresh the token if it's been 30 seconds since
+ // the last request
+ if (
+ (Date.now() - siteActor.type.iat * 1000)
+ >
+ 1000 * 30
+ ) {
+ const siteToken = authService.get_site_app_token({
+ site_uid: site.uuid,
+ });
+ res.cookie('puter.site.token', siteToken);
+ }
+
+ return true;
+ };
+
+ const makeSiteActorFromAppToken = async () => {
+ const token = req.query['puter.auth.token'];
+
+ aclConfig.no_acl = false;
+
+ if ( ! token ) {
+ const e = APIError.create('token_missing');
+ return respondError({ req, res, e });
+ }
+
+ const appActor =
+ await authService.authenticate_from_token(token);
+
+ const userActor =
+ appActor.get_related_actor(UserActorType);
+
+ const permissionService = req.services.get('permission');
+ const perm = await (async () => {
+ if ( userActor.type.user.id === site.user_id ) {
+ return {};
+ }
+
+ const reading = await permissionService.scan(userActor, `site:uid#${site.uuid}:access`);
+ const options = PermissionUtil.reading_to_options(reading);
+ return options.length > 0;
+ })();
+
+ if ( ! perm ) {
+ const e = APIError.create('forbidden');
+ respondError({ req, res, e });
+ return false;
+ }
+
+ const siteActor = await Actor.create(SiteActorType, { site });
+ aclConfig.actor = siteActor;
+
+ // This subdomain is allowed to keep the site actor token,
+ // so we send it here as a cookie so other html files can
+ // also load.
+ const siteToken = authService.get_site_app_token({
+ site_uid: site.uuid,
+ });
+ res.cookie('puter.site.token', siteToken);
+ return true;
+ };
+
+ let ok = await getSiteActorFromToken();
+ if ( ! ok ) {
+ ok = await makeSiteActorFromAppToken();
+ }
+ if ( ! ok ) return;
+
+ Object.freeze(aclConfig);
+ }
+
+ // Helper function to parse Range header
+ const parseRangeHeader = (rangeHeader) => {
+ // Check if this is a multipart range request
+ if ( rangeHeader.includes(',') ) {
+ // For now, we'll only serve the first range in multipart requests
+ // as the underlying storage layer doesn't support multipart responses
+ const firstRange = rangeHeader.split(',')[0].trim();
+ const matches = firstRange.match(/bytes=(\d+)-(\d*)/);
+ if ( ! matches ) return null;
+
+ const start = parseInt(matches[1], 10);
+ const end = matches[2] ? parseInt(matches[2], 10) : null;
+
+ return { start, end, isMultipart: true };
+ }
+
+ // Single range request
+ const matches = rangeHeader.match(/bytes=(\d+)-(\d*)/);
+ if ( ! matches ) return null;
+
+ const start = parseInt(matches[1], 10);
+ const end = matches[2] ? parseInt(matches[2], 10) : null;
+
+ return { start, end, isMultipart: false };
+ };
+ if ( req.headers['range'] ) {
+ res.status(206);
+
+ // Parse the Range header and set Content-Range
+ const rangeInfo = parseRangeHeader(req.headers['range']);
+ if ( rangeInfo ) {
+ const { start, end, isMultipart } = rangeInfo;
+
+ // For open-ended ranges, we need to calculate the actual end byte
+ let actualEnd = end;
+ let fileSize = null;
+
+ try {
+ fileSize = await targetNode.get('size');
+ if ( end === null ) {
+ actualEnd = fileSize - 1; // File size is 1-based, end byte is 0-based
+ }
+ } catch (e) {
+ // If we can't get file size, we'll let the storage layer handle it
+ // and not set Content-Range header
+ actualEnd = null;
+ fileSize = null;
+ }
+
+ if ( actualEnd !== null ) {
+ const totalSize = fileSize !== null ? fileSize : '*';
+ const contentRange = `bytes ${start}-${actualEnd}/${totalSize}`;
+ res.set('Content-Range', contentRange);
+ }
+
+ // If this was a multipart request, modify the range header to only include the first range
+ if ( isMultipart ) {
+ req.headers['range'] = end !== null
+ ? `bytes=${start}-${end}`
+ : `bytes=${start}-`;
+ }
+ }
+ } else {
+ if ( targetNode.entry.size ) {
+ res.set('x-expected-entity-length', targetNode.entry.size);
+ }
+ }
+ res.set({ 'Accept-Ranges': 'bytes' });
+
+ const llRead = new LLRead();
+ // const actor = Actor.adapt(req.user);
+ const stream = await llRead.run({
+ no_acl: aclConfig.no_acl,
+ actor: aclConfig.actor,
+ fsNode: targetNode,
+ ...(req.headers['range'] ? { range: req.headers['range'] } : { }),
+ });
+
+ // Destroy the stream if the client disconnects
+ req.on('close', () => {
+ stream.destroy();
+ });
+
+ try {
+ return stream.pipe(res);
+ } catch (e) {
+ const handled = await respondSiteError({
+ path,
+ req,
+ res,
+ next,
+ subdomainRootPath,
+ });
+ if ( handled ) return;
+ return res.status(500).send(`Error reading file: ${ e.message}`);
+ }
+}
+
+async function respondSiteError ({ path, html, req, res, next, subdomainRootPath }) {
+ const handled = await maybeRespondWithSiteConfig({
+ path,
+ html,
+ req,
+ res,
+ next,
+ subdomainRootPath,
+ errorStatus: 500,
+ });
+ return handled;
+}
+
+async function getSiteErrorConfig (req, subdomainRootPath) {
+ if ( ! subdomainRootPath ) return null;
+ req.__puterSiteErrorConfigCache ??= Object.create(null);
+
+ if ( req.__puterSiteErrorConfigCache[subdomainRootPath] !== undefined ) {
+ return req.__puterSiteErrorConfigCache[subdomainRootPath];
+ }
+
+ try {
+ const context = Context.get();
+ const services = context.get('services');
+ const filesystemService = services.get('filesystem');
+
+ const configPath = `${subdomainRootPath}/${puterSiteConfigFilename}`;
+ const configNode = await filesystemService.node(new NodePathSelector(configPath));
+ await configNode.fetchEntry();
+
+ if ( ! await configNode.exists() ) {
+ req.__puterSiteErrorConfigCache[subdomainRootPath] = null;
+ return null;
+ }
+ if ( await configNode.get('type') === TYPE_DIRECTORY ) {
+ req.__puterSiteErrorConfigCache[subdomainRootPath] = null;
+ return null;
+ }
+
+ const size = Number(await configNode.get('size') ?? 0);
+ if ( Number.isFinite(size) && size > puterSiteConfigMaxSize ) {
+ req.__puterSiteErrorConfigCache[subdomainRootPath] = null;
+ return null;
+ }
+
+ const llRead = new LLRead();
+ const stream = await llRead.run({
+ no_acl: true,
+ actor: null,
+ fsNode: configNode,
+ });
+ const buffer = await streamToBuffer(stream);
+ const text = buffer.toString('utf8');
+ const parsed = parseSiteErrorConfig(text);
+
+ req.__puterSiteErrorConfigCache[subdomainRootPath] = parsed;
+ return parsed;
+ } catch {
+ req.__puterSiteErrorConfigCache[subdomainRootPath] = null;
+ return null;
+ }
+}
+
+async function getSiteFileNode (subdomainRootPath, sitePath) {
+ const context = Context.get();
+ const services = context.get('services');
+ const filesystemService = services.get('filesystem');
+
+ const fullPath = `${subdomainRootPath}${sitePath}`;
+ const node = await filesystemService.node(new NodePathSelector(fullPath));
+ await node.fetchEntry();
+ if ( ! await node.exists() ) return null;
+ if ( await node.get('type') === TYPE_DIRECTORY ) return null;
+ return node;
+}
+
+async function maybeRespondWithSiteConfig ({
+ path,
+ html,
+ req,
+ res,
+ next,
+ subdomainRootPath,
+ errorStatus,
+}) {
+ if ( ! subdomainRootPath ) return false;
+
+ const parsedConfig = await getSiteErrorConfig(req, subdomainRootPath);
+ if ( ! parsedConfig ) return false;
+
+ const rule = getSiteErrorRule(parsedConfig, errorStatus);
+ if ( ! rule ) return false;
+
+ const responseStatus = rule.status ?? errorStatus;
+ if ( rule.file ) {
+ const node = await getSiteFileNode(subdomainRootPath, rule.file);
+ if ( node ) {
+ await streamSiteFile({
+ req,
+ res,
+ fsNode: node,
+ status: responseStatus,
+ });
+ return true;
+ }
+ }
+
+ if ( rule.status !== null && rule.status !== undefined ) {
+ respondHtmlError({ path, html, status: responseStatus }, req, res, next);
+ return true;
+ }
+
+ return false;
+}
+
+async function streamSiteFile ({ req, res, fsNode, status }) {
+ res.status(status);
+ const contentType =
+ contentTypeFromMime(await fsNode.get('name')) ||
+ 'application/octet-stream';
+ res.set('Content-Type', contentType);
+
+ const llRead = new LLRead();
+ const stream = await llRead.run({
+ no_acl: true,
+ actor: null,
+ fsNode,
+ });
+
+ req.on('close', () => {
+ stream.destroy();
+ });
+
+ return stream.pipe(res);
+}
+
+async function respond404 ({ path, html }, req, res, next, subdomainRootPath) {
+ const handled = await maybeRespondWithSiteConfig({
+ path,
+ html,
+ req,
+ res,
+ next,
+ subdomainRootPath,
+ errorStatus: 404,
+ });
+ if ( handled ) return;
+
+ if ( subdomainRootPath ) {
+ const custom404Node = await getSiteFileNode(subdomainRootPath, '/404.html');
+ if ( custom404Node ) {
+ return streamSiteFile({
+ req,
+ res,
+ fsNode: custom404Node,
+ status: 404,
+ });
+ }
+ }
+
+ return respondHtmlError({ path, html, status: 404 }, req, res, next);
+}
+
+function respondHtmlError ({ path, html, status = 404 }, req, res, _next) {
+ res.status(status);
+ res.set('Content-Type', 'text/html; charset=UTF-8');
+ res.write(``);
+ res.write(`
${status}
`);
+ res.write('
');
+ if ( status === 404 && path ) {
+ if ( path === '/index.html' ) {
+ res.write('index.html Not Found');
+ } else {
+ res.write('Not Found');
+ }
+ } else {
+ res.write(html || 'Request failed');
+ }
+ res.write('
');
+
+ res.write('
');
+
+ return res.end();
+}
+
+function respondError ({ req, res, e }) {
+ if ( ! (e instanceof APIError) ) {
+ // TODO: alarm here
+ e = APIError.create('unknown_error');
+ }
+
+ res.redirect(`${originUrl}?${e.querystringize({
+ ...(req.query['puter.app_instance_id'] ? {
+ 'error_from_within_iframe': true,
+ } : {}),
+ })}`);
+}
+
+export async function puterSiteMiddleware (req, res, next) {
+ const isSubdomain =
+ req.hostname.endsWith(staticHostingDomain)
+ || (staticHostingDomainAlt && req.hostname.endsWith(staticHostingDomainAlt))
+ || req.subdomains[0] === 'devtest'
+ ;
+
+ if ( !isSubdomain && !req.is_custom_domain ) return next();
+
+ res.setHeader('Access-Control-Allow-Origin', '*');
+
+ try {
+ const expectedCtx = req.ctx;
+ const receivedCtx = Context.get();
+
+ if ( expectedCtx && !receivedCtx ) {
+ await expectedCtx.arun(async () => {
+ await runInternal(req, res, next);
+ });
+ } else await runInternal(req, res, next);
+ } catch ( e ) {
+ console.error('puter-site middleware error', e);
+ if ( !res.headersSent && req.__puterSiteRootPath ) {
+ try {
+ const handled = await respondSiteError({
+ path: req.path,
+ req,
+ res,
+ next,
+ subdomainRootPath: req.__puterSiteRootPath,
+ });
+ if ( handled ) return;
+ } catch ( siteError ) {
+ console.error('failed handling site error response', siteError);
+ }
+ }
+ api_error_handler(e, req, res, next);
+ }
+}
diff --git a/src/backend/src/routers/hosting/puterSiteMiddleware.test.js b/src/backend/src/routers/hosting/puterSiteMiddleware.test.js
new file mode 100644
index 000000000..ad6cc0035
--- /dev/null
+++ b/src/backend/src/routers/hosting/puterSiteMiddleware.test.js
@@ -0,0 +1,409 @@
+/*
+ * Copyright (C) 2026-present Puter Technologies Inc.
+ *
+ * This file is part of Puter.
+ *
+ * Puter is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { puterSiteMiddleware } from './puterSiteMiddleware';
+import config from '../../config.js';
+import { Context } from '../../util/context.js';
+
+// Mocks to test middleware logic with minimal integration complexity
+// (I added region markers, so this can be collapsed for readability)
+
+// #region: mocks
+let getUserMockImpl = async () => null;
+let getAppMockImpl = async () => null;
+
+vi.mock('../../config.js', () => ({
+ default: {
+ static_hosting_domain: 'site.puter.localhost',
+ static_hosting_base_domain_redirect: 'https://developer.puter.com/static-hosting/',
+ private_app_hosting_domain: 'puter.app',
+ origin: 'https://puter.com',
+ cookie_name: 'puter.session.token',
+ username_regex: /^[a-z0-9_]+$/,
+ },
+ static_hosting_domain: 'site.puter.localhost',
+ static_hosting_base_domain_redirect: 'https://developer.puter.com/static-hosting/',
+ private_app_hosting_domain: 'puter.app',
+ origin: 'https://puter.com',
+ cookie_name: 'puter.session.token',
+ username_regex: /^[a-z0-9_]+$/,
+}));
+
+vi.mock('../../modules/web/lib/api_error_handler.js', () => ({
+ default: vi.fn(),
+}));
+
+vi.mock('../../helpers.js', () => ({
+ get_user: vi.fn((...args) => getUserMockImpl(...args)),
+ get_app: vi.fn((...args) => getAppMockImpl(...args)),
+}));
+
+vi.mock('../../util/context.js', () => ({
+ Context: {
+ get: vi.fn(),
+ },
+}));
+
+// Mock Context to allow arun passthrough
+const mockContextInstance = {
+ get: vi.fn(),
+ arun: vi.fn().mockImplementation(async (fn) => await fn()),
+};
+
+vi.mock('../../filesystem/node/selectors.js', () => ({
+ default: {
+ NodeInternalIDSelector: class {
+ },
+ NodePathSelector: class {
+ },
+ },
+ NodeInternalIDSelector: class {
+ },
+ NodePathSelector: class {
+ },
+}));
+
+vi.mock('../../filesystem/FSNodeContext.js', () => ({
+ default: {
+ TYPE_DIRECTORY: 'directory',
+ },
+ TYPE_DIRECTORY: 'directory',
+}));
+
+vi.mock('../../filesystem/ll_operations/ll_read.js', () => ({
+ default: {
+ LLRead: class {
+ },
+ },
+ LLRead: class {
+ },
+}));
+
+vi.mock('../../services/auth/Actor.js', () => ({
+ Actor: { adapt: vi.fn(), create: vi.fn() },
+ UserActorType: class {
+ },
+ SiteActorType: class {
+ },
+}));
+
+vi.mock('../../api/APIError.js', () => ({
+ default: class APIError {
+ static create () {
+ return new this();
+ }
+ },
+}));
+
+vi.mock('../../services/auth/permissionUtils.mjs', () => ({
+ PermissionUtil: {
+ reading_to_options: vi.fn().mockReturnValue([]),
+ },
+}));
+
+vi.mock('dedent', () => ({
+ default: (str) => str,
+}));
+// #endregion
+
+// Now import the module under test - this will use our mocks
+describe('PuterSiteMiddleware', () => {
+ describe('base domain redirect', () => {
+ let capturedMiddleware;
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ Context.get = vi.fn().mockReturnValue(mockContextInstance);
+ getUserMockImpl = async () => null;
+ getAppMockImpl = async () => null;
+ capturedMiddleware = puterSiteMiddleware;
+ });
+
+ /**
+ * Creates a mock request for static hosting domain
+ */
+ const createMockRequest = (subdomain) => {
+ const hostname = subdomain
+ ? `${subdomain}.${config.static_hosting_domain}`
+ : config.static_hosting_domain;
+
+ return {
+ hostname,
+ subdomains: subdomain ? [subdomain] : [],
+ is_custom_domain: false,
+ baseUrl: '',
+ path: '/',
+ ctx: mockContextInstance,
+ };
+ };
+
+ it('should redirect to info page when subdomain is empty (bare domain)', async () => {
+ const mockReq = createMockRequest('');
+ const mockRes = {
+ redirect: vi.fn(),
+ setHeader: vi.fn(),
+ };
+ const mockNext = vi.fn();
+
+ await capturedMiddleware(mockReq, mockRes, mockNext);
+
+ expect(mockRes.redirect).toHaveBeenCalledWith('https://developer.puter.com/static-hosting/');
+ expect(mockNext).not.toHaveBeenCalled();
+ });
+
+ it('should redirect to info page when subdomain is www', async () => {
+ const mockReq = createMockRequest('www');
+ const mockRes = {
+ redirect: vi.fn(),
+ setHeader: vi.fn(),
+ };
+ const mockNext = vi.fn();
+
+ await capturedMiddleware(mockReq, mockRes, mockNext);
+
+ expect(mockRes.redirect).toHaveBeenCalledWith('https://developer.puter.com/static-hosting/');
+ expect(mockNext).not.toHaveBeenCalled();
+ });
+
+ it('should NOT redirect when subdomain is a valid site name', async () => {
+ // Setup mock services for the "site not found" path
+ const mockServices = {
+ get: vi.fn().mockImplementation((svc) => {
+ if ( svc === 'puter-site' ) {
+ return {
+ get_subdomain: vi.fn().mockResolvedValue(null),
+ };
+ }
+ if ( svc === 'filesystem' ) {
+ return {
+ node: vi.fn().mockResolvedValue({
+ exists: vi.fn().mockResolvedValue(false),
+ }),
+ };
+ }
+ return {};
+ }),
+ };
+
+ mockContextInstance.get.mockImplementation((key) => {
+ if ( key === 'services' ) return mockServices;
+ return null;
+ });
+
+ const mockReq = createMockRequest('mysite');
+ const mockRes = {
+ redirect: vi.fn(),
+ setHeader: vi.fn(),
+ status: vi.fn().mockReturnThis(),
+ send: vi.fn(),
+ };
+ const mockNext = vi.fn();
+
+ // The middleware will error out further down (due to incomplete mocks)
+ // but the important thing is: did it try to redirect to the info page?
+ try {
+ await capturedMiddleware(mockReq, mockRes, mockNext);
+ } catch (e) {
+ // Expected - incomplete mocks cause errors after the redirect check
+ }
+
+ // The key assertion: it should NOT have redirected to the info page
+ // because 'mysite' is a valid subdomain, not '' or 'www'
+ expect(mockRes.redirect).not.toHaveBeenCalledWith('https://developer.puter.com/static-hosting/');
+ });
+
+ it('should use exactly the URL from config (not hardcoded)', async () => {
+ // This test verifies the middleware reads from config.static_hosting_base_domain_redirect
+ // If someone hardcodes a different URL, this assertion will catch that the
+ // redirect URL matches what is in the mocked config.
+ const mockReq = createMockRequest('');
+ const mockRes = {
+ redirect: vi.fn(),
+ setHeader: vi.fn(),
+ };
+ const mockNext = vi.fn();
+
+ await capturedMiddleware(mockReq, mockRes, mockNext);
+
+ // Verify it uses the exact URL from the mocked config
+ expect(mockRes.redirect).toHaveBeenCalledWith(config.static_hosting_base_domain_redirect);
+ });
+ });
+
+ describe('private app access gate', () => {
+ let capturedMiddleware;
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ Context.get = vi.fn().mockReturnValue(mockContextInstance);
+ getUserMockImpl = async () => null;
+ getAppMockImpl = async () => null;
+ capturedMiddleware = puterSiteMiddleware;
+ });
+
+ it('redirects private app assets to puter.app host', async () => {
+ const mockServices = {
+ get: vi.fn().mockImplementation((serviceName) => {
+ if ( serviceName === 'puter-site' ) {
+ return {
+ get_subdomain: vi.fn().mockResolvedValue({
+ user_id: 101,
+ associated_app_id: 202,
+ root_dir_id: null,
+ }),
+ };
+ }
+ return {};
+ }),
+ };
+ mockContextInstance.get.mockImplementation((key) => {
+ if ( key === 'services' ) return mockServices;
+ return null;
+ });
+ getUserMockImpl = async () => ({ id: 101, suspended: false });
+ getAppMockImpl = async () => ({
+ uid: 'app-11111111-1111-1111-1111-111111111111',
+ name: 'paid-app',
+ is_private: 1,
+ index_url: 'https://paid.puter.app/',
+ });
+
+ const mockReq = {
+ hostname: 'paid.site.puter.localhost',
+ subdomains: ['paid'],
+ is_custom_domain: false,
+ baseUrl: '',
+ path: '/asset.js',
+ originalUrl: '/asset.js?foo=1',
+ query: {},
+ cookies: {},
+ headers: {},
+ ctx: mockContextInstance,
+ };
+ const mockRes = {
+ redirect: vi.fn(),
+ setHeader: vi.fn(),
+ status: vi.fn().mockReturnThis(),
+ send: vi.fn(),
+ };
+ const mockNext = vi.fn();
+
+ await capturedMiddleware(mockReq, mockRes, mockNext);
+
+ expect(mockRes.redirect).toHaveBeenCalledWith('https://paid.puter.app/asset.js?foo=1');
+ expect(mockNext).not.toHaveBeenCalled();
+ });
+
+ it('denies private app access and redirects using entitlement response', async () => {
+ const eventEmit = vi.fn().mockImplementation(async (_eventName, event) => {
+ event.result.allowed = false;
+ event.result.redirectUrl = 'https://puter.com/app/app-center/?item=app-11111111-1111-1111-1111-111111111111';
+ });
+ const authService = {
+ getPrivateAssetCookieName: vi.fn().mockReturnValue('puter.private.asset.token'),
+ verifyPrivateAssetToken: vi.fn().mockImplementation(() => {
+ throw new Error('invalid');
+ }),
+ authenticate_from_token: vi.fn().mockImplementation(() => {
+ throw new Error('invalid');
+ }),
+ createPrivateAssetToken: vi.fn().mockReturnValue('private-token'),
+ getPrivateAssetCookieOptions: vi.fn().mockReturnValue({}),
+ };
+ const mockServices = {
+ get: vi.fn().mockImplementation((serviceName) => {
+ if ( serviceName === 'puter-site' ) {
+ return {
+ get_subdomain: vi.fn().mockResolvedValue({
+ user_id: 101,
+ associated_app_id: 202,
+ root_dir_id: 303,
+ }),
+ };
+ }
+ if ( serviceName === 'filesystem' ) {
+ return {
+ node: vi.fn().mockResolvedValue({
+ exists: vi.fn().mockResolvedValue(true),
+ get: vi.fn().mockImplementation(async (fieldName) => {
+ if ( fieldName === 'type' ) return 'directory';
+ if ( fieldName === 'path' ) return '/alice/Public';
+ return null;
+ }),
+ }),
+ };
+ }
+ if ( serviceName === 'acl' ) {
+ return {
+ check: vi.fn().mockResolvedValue(true),
+ };
+ }
+ if ( serviceName === 'event' ) return { emit: eventEmit };
+ if ( serviceName === 'auth' ) return authService;
+ return {};
+ }),
+ };
+ mockContextInstance.get.mockImplementation((key) => {
+ if ( key === 'services' ) return mockServices;
+ return null;
+ });
+ getUserMockImpl = async () => ({ id: 101, suspended: false });
+ getAppMockImpl = async () => ({
+ uid: 'app-11111111-1111-1111-1111-111111111111',
+ name: 'paid-app',
+ is_private: 1,
+ index_url: 'https://paid.puter.app/',
+ });
+
+ const mockReq = {
+ hostname: 'paid.puter.app',
+ subdomains: [],
+ is_custom_domain: true,
+ baseUrl: '',
+ path: '/index.html',
+ originalUrl: '/index.html',
+ cookies: {},
+ headers: {},
+ query: {},
+ ctx: mockContextInstance,
+ };
+ const mockRes = {
+ redirect: vi.fn(),
+ cookie: vi.fn(),
+ setHeader: vi.fn(),
+ status: vi.fn().mockReturnThis(),
+ send: vi.fn(),
+ };
+ const mockNext = vi.fn();
+
+ await capturedMiddleware(mockReq, mockRes, mockNext);
+
+ expect(eventEmit).toHaveBeenCalledWith(
+ 'app.privateAccess.check',
+ expect.objectContaining({
+ appUid: 'app-11111111-1111-1111-1111-111111111111',
+ userUid: null,
+ }),
+ );
+ expect(mockRes.redirect).toHaveBeenCalledWith('https://puter.com/app/app-center/?item=app-11111111-1111-1111-1111-111111111111');
+ expect(mockRes.cookie).not.toHaveBeenCalled();
+ expect(mockNext).not.toHaveBeenCalled();
+ });
+ });
+});
diff --git a/src/backend/src/services/ServeGUIService.js b/src/backend/src/services/ServeGUIService.js
index 9d5467a5b..1910e26d5 100644
--- a/src/backend/src/services/ServeGUIService.js
+++ b/src/backend/src/services/ServeGUIService.js
@@ -16,17 +16,19 @@
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see .
*/
-const BaseService = require('./BaseService');
-
-const express = require('express');
-const _path = require('path');
-
+import { static as static_ } from 'express';
+import { join } from 'path';
+import { catchAllRouter } from '../routers/_default.js';
+import { puterSiteMiddleware } from '../routers/hosting/puterSiteMiddleware.js';
+import BaseService from './BaseService.js';
+import { fileURLToPath } from 'url';
+import { dirname } from 'path';
/**
* Class representing the ServeGUIService, which extends the BaseService.
* This service is responsible for setting up the GUI-related routes
* and serving static files for the Puter application.
*/
-class ServeGUIService extends BaseService {
+export class ServeGUIService extends BaseService {
/**
* Handles the installation of GUI-related routes for the web server.
* This method sets up the routing for Puter site domains and other cases,
@@ -39,14 +41,15 @@ class ServeGUIService extends BaseService {
const { app } = this.services.get('web-server');
// is this a puter.site domain?
- require('../routers/hosting/puter-site')(app);
+ app.use(puterSiteMiddleware);
// Router for all other cases
- app.use(require('../routers/_default'));
+ app.use(catchAllRouter);
// Static files
- app.use(express.static(_path.join(__dirname, '../../public')));
+ const __filename = fileURLToPath(import.meta.url);
+ const __dirname = dirname(__filename);
+
+ app.use(static_(join(__dirname, '../../public')));
}
}
-
-module.exports = ServeGUIService;
diff --git a/src/backend/src/services/auth/AuthService.js b/src/backend/src/services/auth/AuthService.js
index 63a25084a..5efd39c76 100644
--- a/src/backend/src/services/auth/AuthService.js
+++ b/src/backend/src/services/auth/AuthService.js
@@ -308,7 +308,6 @@ class AuthService extends BaseService {
if (
privateHostingDomain &&
privateHostingDomain !== 'localhost' &&
- !privateHostingDomain.endsWith('.localhost') &&
!privateHostingDomain.includes(':')
) {
cookieOptions.domain = `.${privateHostingDomain}`;
diff --git a/src/backend/src/services/auth/AuthService.privateAssetToken.test.ts b/src/backend/src/services/auth/AuthService.privateAssetToken.test.ts
index 18ee3d927..bc59cf41d 100644
--- a/src/backend/src/services/auth/AuthService.privateAssetToken.test.ts
+++ b/src/backend/src/services/auth/AuthService.privateAssetToken.test.ts
@@ -27,7 +27,7 @@ const createAuthService = (): AuthServiceForPrivateTokenTests => {
jwt_secret: 'private-asset-test-secret',
private_app_asset_token_ttl_seconds: 3600,
private_app_asset_cookie_name: 'puter.private.asset.token',
- private_app_hosting_domain: 'puter.app',
+ private_app_hosting_domain: 'app.puter.localhost',
};
authService.modules = {
jwt: {
@@ -105,6 +105,6 @@ describe('AuthService private asset token helpers', () => {
expect(options.httpOnly).toBe(true);
expect(options.path).toBe('/');
expect(options.maxAge).toBe(3_600_000);
- expect(options.domain).toBe('.puter.app');
+ expect(options.domain).toBe('.app.puter.localhost');
});
});