/*
* 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 { HttpError } from '../../core/http/HttpError.js';
import { PuterController } from '../types.js';
/**
* System-level endpoints — health, version, contact.
*
* These are all low-risk, authenticated or not, and mostly stateless.
*/
/**
* Liveness polling. The callers here are infrastructure, not people: a load
* balancer, an orchestrator and any external uptime prober all poll this, and
* they typically egress from a small set of addresses. A 429 here is read as an
* unhealthy node and takes the node out of rotation, so the ceiling is set
* where only a runaway loop can reach it. The handler itself reads a status
* snapshot refreshed on a background timer, so the per-request cost is close to
* nil.
*
* `memory` rather than the shared default, and that choice is load-bearing:
*
* - This route decides whether a node stays in rotation, so it must not depend on
* anything it isn't already reporting on. The default backend is redis, and
* the cluster is configured with an offline queue and no per-command timeout
* — so while redis is unreachable a gated request waits on it rather than
* failing fast. The gate does fail open, but only once the call rejects, and
* the ALB gives a target 4s per probe and evicts after two. A redis
* degradation could therefore empty every target group in every region, which
* is the outcome the `@dependencies` degrade rules in the health check query
* exist to prevent. Keeping the counter in-process removes redis from the
* liveness path entirely.
* - Per-node counting is also the more honest bucket here. The ceiling only ever
* needs to cover the pollers hitting _this_ node, not (pollers x fleet size)
* as a shared counter does.
*/
const HEALTHCHECK_LIMIT = {
scope: 'healthcheck',
limit: 30_000,
window: 60_000,
key: 'ip',
backend: 'memory',
};
/**
* Deploy-constant build info, polled by clients. One address is a NAT, a
* campus, a proxy or a server-side renderer, so this bucket aggregates every
* client behind it — sizing it for a single browser would throttle a whole
* office. The response is cached per-client for a minute, which bounds each
* client to roughly one hit per window; the ceiling is what is left to catch a
* client that ignores the cache.
*/
const VERSION_LIMIT = {
scope: 'version',
limit: 6_000,
window: 60_000,
key: 'ip',
};
/**
* Deploy-constant deployment identity, read once per page load to decide
* whether to offer signup. Same aggregation as `/version` — the bucket is a
* whole network's worth of clients — and the payload is four constants, so the
* limit only guards against an unbounded client loop.
*/
const WHOAREWE_LIMIT = {
scope: 'whoarewe',
limit: 6_000,
window: 60_000,
key: 'ip',
};
/** Static introspection output, read once at boot rather than in a loop. */
const LSMOD_LIMIT = {
scope: 'lsmod',
limit: 60,
window: 60_000,
key: 'user',
};
export class SystemController extends PuterController {
constructor(config, clients, stores, services, drivers) {
super(config, clients, stores, services, drivers);
this.bootTime = Date.now();
}
registerRoutes(
/** @type {import('../../core/http/PuterRouter.js').PuterRouter} */
router,
) {
// -- Healthcheck ---------------------------------------------
// Delegates to ServerHealthService for the real check-based
// status. Returns `{ ok: true }` + 200 when all registered checks
// pass, or `{ ok: false, failed: [...] }` + 503 when any fail or the
// server is draining.
//
// `?ignore=a,b` disregards the named checks for this request only.
// `?marked-degraded=a,b` demotes the named checks to a non-fatal
// `degraded` list: `ok` stays true but the response is 207 so the
// caller can tell the node is running in a degraded state. Either list
// accepts `@` to stand for every check in a group — notably
// `@dependencies` for the backing-service probes — so a caller polling
// this route doesn't have to enumerate them.
const parseNames = (value) =>
typeof value === 'string'
? value
.split(',')
.map((name) => name.trim())
.filter(Boolean)
: [];
router.get(
'/healthcheck',
{ subdomain: '*', rateLimit: HEALTHCHECK_LIMIT },
async (req, res) => {
const health = this.services.health;
if (!health || typeof health.getStatus !== 'function') {
// Fallback for boot ordering / missing service.
return res.send('ok');
}
const status = await health.getStatus({
ignore: parseNames(req.query.ignore),
degrade: parseNames(req.query['marked-degraded']),
});
if (!status.ok) return res.status(503).json(status);
if (status.degraded?.length)
return res.status(207).json(status);
return res.json(status);
},
);
// -- Version -------------------------------------------------
router.get(
'/version',
{ subdomain: '*', rateLimit: VERSION_LIMIT },
(_req, res) => {
const version =
this.config.version ??
process.env.npm_package_version ??
'unknown';
const parts = String(version).split('.');
// Deploy-constant, and callers poll it. Cache per-client only:
// a shared cache could pin one region's `location` for everyone,
// and the short window still bounds how long a client can miss a
// new deploy.
res.setHeader('Cache-Control', 'private, max-age=60');
res.json({
version,
major: parts[0] ? Number(parts[0]) : null,
minor: parts[1] ? Number(parts[1]) : null,
patch: parts[2] ? Number(parts[2]) : null,
environment: this.config.env ?? 'prod',
location: this.config.serverId ?? null,
deploy_timestamp: this.bootTime,
});
},
);
// -- Contact us ----------------------------------------------
router.post(
'/contactUs',
{
subdomain: 'api',
requireUserActor: true,
allowFullAccessToken: true,
rateLimit: {
scope: 'contact-us',
limit: 10,
window: 15 * 60_000,
key: 'user',
},
},
async (req, res) => {
const { message } = req.body ?? {};
if (!message || typeof message !== 'string') {
throw new HttpError(400, '`message` is required', {
legacyCode: 'bad_request',
});
}
if (message.length > 100_000) {
throw new HttpError(
400,
'`message` is too long (max 100,000 characters)',
{ legacyCode: 'bad_request' },
);
}
// Persist to feedback table for durability
try {
await this.clients.db.write(
'INSERT INTO `feedback` (`user_id`, `message`) VALUES (?, ?)',
[req.actor.user.id, message],
);
} catch (e) {
console.warn('[contactUs] feedback insert failed:', e);
}
// Send to support email
const supportEmail =
this.config.support_email ?? 'support@puter.com';
if (this.clients.email && req.actor.user?.email) {
try {
await this.clients.email.sendRaw({
to: supportEmail,
replyTo: req.actor.user.email,
subject: `Contact from ${req.actor.user.username}`,
text: message,
});
} catch (e) {
console.warn('[contactUs] email send failed:', e);
}
}
res.json({});
},
);
// -- GET /whoarewe -------------------------------------------
router.get('/whoarewe', { rateLimit: WHOAREWE_LIMIT }, (_req, res) => {
res.json({
name: 'Puter',
version: this.config.version ?? null,
environment: this.config.env ?? 'prod',
disable_user_signup: Boolean(this.config.disable_user_signup),
});
});
// -- GET|POST /lsmod -----------------------------------------
// Enumerates driver interfaces and their implementors. POST is
// also routed because puter.js `drivers.list()` sends POST.
const lsmod = (_req, res) => {
const interfaces = {};
for (const [key, driver] of Object.entries(this.drivers)) {
const ifaceName = driver?.driverInterface;
if (!ifaceName) continue;
const driverName = driver.driverName ?? key;
if (!interfaces[ifaceName]) {
interfaces[ifaceName] = { implementors: {} };
}
interfaces[ifaceName].implementors[driverName] = {
isDefault: Boolean(driver.isDefault),
};
}
res.json({ interfaces });
};
router.get(
'/lsmod',
{ subdomain: 'api', requireAuth: true, rateLimit: LSMOD_LIMIT },
lsmod,
);
router.post(
'/lsmod',
{ subdomain: 'api', requireAuth: true, rateLimit: LSMOD_LIMIT },
lsmod,
);
}
onServerStart() {}
onServerPrepareShutdown() {
globalThis.__puter_draining = true;
}
onServerShutdown() {}
}