mirror of
https://github.com/HeyPuter/puter.git
synced 2026-08-24 15:07:17 +00:00
1570 lines
66 KiB
TypeScript
1570 lines
66 KiB
TypeScript
/*
|
|
* 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/>.
|
|
*/
|
|
|
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
import compression from 'compression';
|
|
import cookieParser from 'cookie-parser';
|
|
import express from 'express';
|
|
import type { Application, RequestHandler } from 'express';
|
|
import helmet from 'helmet';
|
|
import uaParser from 'ua-parser-js';
|
|
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
import path from 'node:path';
|
|
import { pathToFileURL } from 'node:url';
|
|
import { parseArgs } from 'node:util';
|
|
import http from 'node:http';
|
|
import { puterClients } from './clients';
|
|
import { puterControllers } from './controllers';
|
|
import { createAuthProbe } from './core/http/middleware/authProbe';
|
|
import { createRequestContextMiddleware } from './core/http/middleware/requestContext';
|
|
import { createFingerprintMiddleware } from './core/http/middleware/fingerprint';
|
|
import { createErrorHandler } from './core/http/middleware/errorHandler';
|
|
import { isHttpError } from './core/http/HttpError';
|
|
import {
|
|
adminOnlyGate,
|
|
allowedAppIdsGate,
|
|
noUserSessionGate,
|
|
requireAuthGate,
|
|
requireCardVerifiedGate,
|
|
requirePhoneVerifiedGate,
|
|
requireVerifiedAccount,
|
|
requireNonAccessTokenGate,
|
|
requireUserActorGate,
|
|
requireVerifiedGate,
|
|
subdomainGate,
|
|
} from './core/http/middleware/gates';
|
|
import { guiOriginGate } from './core/http/middleware/originGate';
|
|
import { requireCreditsGate } from './core/http/middleware/credits';
|
|
import { requireSubscriptionGate } from './core/http/middleware/subscription';
|
|
import { validateSubscriptionRequirement } from './services/metering/enforcement';
|
|
import { createStepUpGate } from './core/http/middleware/stepUpSession';
|
|
import { createNotFoundHandler } from './core/http/middleware/notFoundHandler';
|
|
import { installProcessGuards } from './util/processGuards';
|
|
import { activeSubdomain, subdomainOffsetForDomain } from './util/subdomains';
|
|
import {
|
|
requireAntiCsrf,
|
|
setAntiCsrfRedis,
|
|
} from './core/http/middleware/antiCsrf';
|
|
import { captchaGate, setCaptchaRedis } from './core/http/middleware/captcha';
|
|
import {
|
|
concurrencyGate,
|
|
configureRateLimit,
|
|
rateLimitGate,
|
|
} from './core/http/middleware/rateLimit';
|
|
import {
|
|
createWwwRedirect,
|
|
createUserSubdomainRedirect,
|
|
createNativeAppStatic,
|
|
} from './core/http/middleware/hostRedirects';
|
|
import { createEgressMeteringMiddleware } from './core/http/middleware/egressMetering';
|
|
import { createLocalWorkerProxyMiddleware } from './core/http/middleware/localWorkerProxy';
|
|
import { createPuterSiteMiddleware } from './core/http/middleware/puterSite';
|
|
import { PuterRouter } from './core/http/PuterRouter';
|
|
import { createRouteLifecycleMiddleware } from './core/http/routeLifecycle';
|
|
import { PREFIX_METADATA_KEY, type RouteDescriptor } from './core/http/types';
|
|
import type { AuthService } from './services/auth/AuthService';
|
|
import { puterDrivers } from './drivers';
|
|
import {
|
|
clientsContainers,
|
|
configContainer,
|
|
controllersContainers,
|
|
driversContainers,
|
|
servicesContainers,
|
|
storesContainers,
|
|
} from './exports';
|
|
import { extensionStore } from './extensions';
|
|
import { puterServices } from './services';
|
|
import { puterStores } from './stores';
|
|
import type {
|
|
IConfig,
|
|
LayerInstances,
|
|
PagerSeverity,
|
|
WithControllerRegistration,
|
|
WithLifecycle,
|
|
} from './types';
|
|
|
|
export class PuterServer {
|
|
clients!: LayerInstances<typeof puterClients>;
|
|
stores!: LayerInstances<typeof puterStores>;
|
|
services!: LayerInstances<typeof puterServices>;
|
|
controllers!: LayerInstances<typeof puterControllers>;
|
|
drivers!: LayerInstances<typeof puterDrivers>;
|
|
#config: IConfig;
|
|
#app!: ReturnType<typeof express>;
|
|
#server: ReturnType<ReturnType<typeof express>['listen']> | null = null;
|
|
#removeProcessGuards: (() => void) | null = null;
|
|
|
|
#ready: Promise<boolean>;
|
|
|
|
constructor(
|
|
config: IConfig,
|
|
clients: typeof puterClients = puterClients,
|
|
stores: typeof puterStores = puterStores,
|
|
services: typeof puterServices = puterServices,
|
|
controllers: typeof puterControllers = puterControllers,
|
|
drivers: typeof puterDrivers = puterDrivers,
|
|
) {
|
|
this.#config = config;
|
|
// Expose config to the extension API (extension.config)
|
|
Object.assign(configContainer, config);
|
|
this.#ready = this.#setupServer(
|
|
clients,
|
|
stores,
|
|
services,
|
|
controllers,
|
|
drivers,
|
|
);
|
|
}
|
|
|
|
async #setupServer(
|
|
clients: typeof puterClients,
|
|
stores: typeof puterStores,
|
|
services: typeof puterServices,
|
|
controllers: typeof puterControllers,
|
|
drivers: typeof puterDrivers,
|
|
) {
|
|
// Load prod extensions from configured directories (dynamic)
|
|
const extensionDirs = this.#config.extensions;
|
|
await this.#importExtensions(extensionDirs);
|
|
|
|
this.clients = {} as typeof this.clients;
|
|
for (const [clientName, ClientClass] of Object.entries(clients)) {
|
|
// @ts-expect-error as any casting to avoid overly complex or circular types
|
|
this.clients[clientName] =
|
|
typeof ClientClass === 'object'
|
|
? ClientClass
|
|
: (new (ClientClass as any)(this.#config) as any);
|
|
// @ts-expect-error implicit any casting to avoid overly complex or circular types
|
|
clientsContainers[clientName] = this.clients[clientName];
|
|
}
|
|
for (const [clientName, ClientClass] of Object.entries(
|
|
extensionStore.clients,
|
|
)) {
|
|
// @ts-expect-error as any casting to avoid overly complex or circular types
|
|
this.clients[clientName] =
|
|
typeof ClientClass === 'object'
|
|
? ClientClass
|
|
: (new (ClientClass as any)(this.#config) as any);
|
|
// @ts-expect-error implicit any casting to avoid overly complex or circular types
|
|
clientsContainers[clientName] = this.clients[clientName];
|
|
}
|
|
|
|
this.stores = {} as typeof this.stores;
|
|
for (const [storeName, StoreClass] of Object.entries(stores)) {
|
|
// @ts-expect-error as any casting to avoid overly complex or circular types
|
|
this.stores[storeName] =
|
|
typeof StoreClass === 'object'
|
|
? StoreClass
|
|
: (new (StoreClass as any)(
|
|
this.#config,
|
|
this.clients,
|
|
this.stores,
|
|
) as any);
|
|
// @ts-expect-error implicit any casting to avoid overly complex or circular types
|
|
storesContainers[storeName] = this.stores[storeName];
|
|
}
|
|
for (const [storeName, StoreClass] of Object.entries(
|
|
extensionStore.stores,
|
|
)) {
|
|
// @ts-expect-error as any casting to avoid overly complex or circular types
|
|
this.stores[storeName] =
|
|
typeof StoreClass === 'object'
|
|
? StoreClass
|
|
: (new (StoreClass as any)(
|
|
this.#config,
|
|
this.clients,
|
|
this.stores,
|
|
) as any);
|
|
// @ts-expect-error implicit any casting to avoid overly complex or circular types
|
|
storesContainers[storeName] = this.stores[storeName];
|
|
}
|
|
|
|
this.services = {} as typeof this.services;
|
|
for (const [serviceName, ServiceClass] of Object.entries(services)) {
|
|
// @ts-expect-error as any casting to avoid overly complex or circular types
|
|
this.services[serviceName] =
|
|
typeof ServiceClass === 'object'
|
|
? ServiceClass
|
|
: (new (ServiceClass as any)(
|
|
this.#config,
|
|
this.clients,
|
|
this.stores,
|
|
this.services,
|
|
) as any);
|
|
// @ts-expect-error implicit any casting to avoid overly complex or circular types
|
|
servicesContainers[serviceName] = this.services[serviceName];
|
|
}
|
|
for (const [serviceName, ServiceClass] of Object.entries(
|
|
extensionStore.services,
|
|
)) {
|
|
// @ts-expect-error as any casting to avoid overly complex or circular types
|
|
this.services[serviceName] =
|
|
typeof ServiceClass === 'object'
|
|
? ServiceClass
|
|
: (new (ServiceClass as any)(
|
|
this.#config,
|
|
this.clients,
|
|
this.stores,
|
|
this.services,
|
|
) as any);
|
|
// @ts-expect-error implicit any casting to avoid overly complex or circular types
|
|
servicesContainers[serviceName] = this.services[serviceName];
|
|
}
|
|
|
|
// Wire the rate-limiter to its configured backend now that clients
|
|
// and stores exist. Memory is the default; `redis` needs a redis
|
|
// client, `kv` needs the system KV store (DynamoDB-backed).
|
|
this.#configureRateLimiter();
|
|
|
|
// Anti-CSRF tokens live in redis so they survive cross-node hops
|
|
// (issue on node A, consume on node B).
|
|
setAntiCsrfRedis(this.clients.redis);
|
|
setCaptchaRedis(this.clients.redis);
|
|
|
|
// init express server here
|
|
this.#app = express();
|
|
// `trust proxy` MUST be set before any middleware reads `req.ip` /
|
|
// `req.ips` / `req.protocol`, since express derives those from XFF
|
|
// only when this flag is set. Default is `false` (no proxy trusted)
|
|
// — deployments behind a reverse proxy chain must set
|
|
// `config.trust_proxy` to the hop count (e.g. `1` for a single
|
|
// Cloudflare/nginx hop). Never `true` in prod: that trusts every hop
|
|
// and makes XFF forgeable.
|
|
this.#app.set('trust proxy', this.#config.trust_proxy ?? false);
|
|
// Every subdomain gate reads `req.subdomains`, which express derives by
|
|
// dropping `subdomain offset` labels from the right of the hostname.
|
|
// The offset is the root domain's own label count, so a deployment on
|
|
// `puter.example.com` doesn't read `puter` as an active subdomain.
|
|
this.#app.set(
|
|
'subdomain offset',
|
|
subdomainOffsetForDomain(this.#config.domain),
|
|
);
|
|
this.#installGlobalMiddleware();
|
|
|
|
// Instantiate drivers BEFORE controllers so controllers can receive
|
|
// a typed `drivers` reference. The `/drivers/*` HTTP surface lives
|
|
// on `DriverController` (a regular controller) which reads from
|
|
// `this.drivers` — no separate registry object here any more.
|
|
this.drivers = {} as typeof this.drivers;
|
|
const allDriverSources = [
|
|
...Object.entries(drivers),
|
|
...Object.entries(extensionStore.drivers),
|
|
];
|
|
for (const [driverKey, DriverClass] of allDriverSources) {
|
|
const instance =
|
|
typeof DriverClass === 'object'
|
|
? DriverClass
|
|
: (new (DriverClass as any)(
|
|
this.#config,
|
|
this.clients,
|
|
this.stores,
|
|
this.services,
|
|
) as any);
|
|
// @ts-expect-error as any casting to avoid overly complex or circular types
|
|
this.drivers[driverKey] = instance;
|
|
driversContainers[driverKey] = instance;
|
|
}
|
|
|
|
this.controllers = {} as typeof this.controllers;
|
|
for (const [controllerName, ControllerClass] of Object.entries(
|
|
controllers,
|
|
)) {
|
|
// @ts-expect-error as any casting to avoid overly complex or circular types
|
|
this.controllers[controllerName] =
|
|
typeof ControllerClass === 'object'
|
|
? ControllerClass
|
|
: (new (ControllerClass as any)(
|
|
this.#config,
|
|
this.clients,
|
|
this.stores,
|
|
this.services,
|
|
this.drivers,
|
|
) as any);
|
|
this.#registerControllerRoutes(
|
|
controllerName,
|
|
// @ts-expect-error as any casting to avoid overly complex or circular types
|
|
this.controllers[controllerName],
|
|
);
|
|
controllersContainers[controllerName] =
|
|
// @ts-expect-error as any casting to avoid overly complex or circular types
|
|
this.controllers[controllerName];
|
|
}
|
|
for (const [controllerName, ControllerClass] of Object.entries(
|
|
extensionStore.controllers,
|
|
)) {
|
|
// @ts-expect-error as any casting to avoid overly complex or circular types
|
|
this.controllers[controllerName] =
|
|
typeof ControllerClass === 'object'
|
|
? ControllerClass
|
|
: (new (ControllerClass as any)(
|
|
this.#config,
|
|
this.clients,
|
|
this.stores,
|
|
this.services,
|
|
this.drivers,
|
|
) as any);
|
|
this.#registerControllerRoutes(
|
|
controllerName,
|
|
// @ts-expect-error as any casting to avoid overly complex or circular types
|
|
this.controllers[controllerName],
|
|
);
|
|
controllersContainers[controllerName] =
|
|
// @ts-expect-error as any casting to avoid overly complex or circular types
|
|
this.controllers[controllerName];
|
|
}
|
|
|
|
// Extension routes are shaped as `RouteDescriptor`s too, so they
|
|
// flow through the same materializer as controller routes — same
|
|
// option → middleware translation (subdomain, auth, body parsers, …).
|
|
// The extension-layer "prefix" is always empty; extensions compose
|
|
// their own path strings.
|
|
for (const route of extensionStore.routeHandlers) {
|
|
this.#materializeRoute(this.#app, '', route);
|
|
}
|
|
|
|
// Terminal middleware MUST install last — after every route + extension
|
|
// route is registered, so the catch-all 404 only fires for genuinely
|
|
// unmatched requests, and the error handler is reachable from any
|
|
// thrown error in the stack above it.
|
|
this.#installTerminalMiddleware();
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Register every rate-limit backend whose dependency is available, so
|
|
* routes / drivers can mix and match per call. `config.rate_limit.backend`
|
|
* selects the _default_ applied when a caller doesn't specify a backend;
|
|
* it's no longer an exclusive choice. A typo or missing dependency for the
|
|
* chosen default falls back to memory with a warning so boot doesn't
|
|
* break.
|
|
*/
|
|
#configureRateLimiter() {
|
|
// Default to `redis` — the redis client is always present (falls
|
|
// back to ioredis-mock in dev when no nodes are configured), and
|
|
// sorted-set rate limiting scales across nodes for free. Set
|
|
// `rate_limit.backend` in config to switch to `memory` or `kv`.
|
|
const defaultBackend = this.#config.rate_limit?.backend ?? 'redis';
|
|
// Metering is wired so the concurrency gate can resolve
|
|
// `bySubscription` overrides per actor. Optional — without it,
|
|
// the base `limit` applies uniformly.
|
|
const metering = this.services?.metering as unknown;
|
|
try {
|
|
configureRateLimit({
|
|
default: defaultBackend,
|
|
redis: this.clients.redis,
|
|
kv: this.stores.kv,
|
|
metering,
|
|
});
|
|
} catch (e) {
|
|
console.warn(
|
|
`[rate-limit] default backend '${defaultBackend}' unavailable, falling back to memory:`,
|
|
(e as Error).message,
|
|
);
|
|
configureRateLimit({
|
|
redis: this.clients.redis,
|
|
kv: this.stores.kv,
|
|
metering,
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Install always-on middleware on the express app, in the order they must
|
|
* run at request time. Ordering note:
|
|
*
|
|
* - `express.json` must run before `authProbe` so `req.body.auth_token` is
|
|
* readable.
|
|
* - `authProbe` never rejects; it only populates `req.actor` if a valid token
|
|
* is present.
|
|
* - Per-route gate middleware (requireAuth, adminOnly, ...) lands in
|
|
* `#materializeRoute` as those options ship.
|
|
*/
|
|
#installGlobalMiddleware() {
|
|
// -- Egress metering -----------------------------------------
|
|
// First, so the byte counter wraps `res.write` before compression
|
|
// does and therefore counts what actually goes out rather than what
|
|
// the handler produced. Reads the actor when the response ends, by
|
|
// which point the auth probe below has run.
|
|
this.#app.use(
|
|
createEgressMeteringMiddleware({ services: this.services }),
|
|
);
|
|
|
|
this.#app.use(cookieParser());
|
|
|
|
this.#app.use(compression());
|
|
|
|
this.#app.use(helmet.noSniff());
|
|
this.#app.use(helmet.hsts());
|
|
this.#app.use(helmet.ieNoOpen());
|
|
this.#app.use(helmet.permittedCrossDomainPolicies());
|
|
this.#app.use(helmet.xssFilter());
|
|
// Don't leak full URLs (which can carry signed tokens / file paths)
|
|
// to cross-origin destinations. Per-user hosted sites tighten this
|
|
// further to `no-referrer` in the puterSite middleware.
|
|
this.#app.use(
|
|
helmet.referrerPolicy({
|
|
policy: 'strict-origin-when-cross-origin',
|
|
}),
|
|
);
|
|
this.#app.disable('x-powered-by');
|
|
|
|
// Cross-Origin-Resource-Policy: always allow cross-origin reads.
|
|
// The stricter COOP+COEP pair (for SharedArrayBuffer) is deferred
|
|
// until the hosting layer lands — it requires UA + context gating.
|
|
this.#app.use((_req, res, next) => {
|
|
res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
|
|
next();
|
|
});
|
|
|
|
// -- Query param sanitization --------------------------------
|
|
// Strip non-primitive query values. Express 5's default simple
|
|
// parser mostly avoids these, but when `extended` qs is enabled
|
|
// (or a client tricks the parser) arrays/objects can sneak in.
|
|
this.#app.use((req, _res, next) => {
|
|
if (req.query) {
|
|
const allowed = ['string', 'number', 'boolean'];
|
|
for (const k of Object.keys(req.query)) {
|
|
const v = req.query[k];
|
|
if (v != null && !allowed.includes(typeof v)) {
|
|
delete req.query[k];
|
|
}
|
|
}
|
|
}
|
|
next();
|
|
});
|
|
|
|
// -- UA parsing ----------------------------------------------
|
|
this.#app.use((req, _res, next) => {
|
|
const header = req.headers['user-agent'];
|
|
if (header) {
|
|
req.ua = uaParser(header);
|
|
}
|
|
next();
|
|
});
|
|
|
|
// -- Host header validation ----------------------------------
|
|
this.#installHostValidation();
|
|
|
|
// -- Host redirects (www → root, user subdomain → static hosting)
|
|
// Installed after host validation so we know the host is allowed,
|
|
// and before CORS/body-parsing so we short-circuit on redirects
|
|
// without burning work.
|
|
this.#app.use(createWwwRedirect(this.#config));
|
|
this.#app.use(createUserSubdomainRedirect(this.#config));
|
|
|
|
// -- Native app static serving (editor.*, docs.*, …) ---------
|
|
// No-op when `native_apps_root` is unset.
|
|
this.#app.use(createNativeAppStatic(this.#config));
|
|
|
|
// -- CORS headers --------------------------------------------
|
|
this.#installCors();
|
|
|
|
// -- IP validation -------------------------------------------
|
|
if (this.#config.enable_ip_validation) {
|
|
this.#installIpValidation();
|
|
}
|
|
|
|
// -- OPTIONS preflight ---------------------------------------
|
|
// WebDAV is exempt: OPTIONS is how a DAV client discovers the server,
|
|
// and the reply has to carry `DAV:` and `Allow:` for the mount to
|
|
// proceed. Answering it here with a bare 200 tells the client this
|
|
// isn't a WebDAV server at all, so let it fall through to the
|
|
// controller, which builds the real response.
|
|
this.#app.options('/*splat', (req, res, next) => {
|
|
if (activeSubdomain(req) === 'dav') {
|
|
next();
|
|
return;
|
|
}
|
|
res.sendStatus(200);
|
|
});
|
|
|
|
// -- Local Worker proxy (*.workers.puter.localhost) ----------
|
|
// Dev-only Miniflare dispatch, gated on `config.workers.localServer`.
|
|
// Mounted BEFORE body parsing so the Worker receives the raw request
|
|
// stream; no-op in production (real Cloudflare via WorkerDriver).
|
|
this.#app.use(
|
|
createLocalWorkerProxyMiddleware(this.#config, {
|
|
clients: this.clients,
|
|
stores: this.stores,
|
|
services: this.services,
|
|
}),
|
|
);
|
|
|
|
// -- Body parsing (JSON + text-as-json shim) -----------------
|
|
const captureRawBody: NonNullable<
|
|
Parameters<typeof express.json>[0]
|
|
>['verify'] = (req, _res, buf) => {
|
|
(req as { rawBody?: Buffer }).rawBody = Buffer.from(buf);
|
|
};
|
|
this.#app.use(express.json({ limit: '50mb', verify: captureRawBody }));
|
|
this.#app.use(
|
|
express.json({
|
|
limit: '50mb',
|
|
type: (req) =>
|
|
req.headers['content-type'] === 'text/plain;actually=json',
|
|
verify: captureRawBody,
|
|
}),
|
|
);
|
|
// Form-encoded bodies (e.g. `/down` from the GUI's iframe-triggered
|
|
// download form). Needs to run before the auth probe so
|
|
// `req.body.auth_token` is populated for urlencoded POSTs the same
|
|
// way it is for JSON POSTs. Small cap — this parser is only here to
|
|
// cover the auth_token / anti_csrf field shape, not file uploads.
|
|
this.#app.use(express.urlencoded({ extended: true, limit: '100kb' }));
|
|
|
|
// -- Auth probe ----------------------------------------------
|
|
const authService = this.services.auth as AuthService | undefined;
|
|
if (authService) {
|
|
this.#app.use(
|
|
createAuthProbe({
|
|
authService,
|
|
cookieName: this.#config.cookie_name,
|
|
}),
|
|
);
|
|
}
|
|
|
|
// -- Request fingerprints ------------------------------------
|
|
// Stamp `req.networkFingerprint` (server-derived) and
|
|
// `req.deviceFingerprint` (client-supplied, from body/header). Runs
|
|
// AFTER body parsing so the body fingerprint is readable, and before
|
|
// the ALS context so the snapshot carries them.
|
|
this.#app.use(createFingerprintMiddleware());
|
|
|
|
// -- Per-request ALS context ---------------------------------
|
|
// Runs AFTER auth probe so `req.actor` is already populated when
|
|
// we snapshot it into the context.
|
|
this.#app.use(createRequestContextMiddleware());
|
|
|
|
// -- User-hosted sites (*.puter.site, *.puter.app) -----------
|
|
// Short-circuits hosting-domain hosts before any API/GUI
|
|
// controller route has a chance to match. Needs DI layers for
|
|
// subdomain lookup, private-app gate, and file streaming.
|
|
this.#app.use(
|
|
createPuterSiteMiddleware(this.#config, {
|
|
clients: this.clients,
|
|
stores: this.stores,
|
|
services: this.services,
|
|
}),
|
|
);
|
|
|
|
extensionStore.globalMiddlewares.forEach((mw) => {
|
|
this.#app.use(mw);
|
|
});
|
|
}
|
|
|
|
// -- Host header validation --------------------------------------
|
|
|
|
#installHostValidation() {
|
|
const config = this.#config;
|
|
|
|
// Hostname missing — malformed request from a broken client.
|
|
this.#app.use((req, res, next) => {
|
|
if (req.hostname === undefined) {
|
|
res.status(400).send(
|
|
'Please verify your browser is up-to-date.',
|
|
);
|
|
return;
|
|
}
|
|
next();
|
|
});
|
|
|
|
// Build the allowed-domain set from config.
|
|
this.#app.use((req, res, next) => {
|
|
if (config.allow_all_host_values) {
|
|
next();
|
|
return;
|
|
}
|
|
|
|
if (!config.allow_no_host_header && !req.headers.host) {
|
|
res.status(400).send('Missing Host header.');
|
|
return;
|
|
}
|
|
|
|
// /healthcheck is always reachable regardless of host.
|
|
if (req.path === '/healthcheck') {
|
|
next();
|
|
return;
|
|
}
|
|
|
|
const hostName = (req.headers.host ?? '')
|
|
.split(':')[0]
|
|
.trim()
|
|
.toLowerCase();
|
|
const allowed = this.#getAllowedDomains();
|
|
|
|
if (
|
|
allowed.some((d) => PuterServer.#hostMatchesDomain(hostName, d))
|
|
) {
|
|
next();
|
|
return;
|
|
}
|
|
|
|
if (config.custom_domains_enabled) {
|
|
req.is_custom_domain = true;
|
|
next();
|
|
return;
|
|
}
|
|
|
|
res.status(400).send('Invalid Host header.');
|
|
});
|
|
}
|
|
|
|
#allowedDomainsCache: string[] | null = null;
|
|
|
|
#getAllowedDomains(): string[] {
|
|
if (this.#allowedDomainsCache) return this.#allowedDomainsCache;
|
|
const cfg = this.#config;
|
|
const raw = [
|
|
cfg.domain,
|
|
cfg.static_hosting_domain,
|
|
cfg.static_hosting_domain_alt,
|
|
cfg.private_app_hosting_domain,
|
|
cfg.private_app_hosting_domain_alt,
|
|
];
|
|
const staticDomain = PuterServer.#normalizeDomain(
|
|
cfg.static_hosting_domain,
|
|
);
|
|
if (staticDomain) raw.push(`at.${staticDomain}`);
|
|
if (cfg.allow_nipio_domains) raw.push('nip.io');
|
|
|
|
this.#allowedDomainsCache = raw
|
|
.map(PuterServer.#normalizeDomain)
|
|
.filter((d): d is string => d !== null);
|
|
return this.#allowedDomainsCache;
|
|
}
|
|
|
|
static #normalizeDomain(d: string | undefined | null): string | null {
|
|
if (!d || typeof d !== 'string') return null;
|
|
const trimmed = d.trim().toLowerCase();
|
|
return trimmed.length > 0 ? trimmed : null;
|
|
}
|
|
|
|
static #hostMatchesDomain(hostname: string, domain: string): boolean {
|
|
return hostname === domain || hostname.endsWith(`.${domain}`);
|
|
}
|
|
|
|
// -- CORS headers -------------------------------------------------
|
|
|
|
#installCors() {
|
|
const config = this.#config;
|
|
const allowedMethods =
|
|
'GET, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK';
|
|
const allowedHeaders = [
|
|
'Origin',
|
|
'X-Requested-With',
|
|
'Content-Type',
|
|
'Accept',
|
|
'Authorization',
|
|
'Cache-Control',
|
|
'Pragma',
|
|
'sentry-trace',
|
|
'baggage',
|
|
'Depth',
|
|
'Destination',
|
|
'Overwrite',
|
|
'If',
|
|
'Lock-Token',
|
|
'Timeout',
|
|
'X-Expected-Entity-Length',
|
|
'DAV',
|
|
'stripe-signature',
|
|
].join(', ');
|
|
|
|
// What a browser DAV client is allowed to read back off a response.
|
|
// Everything below is a header the WebDAV controller sets and a client
|
|
// acts on: without this list `fetch` hands the page a response whose
|
|
// ETag, lock token and content range are all invisible, so it can't
|
|
// cache, lock, or resume anything.
|
|
const davExposedHeaders = [
|
|
'DAV',
|
|
'MS-Author-Via',
|
|
'Allow',
|
|
'ETag',
|
|
'Last-Modified',
|
|
'Content-Length',
|
|
'Content-Range',
|
|
'Accept-Ranges',
|
|
'Location',
|
|
'Lock-Token',
|
|
'WWW-Authenticate',
|
|
].join(', ');
|
|
|
|
this.#app.use((req, res, next) => {
|
|
const origin = req.headers.origin;
|
|
const subdomain = activeSubdomain(req);
|
|
|
|
// Allow any origin. puter.js is meant to be consumed from
|
|
// arbitrary third-party sites, so reflect the caller's origin
|
|
// (or fall back to `*` for non-browser clients).
|
|
res.setHeader('Access-Control-Allow-Origin', origin ?? '*');
|
|
if (origin) res.vary('Origin');
|
|
|
|
// Sticky cookies require api to allow credentials, but only for the API subdomain, and be careful not to set any other credentials on it
|
|
if (subdomain === 'api' && origin) {
|
|
res.setHeader('Access-Control-Allow-Credentials', 'true');
|
|
} else if (subdomain === 'dav') {
|
|
res.setHeader('Access-Control-Allow-Credentials', 'false');
|
|
res.setHeader(
|
|
'Access-Control-Expose-Headers',
|
|
davExposedHeaders,
|
|
);
|
|
}
|
|
|
|
res.setHeader('Access-Control-Allow-Methods', allowedMethods);
|
|
res.setHeader('Access-Control-Allow-Headers', allowedHeaders);
|
|
|
|
res.setHeader('Access-Control-Allow-Private-Network', 'true');
|
|
|
|
// Disable iframes on the main domain
|
|
if (req.hostname === config.domain) {
|
|
res.setHeader('X-Frame-Options', 'SAMEORIGIN');
|
|
}
|
|
|
|
next();
|
|
});
|
|
}
|
|
|
|
// -- IP validation -----------------------------------------------
|
|
|
|
#installIpValidation() {
|
|
this.#app.use(async (req, res, next) => {
|
|
// `req.ip` reflects `trust proxy`: it's the leftmost untrusted
|
|
// address from XFF when behind a configured proxy chain, and the
|
|
// direct socket peer otherwise. Reading XFF directly would let a
|
|
// client forge the value when traffic isn't behind the expected
|
|
// proxy.
|
|
const ip = req.ip;
|
|
const event = { allow: true, ip: ip! };
|
|
// emitAndWait so listeners that do async work (IP-reputation
|
|
// lookups, Redis checks) can complete before we read
|
|
// `event.allow` and decide the gate.
|
|
await this.clients.event.emitAndWait('ip.validate', event, {});
|
|
if (!event.allow) {
|
|
res.status(403).send('Forbidden');
|
|
return;
|
|
}
|
|
next();
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Install end-of-pipeline middleware. Order matters:
|
|
*
|
|
* 1. The 404 catch-all runs only when no earlier route matched, so it must be
|
|
* installed _after_ every controller + extension route.
|
|
* 2. The error handler is the express terminal — it catches everything thrown
|
|
* by routes, gates, and the 404 above. Express 5 auto-forwards thrown
|
|
* errors (sync and async), so handlers can `throw new HttpError(...)`
|
|
* without `next(err)` ceremony.
|
|
*/
|
|
#installTerminalMiddleware() {
|
|
this.#app.use(
|
|
createNotFoundHandler({ guiDomain: this.#config.domain }),
|
|
);
|
|
this.#app.use(
|
|
createErrorHandler({
|
|
onError: (err, req) => {
|
|
// Page on 5xx only — skip 4xx HttpErrors, which are
|
|
// expected client-caused failures. Non-HttpError values
|
|
// are treated as unexpected 500s. De-dupe alarms by
|
|
// route + error signature so a hot loop of the same
|
|
// crash lands as a single alarm with N occurrences
|
|
// instead of N pages.
|
|
//
|
|
// FORCED_ALERT_CODES override the 5xx-only rule: a
|
|
// status < 500 still alarms if its legacyCode is in
|
|
// the map. Use this for things we want to know about
|
|
// even though we expose them as 4xx to users (e.g.
|
|
// sustained upstream provider rate limits). They are
|
|
// not our own crashes, so each maps to the severity it
|
|
// deserves rather than paging.
|
|
//
|
|
// SKIP_ALERT_PREFIXES override the 5xx rule the other
|
|
// direction: an error tagged as caused by an upstream
|
|
// provider or a misbehaving client gets exposed to
|
|
// the user but does not alarm at all.
|
|
//
|
|
// `noAlarm` beats both: the call site already decided
|
|
// this failure isn't worth recording (e.g. an upstream
|
|
// rate limit on a free model, where the volume tracks
|
|
// traffic and there's nothing to act on).
|
|
const FORCED_ALERT_CODES = new Map<string, PagerSeverity>([
|
|
['upstream_rate_limited', 'info'],
|
|
// Our credentials for a provider stopped working —
|
|
// everything through it fails until someone looks.
|
|
['upstream_auth_failed', 'warning'],
|
|
]);
|
|
const SKIP_ALERT_PREFIXES = /^(upstream_|client_)/;
|
|
const isHttp = isHttpError(err);
|
|
if (isHttp && err.noAlarm) return;
|
|
const status = isHttp ? err.statusCode : 500;
|
|
const legacyCode = isHttp ? (err.legacyCode ?? '') : '';
|
|
const forcedSeverity = FORCED_ALERT_CODES.get(legacyCode);
|
|
if (!forcedSeverity) {
|
|
if (status < 500) return;
|
|
if (SKIP_ALERT_PREFIXES.test(legacyCode)) return;
|
|
}
|
|
const signature = !isHttp
|
|
? err instanceof Error
|
|
? err.message
|
|
: String(err)
|
|
: status >= 500
|
|
? `${err.legacyCode || err.code || 'http'}:${err.message}`
|
|
: err.legacyCode || err.code || err.message;
|
|
const routePath =
|
|
(req as unknown as { route?: { path?: string } }).route
|
|
?.path ?? req.path;
|
|
const alarmId = `http_${status}:${req.method}:${routePath}:${signature}`;
|
|
this.clients.alarm.create(
|
|
alarmId,
|
|
`HTTP ${status} on ${req.method} ${req.originalUrl}: ${signature}`,
|
|
{
|
|
error: err instanceof Error ? err : undefined,
|
|
status,
|
|
method: req.method,
|
|
path: req.originalUrl,
|
|
body: req.body,
|
|
route: routePath,
|
|
actor: req.actor,
|
|
},
|
|
// An unhandled server error is the one thing that
|
|
// still pages on-call.
|
|
forcedSeverity ?? 'critical',
|
|
// The id pins route + error signature, so repeats are
|
|
// the same fault and belong on one incident.
|
|
{ dedup: true },
|
|
);
|
|
},
|
|
}),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Walk a controller's declared routes (via `PuterRouter`) and register each
|
|
* one against the underlying express app. Per-route option → middleware
|
|
* translation lives here — when we add auth/subdomain/body-parsing options,
|
|
* they get wired in at this single point without touching any controller
|
|
* call site.
|
|
*/
|
|
#registerControllerRoutes(
|
|
controllerName: string,
|
|
controller: WithControllerRegistration,
|
|
) {
|
|
if (!controller.registerRoutes) {
|
|
throw new Error(
|
|
`Controller ${controllerName} does not have registerRoutes method`,
|
|
);
|
|
}
|
|
|
|
// Controllers annotated with `@Controller('/prefix')` carry the prefix
|
|
// on their prototype; bare (imperative) controllers default to ''.
|
|
const prefix = (controller as unknown as Record<string, unknown>)[
|
|
PREFIX_METADATA_KEY
|
|
] as string | undefined;
|
|
const router = new PuterRouter(prefix ?? '');
|
|
controller.registerRoutes(router);
|
|
|
|
for (const route of router.routes) {
|
|
this.#materializeRoute(this.#app, router.prefix, route);
|
|
}
|
|
}
|
|
|
|
#materializeRoute(
|
|
app: Application,
|
|
routerPrefix: string,
|
|
route: RouteDescriptor,
|
|
) {
|
|
const mwChain: RequestHandler[] = [];
|
|
const opts = route.options;
|
|
|
|
// Validated here rather than trusted, and by the same function the
|
|
// driver decorator uses: a malformed requirement is a boot failure
|
|
// naming the route, never a gate that quietly admits everyone.
|
|
const subscriptionRequirement =
|
|
opts.requireSubscription === undefined
|
|
? undefined
|
|
: validateSubscriptionRequirement(
|
|
opts.requireSubscription,
|
|
`route ${route.method.toUpperCase()} ${routerPrefix}${String(route.path)}: requireSubscription`,
|
|
);
|
|
const requiresSubscription =
|
|
subscriptionRequirement !== undefined &&
|
|
subscriptionRequirement !== false;
|
|
|
|
// 1. Subdomain routing. Routes that specify `subdomain` only match
|
|
// that subdomain(s). Routes WITHOUT a `subdomain` option (and that
|
|
// aren't `use` middleware) are restricted to the root origin — this
|
|
// prevents API-subdomain requests from accidentally hitting a root-
|
|
// only route. Explicit `subdomain: '*'` disables the gate entirely.
|
|
//
|
|
// For `use` routes, `next('route')` in a middleware doesn't skip the
|
|
// handler (that's only reliable inside `app.METHOD`/`router.METHOD`
|
|
// chains). We handle subdomain gating by wrapping the handler for
|
|
// `use` routes further down — don't push `subdomainGate` here.
|
|
const isUse = route.method === 'use';
|
|
if (opts.subdomain !== undefined) {
|
|
if (opts.subdomain !== '*' && !isUse) {
|
|
mwChain.push(subdomainGate(opts.subdomain));
|
|
}
|
|
// subdomain: '*' → no gate, match any subdomain
|
|
} else if (!isUse) {
|
|
// No subdomain specified + not a `use()` middleware → root only.
|
|
// Root = no subdomain present (req.subdomains is empty).
|
|
mwChain.push((req, _res, next) => {
|
|
if (req.subdomains && req.subdomains.length > 0) {
|
|
next('route');
|
|
return;
|
|
}
|
|
next();
|
|
});
|
|
}
|
|
|
|
// 1b. Origin gate. Runs before auth, rate limiting, and captcha so an
|
|
// off-origin caller is rejected on the header alone — it never reaches
|
|
// the credential comparison, and it can't burn another request's rate
|
|
// limit budget on the way. Unauthenticated by nature: the routes that
|
|
// opt in are the ones that *hand out* a credential.
|
|
if (opts.guiOriginOnly) {
|
|
mwChain.push(guiOriginGate(this.#config));
|
|
}
|
|
|
|
// 2. Auth gates. Implication graph:
|
|
// adminOnly => requireAuth
|
|
// allowedAppIds => requireAuth
|
|
// requireUserActor => requireAuth
|
|
// Dedupe: only push requireAuthGate once when *any* of these are set.
|
|
const needsAuth = Boolean(
|
|
opts.requireAuth ||
|
|
opts.requireUserActor ||
|
|
opts.adminOnly ||
|
|
opts.allowedAppIds ||
|
|
opts.requireVerified ||
|
|
opts.noUserSession ||
|
|
opts.requirePhoneVerified ||
|
|
opts.requireCardVerified ||
|
|
requiresSubscription,
|
|
);
|
|
if (needsAuth) {
|
|
mwChain.push(requireAuthGate());
|
|
}
|
|
|
|
// Default-on account-verification gate. Every authenticated route
|
|
// rejects accounts still pending any signup-time verification —
|
|
// email confirmation, SMS phone verification, or card verification —
|
|
// unless `allowUnconfirmed` opts out. This is what keeps low-reputation
|
|
// signups (which the abuse harness flags instead of hard-blocking) out
|
|
// of AI, FS, driver, etc. endpoints server-side, not just behind the
|
|
// GUI modal, while still allowing essential flows (logout,
|
|
// confirm-email / -phone, card verification, whoami, save-account, …).
|
|
if (needsAuth && !opts.allowUnconfirmed) {
|
|
mwChain.push(requireVerifiedAccount());
|
|
}
|
|
|
|
// block access tokens by default
|
|
if (needsAuth && !opts.allowAccessToken) {
|
|
mwChain.push(requireNonAccessTokenGate());
|
|
}
|
|
|
|
// `requireVerified` intentionally does NOT imply `requireUserActor`:
|
|
// FS routes (and similar) want the user's email to be confirmed even
|
|
// when an app acts on the user's behalf. `requireVerifiedGate` reads
|
|
// `req.actor?.user?.email_confirmed`, which app-under-user actors
|
|
// carry, so it works for either actor shape.
|
|
//
|
|
// `adminOnly` also does NOT imply `requireUserActor`: admin endpoints
|
|
// stay callable from scripts/automation using an admin's full-access
|
|
// token, not only from browser sessions — both are root tokens.
|
|
// Beyond the username check, `adminOnlyGate` requires a root token
|
|
// (rejecting an admin acting through a third-party app) unless the
|
|
// route is also appId-gated, in which case `allowedAppIdsGate` governs
|
|
// which apps may pass.
|
|
if (opts.requireUserActor) {
|
|
mwChain.push(
|
|
requireUserActorGate({
|
|
allowFullAccess: opts.allowFullAccessToken,
|
|
}),
|
|
);
|
|
}
|
|
|
|
// Bare user-session ("root" token) rejection. Runs after
|
|
// `requireUserActor` so that on routes combining both, an app is
|
|
// rejected with the user-actor message and only a bare session gets
|
|
// the "use an app or API token" message.
|
|
if (opts.noUserSession) {
|
|
mwChain.push(noUserSessionGate());
|
|
}
|
|
|
|
if (opts.adminOnly) {
|
|
const extras = Array.isArray(opts.adminOnly) ? opts.adminOnly : [];
|
|
mwChain.push(
|
|
adminOnlyGate(extras, {
|
|
appGated: Boolean(opts.allowedAppIds),
|
|
}),
|
|
);
|
|
// An admin username on a leaked session isn't enough — also require
|
|
// a recent re-authentication. Exempt only a token that carries one
|
|
// of the route's allowlisted app ids: an admin acting through an
|
|
// allowlisted app can't elevate (apps have no password/TOTP; see
|
|
// createStepUpGate). A root/human session — no app id in the token —
|
|
// still requires step-up, and `allowedAppIdsGate` still enforces the
|
|
// allowlist for the app path.
|
|
mwChain.push(
|
|
createStepUpGate({
|
|
tokenService: this.services.token,
|
|
allowedAppUids: opts.allowedAppIds,
|
|
}),
|
|
);
|
|
}
|
|
|
|
if (opts.allowedAppIds) {
|
|
mwChain.push(allowedAppIdsGate(opts.allowedAppIds));
|
|
}
|
|
|
|
// 2a. Email verification. Keyed off `strict_email_verification_required`
|
|
// so self-hosted boxes without SMTP don't break every fs route.
|
|
if (opts.requireVerified) {
|
|
mwChain.push(
|
|
requireVerifiedGate(
|
|
Boolean(this.#config.strict_email_verification_required),
|
|
),
|
|
);
|
|
}
|
|
|
|
// 2a'. Per-factor verification gates. Opt-in, and independent of the
|
|
// default-on pending-verification gate above: these require the factor
|
|
// to have actually been verified, for routes worth that friction.
|
|
if (opts.requirePhoneVerified) {
|
|
mwChain.push(requirePhoneVerifiedGate());
|
|
}
|
|
if (opts.requireCardVerified) {
|
|
mwChain.push(requireCardVerifiedGate());
|
|
}
|
|
|
|
// 2a''. Plan enforcement. Before the rate limit — the same order the
|
|
// driver dispatch path uses — so an account on a plan that never
|
|
// included this route is told to upgrade rather than to slow down,
|
|
// and doesn't spend a rate-limit token on a request that can never
|
|
// pass. Also before the budget gate: "your plan doesn't include this"
|
|
// beats "you're out of credits" that a top-up wouldn't fix.
|
|
if (requiresSubscription) {
|
|
mwChain.push(
|
|
requireSubscriptionGate(
|
|
this.services.metering,
|
|
this.#config,
|
|
subscriptionRequirement!,
|
|
),
|
|
);
|
|
}
|
|
|
|
// 2b. Rate limiting. Runs after auth so 'user' key strategy
|
|
// has access to req.actor. An array applies each limit as its
|
|
// own gate — a request must pass all of them.
|
|
if (opts.rateLimit) {
|
|
const limits = Array.isArray(opts.rateLimit)
|
|
? opts.rateLimit
|
|
: [opts.rateLimit];
|
|
for (const rl of limits) {
|
|
mwChain.push(rateLimitGate(rl) as unknown as RequestHandler);
|
|
}
|
|
}
|
|
|
|
// 2b''. Budget enforcement. After the rate limit so a caller over
|
|
// both gets the cheaper, more specific answer, and before the
|
|
// concurrency slot so a rejected request never takes one. Answered
|
|
// from the metering service's per-actor cache, so ordering it here
|
|
// costs a map lookup rather than a store read.
|
|
if (opts.requireCredits) {
|
|
mwChain.push(
|
|
requireCreditsGate(this.services.metering, this.#config),
|
|
);
|
|
}
|
|
|
|
// 2b'. Concurrent in-flight limiting. Same auth-ordering reason
|
|
// (user key + bySubscription resolution needs req.actor); installed
|
|
// after rateLimitGate so a rate-rejection short-circuits before
|
|
// we acquire a concurrency slot. Slot is released on res finish/close.
|
|
if (opts.concurrent) {
|
|
mwChain.push(
|
|
concurrencyGate(opts.concurrent) as unknown as RequestHandler,
|
|
);
|
|
}
|
|
|
|
// 2c. Captcha verification. Reads captchaToken + captchaAnswer
|
|
// from req.body — body is already parsed by the global JSON
|
|
// middleware at this point.
|
|
if (opts.captcha) {
|
|
const enabled = Boolean(this.#config.captcha?.enabled);
|
|
mwChain.push(captchaGate(enabled) as unknown as RequestHandler);
|
|
}
|
|
|
|
// 2d. Anti-CSRF token consumption.
|
|
if (opts.antiCsrf) {
|
|
mwChain.push(requireAntiCsrf() as unknown as RequestHandler);
|
|
}
|
|
|
|
// 3. Per-route body parsers. Each is a no-op when the request's
|
|
// content-type doesn't match — multiple can coexist. The global
|
|
// `application/json` parser already ran in `#installGlobalMiddleware`,
|
|
// so by default the only reason to opt into one of these is to handle
|
|
// a non-JSON body shape (raw bytes, plain text, urlencoded form) or
|
|
// to override JSON limits on a hot path.
|
|
// bodyJson is `false | { limit?, type? }`. Truthiness check excludes
|
|
// both `undefined` (no opt) and `false` (explicit opt-out).
|
|
if (opts.bodyJson) {
|
|
mwChain.push(
|
|
express.json({
|
|
limit: opts.bodyJson.limit,
|
|
type: opts.bodyJson.type,
|
|
}),
|
|
);
|
|
}
|
|
|
|
if (opts.bodyRaw) {
|
|
const raw = opts.bodyRaw === true ? {} : opts.bodyRaw;
|
|
mwChain.push(
|
|
express.raw({
|
|
limit: raw.limit,
|
|
type: raw.type,
|
|
}),
|
|
);
|
|
}
|
|
|
|
if (opts.bodyText) {
|
|
const text = opts.bodyText === true ? {} : opts.bodyText;
|
|
mwChain.push(
|
|
express.text({
|
|
limit: text.limit,
|
|
type: text.type,
|
|
}),
|
|
);
|
|
}
|
|
|
|
if (opts.bodyUrlencoded) {
|
|
const ue = opts.bodyUrlencoded === true ? {} : opts.bodyUrlencoded;
|
|
mwChain.push(
|
|
express.urlencoded({
|
|
limit: ue.limit,
|
|
extended: ue.extended ?? true,
|
|
}),
|
|
);
|
|
}
|
|
|
|
// 4. Caller-supplied middleware runs after gates + parsers, before the handler.
|
|
if (opts.middleware) mwChain.push(...opts.middleware);
|
|
|
|
const fullPath =
|
|
route.path !== undefined
|
|
? PuterServer.#joinPath(routerPrefix, route.path)
|
|
: undefined;
|
|
|
|
// 5. Per-endpoint lifecycle events. Skipped for `use` middleware
|
|
// (those aren't endpoints). Pushed last so the `before` hook sees a
|
|
// fully-authenticated request, and only when the event client is
|
|
// wired (minimal test harnesses may omit it).
|
|
if (route.method !== 'use' && this.clients.event) {
|
|
mwChain.push(
|
|
createRouteLifecycleMiddleware(
|
|
this.clients.event,
|
|
route.method,
|
|
fullPath,
|
|
),
|
|
);
|
|
}
|
|
|
|
if (route.method === 'use') {
|
|
// Subdomain check for `use` middleware lives INSIDE the handler
|
|
// wrapper — `next('route')` from a stand-alone subdomainGate
|
|
// doesn't reliably skip a `use` handler in Express 5.
|
|
let handler = route.handler;
|
|
if (opts.subdomain !== undefined && opts.subdomain !== '*') {
|
|
const allowList = Array.isArray(opts.subdomain)
|
|
? opts.subdomain
|
|
: [opts.subdomain];
|
|
const original = handler;
|
|
handler = (req, res, next) => {
|
|
const active =
|
|
req.subdomains?.[req.subdomains.length - 1] ?? '';
|
|
if (!allowList.includes(active)) return next();
|
|
return original(req, res, next);
|
|
};
|
|
}
|
|
if (fullPath !== undefined) {
|
|
app.use(fullPath as any, ...mwChain.flat(), handler);
|
|
} else {
|
|
app.use(...mwChain.flat(), handler);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (fullPath === undefined) {
|
|
throw new Error(`Route method '${route.method}' requires a path`);
|
|
}
|
|
|
|
// All express + WebDAV verbs accept the same (path, ...handlers) shape.
|
|
// The `RouteMethod` union is the allowlist of method names we expose.
|
|
const method = app[route.method as keyof Application] as unknown;
|
|
if (typeof method !== 'function') {
|
|
throw new Error(
|
|
`Express app does not support method: ${route.method}`,
|
|
);
|
|
}
|
|
(method as (...args: unknown[]) => unknown).call(
|
|
app,
|
|
fullPath,
|
|
...mwChain,
|
|
route.handler,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Join a controller's prefix with a route path. RegExp / array paths are
|
|
* passed through unprefixed (consistent with express's behavior; decorator
|
|
* paths are assumed to be strings).
|
|
*/
|
|
static #joinPath(
|
|
prefix: string,
|
|
path: NonNullable<RouteDescriptor['path']>,
|
|
): string | RegExp | Array<string | RegExp> {
|
|
if (typeof path !== 'string') return path;
|
|
if (!prefix) return path;
|
|
return `${prefix}/${path}`.replace(/\/+/g, '/');
|
|
}
|
|
|
|
async #importExtensions(extensionDirs: string[]) {
|
|
for (const extDir of extensionDirs) {
|
|
// `withFileTypes: true` gives us `Dirent` objects so we can
|
|
// distinguish files from directories without extra stat calls
|
|
// (and without relying on a dot-in-name heuristic, which breaks
|
|
// for data-bearing sidecar dirs like `pages.assets/`).
|
|
for (const entry of readdirSync(extDir, { withFileTypes: true })) {
|
|
const entryPath = `${extDir}/${entry.name}`;
|
|
|
|
if (entry.isFile()) {
|
|
const name = entry.name;
|
|
let shouldImport: boolean;
|
|
if (this.#config.import_ts_extensions) {
|
|
// Extensions ship as compiled .js at runtime, but
|
|
// transform-capable runtimes (the test harness)
|
|
// import the .ts sources directly. Skip tests and
|
|
// declarations, and skip built .js siblings of a
|
|
// .ts source so a previously-built tree doesn't
|
|
// double-register.
|
|
if (name.endsWith('.ts')) {
|
|
shouldImport =
|
|
!name.endsWith('.test.ts') &&
|
|
!name.endsWith('.d.ts');
|
|
} else {
|
|
shouldImport =
|
|
/\.(js|mjs|cjs)$/.test(name) &&
|
|
!/\.test\.(js|mjs|cjs)$/.test(name) &&
|
|
!existsSync(
|
|
entryPath.replace(/\.(js|mjs|cjs)$/, '.ts'),
|
|
);
|
|
}
|
|
} else {
|
|
shouldImport = /\.(js|mjs|cjs)$/.test(name);
|
|
}
|
|
if (shouldImport) {
|
|
console.log(`Importing extension file ${entryPath}`);
|
|
await import(pathToFileURL(entryPath).href);
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (!entry.isDirectory()) continue; // symlinks, etc. — skip
|
|
|
|
// Prefer package.json "main"; fall back to index.{js,mjs,cjs}.
|
|
// Dirs that match neither (e.g. data-only sidecars) are
|
|
// silently ignored rather than crashing the boot.
|
|
let mainPath: string | null = null;
|
|
const pkgPath = `${entryPath}/package.json`;
|
|
if (existsSync(pkgPath)) {
|
|
try {
|
|
const pkg = JSON.parse(
|
|
readFileSync(pkgPath, 'utf-8'),
|
|
) as { main?: string };
|
|
if (pkg.main) mainPath = `${entryPath}/${pkg.main}`;
|
|
} catch (e) {
|
|
console.warn(
|
|
`[extensions] invalid package.json at ${pkgPath}:`,
|
|
e,
|
|
);
|
|
continue;
|
|
}
|
|
}
|
|
if (!mainPath) {
|
|
for (const cand of ['index.js', 'index.mjs', 'index.cjs']) {
|
|
if (existsSync(`${entryPath}/${cand}`)) {
|
|
mainPath = `${entryPath}/${cand}`;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
if (!mainPath) continue;
|
|
|
|
console.log(`Importing extension file ${mainPath}`);
|
|
await import(pathToFileURL(mainPath).href);
|
|
}
|
|
}
|
|
}
|
|
|
|
async start(noHttpServer = false) {
|
|
await this.#ready;
|
|
|
|
// Installed before anything starts serving, so a fault during boot is
|
|
// reported too. Logging is unconditional; whether an uncaught exception
|
|
// ends the process is a deployment decision, hence the config gate.
|
|
this.#removeProcessGuards = installProcessGuards({
|
|
keepAliveOnUncaught: this.#config.keep_alive_on_uncaught ?? false,
|
|
});
|
|
|
|
// Create the http server explicitly (instead of `app.listen()`) so we
|
|
// have the server reference BEFORE listen starts — anything that needs
|
|
// to hook into the raw server (socket.io upgrades, WebSockets, …) runs
|
|
// its `attachHttpServer(server)` here, pre-listen.
|
|
const httpServer = http.createServer(this.#app);
|
|
for (const service of Object.values(this.services) as Array<
|
|
WithLifecycle & {
|
|
attachHttpServer?: (s: http.Server) => void | Promise<void>;
|
|
}
|
|
>) {
|
|
if (typeof service.attachHttpServer === 'function') {
|
|
await service.attachHttpServer(httpServer);
|
|
}
|
|
}
|
|
|
|
if (!noHttpServer) {
|
|
// Await 'listening' (and full boot below) so callers can rely on
|
|
// the server being reachable once start() resolves — test
|
|
// harnesses connect real clients immediately after.
|
|
this.#server = httpServer.listen(this.#config.port);
|
|
await new Promise<void>((resolve, reject) => {
|
|
const onError = (err: Error) => reject(err);
|
|
httpServer.once('error', onError);
|
|
httpServer.once('listening', () => {
|
|
// Detach so post-boot 'error' events aren't swallowed
|
|
// by a no-op reject on this settled promise.
|
|
httpServer.removeListener('error', onError);
|
|
resolve();
|
|
});
|
|
});
|
|
|
|
const cfg = this.#config;
|
|
const liveUrl =
|
|
cfg.origin ??
|
|
`${cfg.protocol ?? 'http'}://${cfg.domain ?? 'localhost'}:${this.#config.port}`;
|
|
console.log(
|
|
'\n************************************************************',
|
|
);
|
|
console.log(`* Puter is now live at: ${liveUrl}`);
|
|
console.log(
|
|
'************************************************************\n',
|
|
);
|
|
|
|
await this.#fireOnServerStart();
|
|
console.log('PuterServer has fully booted.');
|
|
|
|
// CLI: `--server` (optionally `--puter-backend=<gui-origin>`)
|
|
// runs the AuthMe flow against a remote Puter (default
|
|
// puter.com), then opens the local GUI already logged in and
|
|
// pointed at that backend. Restores the v1 WebServerService
|
|
// `--server` behavior; works in any env. When set, it takes
|
|
// over browser launch so we don't also open a plain tab.
|
|
const { values: cliArgs } = parseArgs({
|
|
args: process.argv.slice(2),
|
|
options: {
|
|
server: { type: 'boolean' },
|
|
'puter-backend': { type: 'string' },
|
|
},
|
|
strict: false,
|
|
});
|
|
|
|
if (cliArgs.server) {
|
|
try {
|
|
// tools/auth_gui.js is not compiled into dist/, so
|
|
// resolve it from the package root (cwd, per the
|
|
// `start` script) rather than relative to this module.
|
|
const authGuiUrl = pathToFileURL(
|
|
path.resolve(process.cwd(), 'tools/auth_gui.js'),
|
|
).href;
|
|
const authGui = (await import(authGuiUrl)).default;
|
|
await authGui(
|
|
cliArgs['puter-backend'] as string | undefined,
|
|
);
|
|
} catch (e) {
|
|
console.log(
|
|
'[server] could not start AuthMe browser flow:',
|
|
(e as Error).message,
|
|
);
|
|
}
|
|
} else if (this.#config.env === 'dev' && !cfg.no_browser_launch) {
|
|
// Auto-launch the browser on dev boot (matches v1
|
|
// WebServerService). Opt out via `no_browser_launch: true`.
|
|
try {
|
|
const openModule = await import('open');
|
|
await openModule.default(liveUrl);
|
|
} catch (e) {
|
|
console.log(
|
|
'[server] could not auto-open browser:',
|
|
(e as Error).message,
|
|
);
|
|
}
|
|
}
|
|
} else {
|
|
this.#server = {
|
|
close: (cb: (error?: Error) => void | undefined) => {
|
|
console.debug('PuterServer mock close called');
|
|
cb?.();
|
|
},
|
|
closeAllConnections: () => {
|
|
console.debug(
|
|
'PuterServer mock closeAllConnections called',
|
|
);
|
|
},
|
|
} as unknown as http.Server;
|
|
// Tests still need onServerStart to fire so stores can
|
|
// bootstrap (e.g. SystemKVStore creates its dynalite table).
|
|
await this.#fireOnServerStart();
|
|
}
|
|
}
|
|
|
|
async #fireOnServerStart() {
|
|
for (const client of Object.values(this.clients) as WithLifecycle[]) {
|
|
if (client.onServerStart) await client.onServerStart();
|
|
}
|
|
for (const store of Object.values(this.stores) as WithLifecycle[]) {
|
|
if (store.onServerStart) await store.onServerStart();
|
|
}
|
|
for (const service of Object.values(this.services) as WithLifecycle[]) {
|
|
if (service.onServerStart) await service.onServerStart();
|
|
}
|
|
for (const controller of Object.values(
|
|
this.controllers,
|
|
) as WithLifecycle[]) {
|
|
if (controller.onServerStart) await controller.onServerStart();
|
|
}
|
|
for (const driver of Object.values(this.drivers) as WithLifecycle[]) {
|
|
if (driver.onServerStart) await driver.onServerStart();
|
|
}
|
|
}
|
|
|
|
#prepareShutdownHooksRan = false;
|
|
|
|
/**
|
|
* Run every layer's `onServerPrepareShutdown` exactly once, whichever of
|
|
* `prepareShutdown()` / `shutdown()` gets there first.
|
|
*/
|
|
async #runPrepareShutdownHooks() {
|
|
if (this.#prepareShutdownHooksRan) return;
|
|
this.#prepareShutdownHooksRan = true;
|
|
for (const client of Object.values(this.clients) as WithLifecycle[]) {
|
|
if (client.onServerPrepareShutdown) {
|
|
await client.onServerPrepareShutdown();
|
|
}
|
|
}
|
|
for (const store of Object.values(this.stores) as WithLifecycle[]) {
|
|
if (store.onServerPrepareShutdown) {
|
|
await store.onServerPrepareShutdown();
|
|
}
|
|
}
|
|
for (const service of Object.values(this.services) as WithLifecycle[]) {
|
|
if (service.onServerPrepareShutdown) {
|
|
await service.onServerPrepareShutdown();
|
|
}
|
|
}
|
|
for (const controller of Object.values(
|
|
this.controllers,
|
|
) as WithLifecycle[]) {
|
|
if (controller.onServerPrepareShutdown) {
|
|
await controller.onServerPrepareShutdown();
|
|
}
|
|
}
|
|
for (const driver of Object.values(this.drivers) as WithLifecycle[]) {
|
|
if (driver.onServerPrepareShutdown) {
|
|
await driver.onServerPrepareShutdown();
|
|
}
|
|
}
|
|
}
|
|
|
|
async prepareShutdown() {
|
|
if (this.#server) {
|
|
this.#server.close(async () => {
|
|
console.log(
|
|
'PuterServer has stopped accepting new connections',
|
|
);
|
|
await this.#runPrepareShutdownHooks();
|
|
});
|
|
}
|
|
}
|
|
|
|
async shutdown() {
|
|
this.#removeProcessGuards?.();
|
|
this.#removeProcessGuards = null;
|
|
if (this.#server) {
|
|
console.log('PuterServer is shutting down');
|
|
// Prepare hooks come first: SocketService's hook closes
|
|
// socket.io, disconnecting upgraded websocket connections that
|
|
// `closeAllConnections()` does not cover — without this,
|
|
// `close()` waits forever on any connected socket.io client.
|
|
await this.#runPrepareShutdownHooks();
|
|
// Stop accepting new connections, then sever live ones; the
|
|
// close callback fires once the listener is fully released.
|
|
const closed = new Promise<void>((resolve) => {
|
|
this.#server!.close(() => resolve());
|
|
});
|
|
this.#server.closeAllConnections();
|
|
await closed;
|
|
for (const client of Object.values(
|
|
this.clients,
|
|
) as WithLifecycle[]) {
|
|
if (client.onServerShutdown) {
|
|
await client.onServerShutdown();
|
|
}
|
|
}
|
|
for (const store of Object.values(this.stores) as WithLifecycle[]) {
|
|
if (store.onServerShutdown) {
|
|
await store.onServerShutdown();
|
|
}
|
|
}
|
|
for (const service of Object.values(
|
|
this.services,
|
|
) as WithLifecycle[]) {
|
|
if (service.onServerShutdown) {
|
|
await service.onServerShutdown();
|
|
}
|
|
}
|
|
for (const controller of Object.values(
|
|
this.controllers,
|
|
) as WithLifecycle[]) {
|
|
if (controller.onServerShutdown) {
|
|
await controller.onServerShutdown();
|
|
}
|
|
}
|
|
for (const driver of Object.values(
|
|
this.drivers,
|
|
) as WithLifecycle[]) {
|
|
if (driver.onServerShutdown) {
|
|
await driver.onServerShutdown();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|