dir) && !mkdir($this->dir, 0775, true) && !is_dir($this->dir)) { throw new RuntimeException("Cannot create directory {$this->dir}"); } } /** The hash-and-extension half of a stored name, as a regular expression without delimiters. */ abstract protected static function filePattern(): string; /** A stored file name as it appears in a project document, with or without its folder. */ public static function isValidName(string $name): bool { return (bool) preg_match('#^(?:' . UserStore::FOLDER_PATTERN . '/)?' . static::filePattern() . '$#', $name); } /** The folder a stored name lies in, '' for a file that belongs to nobody. */ public static function folderOf(string $name): string { $slash = strpos($name, '/'); return $slash === false ? '' : substr($name, 0, $slash); } public function exists(string $file): bool { return static::isValidName($file) && is_file($this->dir . '/' . $file); } /** * What the picker of its kind offers, newest first: the files of `$folder` and the ones that * belong to nobody, or with `$seesAll` every file there is. `owner` is the account a file * belongs to, null for nobody, '' for an account that was deleted; `deletable` says whether * the one asking may try (whether a project still uses it is only known when they do). * * @param array $owners folder => account, see `UserStore::folderOwners` * @return list> */ public function list(string $folder, bool $seesAll, array $owners): array { $items = $this->listIn('', null, $seesAll); foreach (glob($this->dir . '/*', GLOB_ONLYDIR) ?: [] as $path) { $name = basename($path); if (!preg_match('/^' . UserStore::FOLDER_PATTERN . '$/', $name) || (!$seesAll && $name !== $folder)) { continue; } array_push($items, ...$this->listIn($name, $owners[$name] ?? '', true)); } usort($items, static fn(array $a, array $b): int => strcmp($b['updatedAt'], $a['updatedAt'])); return $items; } public function delete(string $file): bool { if (!$this->exists($file)) { return false; } [$dir, $base] = $this->split($file); if (!unlink($dir . '/' . $base)) { return false; } OriginalName::delete($dir, $base); return true; } /** * Keeps bytes that passed the store's checks under their hash in `$folder`, with `$originalName` * beside them as a caption (see OriginalName), and returns the record. Bytes stored there before * are not written again; a new name renames them. * * @param array $details what the record says between the name and the size * @return array */ protected function keep(string $bytes, string $extension, string $originalName, string $folder, array $details = []): array { if (!preg_match('/^' . UserStore::FOLDER_PATTERN . '$/', $folder)) { throw new InvalidArgumentException("Invalid folder: {$folder}"); } $dir = $this->dir . '/' . $folder; if (!is_dir($dir) && !mkdir($dir, 0775, true) && !is_dir($dir)) { throw new RuntimeException("Cannot create directory {$dir}"); } $base = hash('sha256', $bytes) . '.' . $extension; if (!is_file($dir . '/' . $base)) { AtomicFile::write($dir . '/' . $base, $bytes, AtomicFile::SERVED, 'stored file'); } OriginalName::write($dir, $base, $originalName); return ['file' => "{$folder}/{$base}", 'name' => OriginalName::read($dir, $base), ...$details, 'bytes' => strlen($bytes)]; } /** * What a record of a stored file says between its name and its size. An image reads its pixel * size here; a font or a sound has nothing to read without decoding it. * * @return array */ protected function details(string $path): array { return []; } /** @return list> */ private function listIn(string $folder, ?string $owner, bool $deletable): array { $dir = $folder === '' ? $this->dir : $this->dir . '/' . $folder; $prefix = $folder === '' ? '' : $folder . '/'; $items = []; foreach (glob($dir . '/*.*') ?: [] as $path) { $base = basename($path); // Sidecars share the directory and are skipped: `{hash}.png.json` is not a valid name. if (!static::isValidName($base)) { continue; } $items[] = [ 'file' => $prefix . $base, 'name' => OriginalName::read($dir, $base), ...$this->details($path), 'bytes' => (int) filesize($path), 'updatedAt' => gmdate('c', (int) filemtime($path)), 'owner' => $owner, 'deletable' => $deletable, ]; } return $items; } /** @return array{0:string,1:string} the directory a stored name lies in, and its last part */ private function split(string $file): array { $folder = self::folderOf($file); return $folder === '' ? [$this->dir, $file] : [$this->dir . '/' . $folder, substr($file, strlen($folder) + 1)]; } }