Uploaded images, fonts and sounds belonged to nobody. Every account saw every file in the pickers, and only an administrator could delete one, through the API alone. Now every account uploads into a folder of its own, sees its own files and nobody's, and can delete a file of its own that no project uses. - Files are stored as data/<kind>/<folder>/<sha256>.<ext>, and the folder is part of asset.file. A name without a folder belongs to nobody. - The folder is a random 12-character name kept in users.json and given with the first upload. Renaming an account moves no file, and a released game's URLs carry no account name. - The list shows the caller's own files and nobody's. An administrator sees all, each with owner and deletable. - DELETE takes the name with its folder. It answers 403 unless the file is the caller's or the caller is an administrator, and 409 while a project, snapshot or release of any account names it (FileUsage). The 409 names the caller's own projects only. - The pickers have a Delete button. The first press asks, the second deletes, and a refusal shows the server's reason. - A deleted account's folder stays and only administrators see it. - tools/migrate-file-owners.php moved every used file into the folder of the account using it (a copy each for two accounts) and rewrote projects, snapshots and releases. A project's revision goes up, so an open editor gets a conflict instead of saving old names back. - Font families are named after the hash alone, so a folder never ends up in a CSS family name. Run on stein2 on 2026-10-07, backup in /root/config-backup-20261007/data-before-344: 25 files used, 27 copies, 8 projects, 53 snapshots and 1 release rewritten, 30 unused files stay nobody's. Afterwards all 600 references resolve to a file in their owner's folder. Found while building: the Delete button cut its new label to "Del…" because a button does not measure itself again on a new text. The picker now lays its button row out again. Tests: - PHP files.test.php (new): 46 checks, counter-check 3 red - Unit core + editor: 220 files, 3403 tests - e2e 34 (melektaus-atx): ALL PASSED - 67 checks (2 new) - Gate (melektaus-atx): CHECK EXIT 0, 256 files, 3977 tests Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
272 lines
10 KiB
PHP
272 lines
10 KiB
PHP
<?php
|
||
declare(strict_types=1);
|
||
|
||
/**
|
||
* The accounts, as one JSON file: `data/users.json` holding `{username, password, role, createdAt,
|
||
* folder}` per user, where `password` is a hash from `password_hash` – the plain one is never stored.
|
||
*
|
||
* An entry without `role` is a `user` (#180): the weaker of the two is what a missing field means,
|
||
* so a hand-written file never hands out rights by accident. `createdAt` is missing on the accounts
|
||
* from before #179 and stays empty for them.
|
||
*
|
||
* `folder` names the account's own directory under `data/textures/`, `data/fonts/` and
|
||
* `data/sounds/` (#344). It is random rather than the name, so renaming an account moves no file
|
||
* and rewrites no project, and the URL of a released game's files says nothing about whose they
|
||
* are. An account gets one with its first upload.
|
||
*
|
||
* Accounts are made from the command line (`tools/make-user.php`) or by an administrator through
|
||
* the `users` routes (#179); nobody can sign up over the wire.
|
||
*/
|
||
final class UserStore
|
||
{
|
||
/**
|
||
* The shortest password an account may be given. The sign-in form knows the same number
|
||
* (`loginRules.ts`) so it can say so without asking, but this is the one that decides: a
|
||
* client is free to send anything.
|
||
*/
|
||
public const MIN_PASSWORD_LENGTH = 8;
|
||
|
||
public const ROLES = ['admin', 'user'];
|
||
|
||
/** What a folder name looks like, for the file stores to check a path against. */
|
||
public const FOLDER_PATTERN = '[a-f0-9]{12}';
|
||
|
||
public function __construct(private readonly string $file) {}
|
||
|
||
public static function isValidName(string $name): bool
|
||
{
|
||
return (bool) preg_match('/^[a-z0-9][a-z0-9._-]{1,31}$/i', $name);
|
||
}
|
||
|
||
public static function isValidPassword(string $password): bool
|
||
{
|
||
return strlen($password) >= 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<array{username:string,role:string,createdAt:string}>
|
||
*/
|
||
public function accounts(): array
|
||
{
|
||
return array_map(
|
||
static fn(array $u): array => [
|
||
'username' => $u['username'],
|
||
'role' => $u['role'],
|
||
'createdAt' => $u['createdAt'],
|
||
],
|
||
$this->all(),
|
||
);
|
||
}
|
||
|
||
/**
|
||
* The account's own folder for uploads. With `$create` an account without one is given one;
|
||
* without it, '' means it has never uploaded anything.
|
||
*/
|
||
public function folderOf(string $username, bool $create = false): string
|
||
{
|
||
$user = $this->find($username);
|
||
if ($user === null) {
|
||
return '';
|
||
}
|
||
if ($user['folder'] !== '' || !$create) {
|
||
return $user['folder'];
|
||
}
|
||
$taken = array_column($this->all(), 'folder');
|
||
do {
|
||
$folder = bin2hex(random_bytes(6));
|
||
} while (in_array($folder, $taken, true));
|
||
$this->change($username, static fn(array $u): array => ['folder' => $folder] + $u);
|
||
return $folder;
|
||
}
|
||
|
||
/**
|
||
* Which account each folder belongs to. A folder missing here belonged to an account that was
|
||
* deleted; its files stay, for administrators to see (#344).
|
||
*
|
||
* @return array<string,string>
|
||
*/
|
||
public function folderOwners(): array
|
||
{
|
||
$owners = [];
|
||
foreach ($this->all() as $user) {
|
||
if ($user['folder'] !== '') {
|
||
$owners[$user['folder']] = $user['username'];
|
||
}
|
||
}
|
||
return $owners;
|
||
}
|
||
|
||
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'),
|
||
'folder' => $existing['folder'] ?? '',
|
||
];
|
||
$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<array{username:string,password:string,role:string,createdAt:string,folder:string}> */
|
||
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,folder: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<array{username:string,password:string,role:string,createdAt:string,folder:string}> */
|
||
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'] : '',
|
||
'folder' => is_string($user['folder'] ?? null) && preg_match('/^' . self::FOLDER_PATTERN . '$/', $user['folder'])
|
||
? $user['folder']
|
||
: '',
|
||
];
|
||
}
|
||
}
|
||
return $users;
|
||
}
|
||
|
||
/** @param list<array{username:string,password:string,role:string,createdAt:string,folder:string}> $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');
|
||
}
|
||
}
|