mirror of
https://github.com/HeyPuter/puter.git
synced 2026-10-17 00:59:27 +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
147 lines
4.5 KiB
TypeScript
147 lines
4.5 KiB
TypeScript
import { Context } from '@heyputer/backend/src/core';
|
|
import { extension } from '@heyputer/backend/src/extensions';
|
|
import { APP_ICON_SIZES } from '@heyputer/backend/src/util/appIcon.js';
|
|
import {
|
|
buildUserDetails,
|
|
scrubSensitive,
|
|
} from '@heyputer/backend/src/util/userDetails.js';
|
|
import type { Request, Response } from 'express';
|
|
import TimeAgo from 'javascript-time-ago';
|
|
import localeEn from 'javascript-time-ago/locale/en';
|
|
|
|
const stores = extension.import('store');
|
|
const services = extension.import('service');
|
|
const clients = extension.import('client');
|
|
|
|
const timeago = (() => {
|
|
TimeAgo.addDefaultLocale(localeEn);
|
|
return new TimeAgo('en-US');
|
|
})();
|
|
|
|
// What an app actor may receive from `whoami.details` listeners. Anything
|
|
// else a listener adds describes the account, and only ships to user actors.
|
|
const APP_VISIBLE_LISTENER_KEYS: ReadonlySet<string> = new Set([
|
|
'subscribed',
|
|
'paid_storage',
|
|
]);
|
|
|
|
export const handleWhoami = async (
|
|
req: Request,
|
|
res: Response,
|
|
): Promise<void> => {
|
|
const actor = Context.get('actor');
|
|
if (!actor?.user?.id) {
|
|
res.status(401).json({ error: 'Authentication required' });
|
|
return;
|
|
}
|
|
|
|
const isUser = !actor.effectiveApp;
|
|
const user = await stores.user.getById(actor.user.id);
|
|
if (!user) {
|
|
res.status(404).json({ error: 'User not found' });
|
|
return;
|
|
}
|
|
|
|
const rawIconSize =
|
|
typeof req.query?.icon_size === 'string'
|
|
? Number(req.query.icon_size)
|
|
: undefined;
|
|
const iconSize =
|
|
rawIconSize !== undefined && APP_ICON_SIZES.includes(rawIconSize)
|
|
? rawIconSize
|
|
: undefined;
|
|
|
|
const details = await buildUserDetails(
|
|
user,
|
|
{ config: extension.config, clients, stores, services },
|
|
{ isUser, iconSize, noIcons: !iconSize },
|
|
);
|
|
details.human_readable_age = user.timestamp
|
|
? timeago.format(new Date(user.timestamp as string))
|
|
: null;
|
|
|
|
// Strip sensitive fields for app actors
|
|
if (!isUser) {
|
|
const canReadEmail = await services.permission
|
|
.check(actor, `user:${user.uuid}:email:read`)
|
|
.catch(() => false);
|
|
if (!canReadEmail) {
|
|
delete details.email;
|
|
delete details.unconfirmed_email;
|
|
}
|
|
delete details.desktop_bg_url;
|
|
delete details.desktop_bg_color;
|
|
delete details.desktop_bg_fit;
|
|
delete details.human_readable_age;
|
|
delete details.created_ts;
|
|
delete details.is_user_token;
|
|
delete details.metadata;
|
|
// An app has no business reading the code its user earns credit with.
|
|
delete details.referral_code;
|
|
}
|
|
|
|
const app = actor.effectiveApp;
|
|
if (app) {
|
|
details.app_name = app.uid;
|
|
}
|
|
|
|
// A key core left undefined isn't built, so a listener can't fill it for an app.
|
|
const builtKeys = isUser
|
|
? null
|
|
: new Set(
|
|
Object.keys(details).filter((key) => details[key] !== undefined),
|
|
);
|
|
try {
|
|
await clients.event.emitAndWait(
|
|
'whoami.details',
|
|
{ user, details, isUser },
|
|
{},
|
|
);
|
|
} catch {
|
|
/* best-effort */
|
|
}
|
|
if (builtKeys) {
|
|
for (const key of Object.keys(details)) {
|
|
if (!builtKeys.has(key) && !APP_VISIBLE_LISTENER_KEYS.has(key)) {
|
|
delete details[key];
|
|
}
|
|
}
|
|
}
|
|
|
|
const subscription = details.subscription as
|
|
{ offering?: Record<string, unknown> } | undefined;
|
|
if (subscription?.offering) {
|
|
delete subscription.offering.group;
|
|
delete subscription.offering.benefits;
|
|
delete subscription.offering.price_id;
|
|
}
|
|
|
|
// Last word on what ships, after every listener has had its say.
|
|
scrubSensitive(details);
|
|
|
|
res.json(details);
|
|
};
|
|
|
|
extension.get(
|
|
'/whoami',
|
|
{
|
|
subdomain: 'api',
|
|
requireAuth: true,
|
|
allowUnconfirmed: true,
|
|
// The GUI polls this, and each call fans out to every `whoami`
|
|
// event listener — so it costs more than the response suggests.
|
|
//
|
|
// It is also the call everything else leans on to find out who it is
|
|
// talking to, so it rides along with unrelated work rather than
|
|
// arriving at its own pace: the ceiling has to clear whatever the
|
|
// busiest session is doing, not what a person clicks.
|
|
rateLimit: {
|
|
scope: 'whoami',
|
|
limit: 1_800,
|
|
window: 60_000,
|
|
key: 'user',
|
|
},
|
|
},
|
|
handleWhoami,
|
|
);
|