= self::MIN_PASSWORD_LENGTH; } public static function isValidRole(string $role): bool { return in_array($role, self::ROLES, true); } /** The name as stored, or null when the pair does not match an account. */ public function verify(string $username, string $password): ?string { $user = $this->find($username); if ($user === null || !password_verify($password, $user['password'])) { return null; } return $user['username']; } public function exists(string $username): bool { return $this->find($username) !== null; } /** `user` for an account that does not exist, the same as for one without a role. */ public function role(string $username): string { return $this->find($username)['role'] ?? 'user'; } /** * Every account as the API may show it – never the hash. * * @return list */ public function accounts(): array { return array_map( static fn(array $u): array => [ 'username' => $u['username'], 'role' => $u['role'], 'createdAt' => $u['createdAt'], ], $this->all(), ); } public function adminCount(): int { return count(array_filter($this->all(), static fn(array $u): bool => $u['role'] === 'admin')); } /** * Adds the account or replaces the password of one that is already there. Without `$role` a new * account is a `user` and an existing one keeps what it has, so resetting a password from the * command line never demotes an administrator. */ public function put(string $username, string $password, ?string $role = null): void { if (!self::isValidName($username)) { throw new InvalidArgumentException("Invalid user name: {$username}"); } if ($role !== null && !self::isValidRole($role)) { throw new InvalidArgumentException("Invalid role: {$role}"); } $existing = $this->find($username); $users = $this->without($username); $users[] = [ 'username' => $existing['username'] ?? $username, 'password' => password_hash($password, PASSWORD_DEFAULT), 'role' => $role ?? $existing['role'] ?? 'user', 'createdAt' => $existing !== null ? $existing['createdAt'] : gmdate('c'), ]; $this->write($users); } /** @return bool False when there is no such account. */ public function setRole(string $username, string $role): bool { if (!self::isValidRole($role)) { throw new InvalidArgumentException("Invalid role: {$role}"); } return $this->change($username, static fn(array $u): array => ['role' => $role] + $u); } /** * Gives an account a new name. Only the name: the projects that carry the old one as `owner` * and the sessions that do are the caller's to move (see `Store::transfer`). * * @return bool False when there is no such account. * @throws InvalidArgumentException when the new name is invalid or taken by another account. */ public function rename(string $from, string $to): bool { if (!self::isValidName($to)) { throw new InvalidArgumentException("Invalid user name: {$to}"); } $other = $this->find($to); // Only a change of case is the same account; any other match is somebody else's name. if ($other !== null && strcasecmp($other['username'], $from) !== 0) { throw new InvalidArgumentException("The name {$to} is taken"); } return $this->change($from, static fn(array $u): array => ['username' => $to] + $u); } /** @return bool False when there is no such account. */ public function remove(string $username): bool { if ($this->find($username) === null) { return false; } $this->write($this->without($username)); return true; } /** @param callable(array):array $edit */ private function change(string $username, callable $edit): bool { $user = $this->find($username); if ($user === null) { return false; } $users = $this->without($username); $users[] = $edit($user); $this->write($users); return true; } /** @return list */ private function without(string $username): array { return array_values( array_filter($this->all(), static fn(array $u): bool => strcasecmp($u['username'], $username) !== 0), ); } /** @return array{username:string,password:string,role:string,createdAt:string}|null */ private function find(string $username): ?array { foreach ($this->all() as $user) { if (strcasecmp($user['username'], $username) === 0) { return $user; } } return null; } /** @return list */ private function all(): array { if (!is_file($this->file)) { return []; } $raw = file_get_contents($this->file); $data = $raw === false ? null : json_decode($raw, true); if (!is_array($data) || !is_array($data['users'] ?? null)) { return []; } $users = []; foreach ($data['users'] as $user) { if (is_array($user) && is_string($user['username'] ?? null) && is_string($user['password'] ?? null)) { $role = $user['role'] ?? null; $users[] = [ 'username' => $user['username'], 'password' => $user['password'], 'role' => is_string($role) && self::isValidRole($role) ? $role : 'user', 'createdAt' => is_string($user['createdAt'] ?? null) ? $user['createdAt'] : '', ]; } } return $users; } /** @param list $users */ private function write(array $users): void { usort($users, static fn(array $a, array $b): int => strcmp($a['username'], $b['username'])); $dir = dirname($this->file); if (!is_dir($dir) && !mkdir($dir, 0775, true) && !is_dir($dir)) { throw new RuntimeException("Cannot create data directory {$dir}"); } $json = json_encode(['users' => $users], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); if ($json === false) { throw new RuntimeException('Cannot encode users'); } AtomicFile::write($this->file, $json, AtomicFile::HIDDEN, 'users file'); } }