mirror of
https://github.com/HeyPuter/puter.git
synced 2026-11-06 14:07:51 +00:00
* 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
290 lines
9.2 KiB
TypeScript
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('✕', 'Something went wrong', msg, '#e53e3e');
|
|
const ok = (msg: string) => page('✓', '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.'));
|
|
},
|
|
);
|
|
}
|
|
}
|