Spaces:
Sleeping
Sleeping
File size: 6,764 Bytes
1cf4d4c 1c7b998 1cf4d4c 1c7b998 1cf4d4c 1c7b998 1cf4d4c 1c7b998 1cf4d4c e830ad4 1cf4d4c | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 | import { USERNAME_RE, UsersFileSchema } from '../../domain/schemas';
import type { User } from '../../domain/types';
import { ulid } from '../../domain/ulid';
import { hashPassword, MIN_PASSWORD_LENGTH, verifyPassword } from '../auth/password';
import { decodeText, encodeText, type BucketStore } from '../storage/store';
export const USERS_PATH = 'users.json';
export const BOOTSTRAP_ADMIN_PASSWORD = '1234';
export class UserValidationError extends Error {
constructor(
message: string,
public readonly field: string
) {
super(message);
this.name = 'UserValidationError';
}
}
export interface CreateUserInput {
username: string;
displayName: string;
password: string;
}
export interface UsersRepoOptions {
clock?: () => Date;
newId?: () => string;
}
/** Owns users.json. Every mutation rewrites the whole file, then swaps memory. */
export class UsersRepo {
private queue: Promise<unknown> = Promise.resolve();
private constructor(
private readonly store: BucketStore,
private users: User[],
private readonly clock: () => Date,
private readonly newId: () => string
) {}
static async load(store: BucketStore, opts: UsersRepoOptions = {}): Promise<UsersRepo> {
const clock = opts.clock ?? (() => new Date());
const newId = opts.newId ?? ulid;
const bytes = await store.get(USERS_PATH);
const existing = bytes ? UsersFileSchema.parse(JSON.parse(decodeText(bytes))).users : [];
if (bytes && existing.some((u) => u.role === 'admin')) {
return new UsersRepo(store, existing, clock, newId);
}
// users.json is missing, or present but has no admin: bootstrap/re-bootstrap one.
const now = clock().toISOString();
const admin: User = {
id: 'u_admin',
username: 'admin',
displayName: 'Admin',
passwordHash: await hashPassword(BOOTSTRAP_ADMIN_PASSWORD),
role: 'admin',
active: true,
mustChangePassword: true,
sessionVersion: 1,
createdAt: now,
updatedAt: now
};
const repo = new UsersRepo(store, existing, clock, newId);
await repo.persist([...existing, admin]);
return repo;
}
// ---- reads -------------------------------------------------------------
all(): User[] {
return [...this.users];
}
byId(id: string): User | undefined {
return this.users.find((u) => u.id === id);
}
byUsername(username: string): User | undefined {
const key = username.trim().toLowerCase();
return this.users.find((u) => u.username === key);
}
/** Active non-admin users, sorted by display name. */
activeMembers(): User[] {
return this.members().filter((u) => u.active);
}
/** All non-admin users, sorted by display name. */
members(): User[] {
return this.users
.filter((u) => u.role === 'member')
.sort((a, b) => a.displayName.localeCompare(b.displayName) || a.id.localeCompare(b.id));
}
isActiveMember(id: string): boolean {
const u = this.byId(id);
return !!u && u.role === 'member' && u.active;
}
async authenticate(username: string, password: string): Promise<User | null> {
const user = this.byUsername(username);
// Always run a hash comparison so timing does not reveal whether the username exists.
// A ScryptOverloadError from a login flood propagates: the guarded action turns it into a 503.
const ok = await verifyPassword(password, user?.passwordHash ?? 'scrypt$32768$8$1$AAAAAAAAAAAAAAAAAAAAAA==$AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=');
return ok && user && user.active ? user : null;
}
// ---- writes ------------------------------------------------------------
create(input: CreateUserInput): Promise<User> {
return this.run(async () => {
const username = input.username.trim().toLowerCase();
const displayName = input.displayName.trim();
if (!USERNAME_RE.test(username)) throw new UserValidationError('Username must be 3–32 characters: a–z, 0–9, _ . -', 'username');
if (username === 'admin' || this.byUsername(username)) throw new UserValidationError('Username is taken', 'username');
if (displayName.length === 0 || displayName.length > 120) throw new UserValidationError('Display name is required', 'displayName');
validatePassword(input.password);
const now = this.clock().toISOString();
const user: User = {
id: `u_${this.newId()}`,
username,
displayName,
passwordHash: await hashPassword(input.password),
role: 'member',
active: true,
mustChangePassword: true,
sessionVersion: 1,
createdAt: now,
updatedAt: now
};
await this.persist([...this.users, user]);
return user;
});
}
setPassword(id: string, password: string, opts: { mustChange: boolean }): Promise<User> {
return this.run(async () => {
validatePassword(password);
const passwordHash = await hashPassword(password);
return this.patch(id, (u) => ({ ...u, passwordHash, mustChangePassword: opts.mustChange, sessionVersion: u.sessionVersion + 1 }));
});
}
setActive(id: string, active: boolean): Promise<User> {
return this.run(async () => {
const u = this.require(id);
if (u.role === 'admin') throw new UserValidationError('The admin account cannot be deactivated', 'active');
return this.patch(id, (cur) => ({ ...cur, active, sessionVersion: active ? cur.sessionVersion : cur.sessionVersion + 1 }));
});
}
rename(id: string, displayName: string): Promise<User> {
return this.run(async () => {
const name = displayName.trim();
if (name.length === 0 || name.length > 120) throw new UserValidationError('Display name is required', 'displayName');
return this.patch(id, (u) => ({ ...u, displayName: name }));
});
}
// ---- internals ---------------------------------------------------------
private run<T>(fn: () => Promise<T>): Promise<T> {
const next = this.queue.then(fn, fn);
this.queue = next.catch(() => undefined);
return next;
}
private require(id: string): User {
const u = this.byId(id);
if (!u) throw new UserValidationError('User not found', 'id');
return u;
}
private async patch(id: string, fn: (u: User) => User): Promise<User> {
const current = this.require(id);
const updated = { ...fn(current), updatedAt: this.clock().toISOString() };
await this.persist(this.users.map((u) => (u.id === id ? updated : u)));
return updated;
}
private async persist(users: User[]): Promise<void> {
const file = UsersFileSchema.parse({ version: 1, users });
await this.store.put(USERS_PATH, encodeText(JSON.stringify(file, null, 2)), 'application/json');
this.users = users;
}
}
export function validatePassword(password: string): void {
if (password.length < MIN_PASSWORD_LENGTH) throw new UserValidationError(`Password must be at least ${MIN_PASSWORD_LENGTH} characters`, 'password');
if (password.length > 256) throw new UserValidationError('Password is too long', 'password');
}
|