Files
puter/src/backend/controllers/static/StaticPagesController.ts
T
Daniel Salazar d162549c1d refactor(controllers): one signup, login, confirm and window.user path; userProtected/internalAuth route options (#4189)
* refactor(auth): one signup path for form, temp, OIDC and team accounts

Signup steps (validate hook, phone/card forcing, bonus verdict, provisioning,
signup events) move from AuthController and OIDCService into SignupService.
Username rules, availability and generation move to util/username.ts, and
user-row + group + default-folder creation to provisionUser, which
TeamService and DefaultUserService now share.

Behaviour fixes: a provisioned team seat joins default_user_group like every
other account, and generated temp usernames skip names whose home path is
occupied.

* refactor(auth): one web-session start for password and OIDC sign-in

startWebSession creates the session with the request's IP, user agent, origin
and host and sets the session cookie. OIDC sign-ins used to create their
session without that metadata, so they showed up blank in the session list.

* refactor(auth): one confirmEmail for the code and link confirmation routes

Both routes now re-run the email checks, refuse an address another account
confirmed, strip it from other rows, promote the group and push
user.email_confirmed to the account's sockets (with the originating socket id
on the code route). The link route's full-table UPDATE clearing other
accounts' pending change-email requests is dropped: /change_email/confirm
already refuses an address a confirmed account owns.

* refactor(auth): build window.user once for whoami and sign-in

buildUserDetails and scrubSensitive move from the whoami extension into
util/userDetails.ts. /whoami calls them; sign-in responses now return the same
user-actor payload, scrubbed, so the login copy no longer ships the phone
number and a no-reload login gets oidc_only/otp/directories up front. Taskbar
resolution is best-effort on both.

* refactor(http): userProtected route option replaces AuthController's route walker copy

The user-protected gate is materialized from a userProtected RouteOption
(implies requireUserActor), so AuthController no longer re-implements the
decorator walker to wire it per instance; those routes and the session
routes are plain decorators now. AuthRequired also counts requireVerified
and userProtected.

* refactor(http): internalAuth route option and a shared bodyRecord

EventsController and PeerController each checked x-puter-internal-auth by
hand with different compares; an internalAuth RouteOption now picks the
secret from config and one constant-time gate checks it. ShareController and
EventsController share bodyRecord instead of private copies.

* fix(fs): cap legacy /delete at 500 paths per request

The bulk form resolved, ACL-checked and removed an unbounded paths[] list one
by one. It now refuses more than FS_BATCH_WRITE_MAX_ITEMS (500), the cap the
batch write routes already use, before touching any entry.

* refactor(controllers): user and subdomain writes and reads through their stores

AuthController's 2FA setup/enable/disable and recovery-code writes go through
UserStore.update instead of raw UPDATEs plus a manual invalidate, and
/readdir-subdomains pages SubdomainStore.listByUserId instead of an unbounded
SELECT; the response shape is unchanged.

* refactor(auth): note SignupService's call-time peers

* fix(auth): type userDetails' taskbar deps independently of TaskbarDeps.clients
2026-10-11 22:00:53 -07:00

290 lines
9.2 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/>.
*/
import { PuterController } from '../types.js';
import type { PuterRouter } from '../../core/http/PuterRouter';
import { HttpError } from '../../core/http/HttpError.js';
/**
* One-off user-facing pages.
*
* /robots.txt — static text /sitemap.xml — docs + approved apps /unsubscribe —
* toggles `user.unsubscribed` from an email link /confirm-email-by-token —
* email-link confirmation flow (distinct from the POST /confirm-email JSON API
* used by the in-app code-entry form)
*
* All root-subdomain-only, all unauthenticated (the confirm/unsubscribe tokens
* in the query string are the auth).
*/
/**
* Unauthenticated pages reached from a token-bearing email link. Matches what
* the emailSend extension already applies to its own link pages.
*/
const TOKEN_LINK_LIMIT = {
scope: 'token-link-page',
limit: 30,
window: 60_000,
key: 'ip' as const,
};
export class StaticPagesController extends PuterController {
registerRoutes(router: PuterRouter) {
const origin = this.config.origin ?? '';
const docsOrigin = (() => {
const d = this.config.domain;
return d ? `https://docs.${d}` : '';
})();
const page = (
icon: string,
title: string,
msg: string,
color: string,
) => `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${title} — Puter</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
Helvetica, Arial, sans-serif;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100vh;
background: #f8f9fa;
color: #1a1a1a;
-webkit-font-smoothing: antialiased;
}
.card {
background: #fff;
border-radius: 12px;
box-shadow: 0 1px 3px rgba(0,0,0,.08), 0 4px 12px rgba(0,0,0,.04);
padding: 48px 40px;
max-width: 420px;
width: 100%;
margin: 24px;
text-align: center;
}
.icon {
width: 56px;
height: 56px;
border-radius: 50%;
background: ${color}30;
display: inline-flex;
align-items: center;
justify-content: center;
margin-bottom: 20px;
font-size: 26px;
line-height: 1;
}
h1 {
font-size: 20px;
font-weight: 600;
margin-bottom: 8px;
color: #111;
}
p {
font-size: 15px;
line-height: 1.5;
color: #555;
}
.links {
position: fixed;
bottom: 0;
left: 0;
right: 0;
padding: 16px;
display: flex;
justify-content: center;
gap: 24px;
}
.links a {
font-size: 13px;
color: #888;
text-decoration: none;
transition: color .15s;
}
.links a:hover { color: #111; }
</style>
</head>
<body>
<div class="card">
<div class="icon">${icon}</div>
<h1>${title}</h1>
<p>${msg}</p>
</div>
<div class="links">
<a href="${origin}">Home</a>
<a href="${origin}/action/login">Log In</a>
${docsOrigin ? `<a href="${docsOrigin}">Docs</a>` : ''}
<a href="${origin}/terms">Terms</a>
<a href="${origin}/privacy">Privacy</a>
<a href="https://github.com/HeyPuter/puter">GitHub</a>
</div>
</body>
</html>`;
const err = (msg: string) =>
page('&#10005;', 'Something went wrong', msg, '#e53e3e');
const ok = (msg: string) => page('&#10003;', 'Success', msg, '#38a169');
// -- /robots.txt ---------------------------------------------
router.get('/robots.txt', {}, (req, res) => {
const domain = this.config.domain ?? req.hostname;
const disallowed = [
'AhrefsBot',
'BLEXBot',
'DotBot',
'ia_archiver',
'MJ12bot',
'SearchmetricsBot',
'SemrushBot',
];
const body =
disallowed
.map((ua) => `User-agent: ${ua}\nDisallow: /\n`)
.join('\n') +
`\nSitemap: ${req.protocol}://${domain}/sitemap.xml\n`;
res.type('text/plain').send(body);
});
// -- /sitemap.xml --------------------------------------------
router.get(
'/sitemap.xml',
{
// Unauthenticated and runs a full-table scan over approved
// apps on every request, with no response cache in front.
rateLimit: {
scope: 'sitemap',
limit: 10,
window: 60_000,
key: 'ip',
},
},
async (req, res) => {
const domain = this.config.domain ?? req.hostname;
const origin = `${req.protocol}://${domain}`;
const apps = (await this.clients.db.read(
`SELECT \`name\` FROM \`apps\` WHERE \`approved_for_listing\` = ${this.clients.db.booleanLiteral(true)}`,
)) as Array<{ name: string }>;
const urls = [
`<url><loc>${req.protocol}://docs.${domain}/</loc></url>`,
...apps.map(
(a) => `<url><loc>${origin}/app/${a.name}</loc></url>`,
),
];
const body =
'<?xml version="1.0" encoding="UTF-8"?>' +
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">' +
urls.join('') +
'</urlset>';
res.type('application/xml').send(body);
},
);
// -- /unsubscribe --------------------------------------------
router.get(
'/unsubscribe',
{ rateLimit: TOKEN_LINK_LIMIT },
async (req, res) => {
const userUuid =
typeof req.query.user_uuid === 'string'
? req.query.user_uuid
: undefined;
if (!userUuid) {
res.send(err('user_uuid is required'));
return;
}
const user = await this.stores.user.getByUuid(userUuid);
if (!user) {
res.send(err('User not found.'));
return;
}
if (user.unsubscribed) {
res.send(ok('You are already unsubscribed.'));
return;
}
await this.stores.user.update(user.id, { unsubscribed: 1 });
res.send(
ok('You have successfully unsubscribed from all emails.'),
);
},
);
// -- /confirm-email-by-token ---------------------------------
router.get(
'/confirm-email-by-token',
{ rateLimit: TOKEN_LINK_LIMIT },
async (req, res) => {
const userUuid =
typeof req.query.user_uuid === 'string'
? req.query.user_uuid
: undefined;
const token =
typeof req.query.token === 'string'
? req.query.token
: undefined;
if (!userUuid) {
res.send(err('user_uuid is required'));
return;
}
if (!token) {
res.send(err('token is required'));
return;
}
const user = await this.stores.user.getByProperty(
'uuid',
userUuid,
{ force: true },
);
if (!user) {
res.send(err('user not found.'));
return;
}
if (user.email_confirmed) {
res.send(ok('Email already confirmed.'));
return;
}
if (user.email_confirm_token !== token) {
res.send(err('invalid token.'));
return;
}
try {
await this.services.signup.confirmEmail(user);
} catch (e) {
if (!(e instanceof HttpError)) throw e;
res.send(err(e.message));
return;
}
res.send(ok('Your email has been successfully confirmed.'));
},
);
}
}