mirror of
https://github.com/HeyPuter/puter.git
synced 2026-11-06 05:55:42 +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
118 lines
4.1 KiB
TypeScript
118 lines
4.1 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 { HttpError } from '../core/http/HttpError.js';
|
|
import type { FSEntryStore } from '../stores/fs/FSEntryStore.js';
|
|
import type { UserStore } from '../stores/user/UserStore.js';
|
|
import { isUniqueViolation } from './dbError.js';
|
|
import { generate_identifier } from './identifier.js';
|
|
import { isReservedUsername } from './reservedUsernames.js';
|
|
|
|
export const USERNAME_REGEX = /^\w{1,}$/;
|
|
export const USERNAME_MAX_LENGTH = 45;
|
|
const GENERATE_ATTEMPTS = 20;
|
|
|
|
interface UsernameStores {
|
|
user: Pick<UserStore, 'getByUsername'>;
|
|
fsEntry: Pick<FSEntryStore, 'findHomePathConflict'>;
|
|
}
|
|
|
|
export type UsernameRejection = 'format' | 'length' | 'reserved';
|
|
|
|
/** Which account-name rule `username` breaks, or null when it breaks none. */
|
|
export const usernameRejection = (
|
|
username: string,
|
|
): UsernameRejection | null => {
|
|
if (!USERNAME_REGEX.test(username)) return 'format';
|
|
if (username.length > USERNAME_MAX_LENGTH) return 'length';
|
|
if (isReservedUsername(username)) return 'reserved';
|
|
return null;
|
|
};
|
|
|
|
/** Throws the 400 signup answers for a name that breaks a rule. */
|
|
export const assertValidUsername = (username: string): void => {
|
|
const rejection = usernameRejection(username);
|
|
if (rejection === 'format') {
|
|
throw new HttpError(
|
|
400,
|
|
'Username can only contain letters, numbers and underscore (_).',
|
|
{ legacyCode: 'bad_request' },
|
|
);
|
|
}
|
|
if (rejection === 'length') {
|
|
throw new HttpError(
|
|
400,
|
|
`Username cannot be longer than ${USERNAME_MAX_LENGTH} characters.`,
|
|
{ legacyCode: 'bad_request' },
|
|
);
|
|
}
|
|
if (rejection === 'reserved') {
|
|
throw new HttpError(400, 'This username is not available.', {
|
|
legacyCode: 'username_already_in_use',
|
|
});
|
|
}
|
|
};
|
|
|
|
/**
|
|
* What already holds `username`: another account, or rows at or under its home
|
|
* path (which would give a new account a second root there). `ownId` is the
|
|
* account claiming it, excluded from both checks.
|
|
*/
|
|
export const usernameConflict = async (
|
|
stores: UsernameStores,
|
|
username: string,
|
|
ownId?: number,
|
|
): Promise<'account' | 'home' | null> => {
|
|
const holder = await stores.user.getByUsername(username);
|
|
if (holder && holder.id !== ownId) return 'account';
|
|
const homeTaken = await stores.fsEntry.findHomePathConflict(
|
|
username,
|
|
ownId,
|
|
{ includeDescendants: true },
|
|
);
|
|
return homeTaken ? 'home' : null;
|
|
};
|
|
|
|
/** A random free username, or null after a bounded number of misses. */
|
|
export const generateUsername = async (
|
|
stores: UsernameStores,
|
|
): Promise<string | null> => {
|
|
for (let i = 0; i < GENERATE_ATTEMPTS; i++) {
|
|
const candidate = generate_identifier();
|
|
if (!(await usernameConflict(stores, candidate))) return candidate;
|
|
}
|
|
return null;
|
|
};
|
|
|
|
/**
|
|
* Whether a failed write to user `ownId` (none for an insert) lost `username`
|
|
* to another account. The primary is read because the winner may not have
|
|
* replicated yet.
|
|
*/
|
|
export const isUsernameTaken = async (
|
|
userStore: Pick<UserStore, 'getByUsername'>,
|
|
err: unknown,
|
|
username: string,
|
|
ownId?: number,
|
|
): Promise<boolean> => {
|
|
if (!isUniqueViolation(err)) return false;
|
|
const holder = await userStore.getByUsername(username, { force: true });
|
|
return Boolean(holder && holder.id !== ownId);
|
|
};
|