<?php
if (!defined('HOBBES')) { http_response_code(403); exit; }
// ── Read/write helpers ────────────────────────────────────────────────────────
// Writes use a temp file + rename() for atomicity — no flock() needed.
// flock() can block indefinitely on some shared-host filesystems (NFS, etc.).
function storage_read(string $file): ?array {
if (!file_exists($file)) return null;
$raw = @file_get_contents($file);
if ($raw === false) return null;
return json_decode($raw, true) ?: null;
}
function storage_write(string $file, array $data, bool $pretty = true): bool {
$dir = dirname($file);
if (!is_dir($dir)) mkdir($dir, 0755, true);
$tmp = $file . '.tmp.' . getmypid();
// JSON_INVALID_UTF8_SUBSTITUTE: replace invalid byte sequences (e.g. CP850/CP437
// in legacy OS/2 text files) with U+FFFD instead of aborting with false.
$flags = JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE | ($pretty ? JSON_PRETTY_PRINT : 0);
$json = json_encode($data, $flags);
if ($json === false) return false; // should not happen with substitute flag, but guard anyway
$ok = @file_put_contents($tmp, $json);
if ($ok === false) return false;
return @rename($tmp, $file);
}
function storage_delete(string $file): bool {
if (file_exists($file)) return unlink($file);
return false;
}
// ── Settings (static cache — read once per request) ───────────────────────────
function settings_load(): array {
static $cache = null;
if ($cache !== null) return $cache;
$s = storage_read(SETTINGS_FILE);
if (!$s) {
$s = DEFAULT_SETTINGS;
settings_save($s);
}
$cache = array_replace_recursive(DEFAULT_SETTINGS, $s);
return $cache;
}
function settings_save(array $s): void {
static $cache = null;
$cache = null; // invalidate cache on write
storage_write(SETTINGS_FILE, $s);
}
// ── Users ─────────────────────────────────────────────────────────────────────
function user_file(string $username): string {
return USERS_DIR . '/' . preg_replace('/[^a-zA-Z0-9_-]/', '', $username) . '.json';
}
function user_load(string $username): ?array {
return storage_read(user_file($username));
}
function user_save(array $user): bool {
return storage_write(user_file($user['username']), $user);
}
function user_exists(string $username): bool {
return file_exists(user_file($username));
}
function user_find_by_email(string $email): ?array {
foreach (glob(USERS_DIR . '/*.json') as $f) {
$u = storage_read($f);
if ($u && strtolower($u['email']) === strtolower($email)) return $u;
}
return null;
}
function user_list(): array {
$users = [];
foreach (glob(USERS_DIR . '/*.json') as $f) {
$u = storage_read($f);
if ($u) $users[] = $u;
}
usort($users, fn($a, $b) => strcmp($a['username'], $b['username']));
return $users;
}
function user_create(string $username, string $password, string $email, string $role, ?string $invited_by = null): array|false {
if (user_exists($username)) return false;
if (user_find_by_email($email)) return false;
$user = [
'username' => $username,
'password' => password_hash($password, PASSWORD_BCRYPT),
'email' => $email,
'role' => $role,
'created' => time(),
'invited_by' => $invited_by,
'active' => true,
];
return user_save($user) ? $user : false;
}
function user_authenticate(string $username, string $password): ?array {
$u = user_load($username);
if (!$u || empty($u['active'])) return null;
return password_verify($password, $u['password']) ? $u : null;
}
// ── Invites ───────────────────────────────────────────────────────────────────
function invite_file(string $code): string {
return INVITES_DIR . '/' . preg_replace('/[^a-zA-Z0-9]/', '', $code) . '.json';
}
function invite_create(string $by_user, string $role): string {
$code = bin2hex(random_bytes(12));
storage_write(invite_file($code), [
'code' => $code,
'created_by' => $by_user,
'role' => $role,
'used' => false,
'used_by' => null,
'created' => time(),
]);
return $code;
}
function invite_load(string $code): ?array {
return storage_read(invite_file($code));
}
function invite_mark_used(string $code, string $username): void {
$inv = invite_load($code);
if ($inv) {
$inv['used'] = true;
$inv['used_by'] = $username;
$inv['used_at'] = time();
storage_write(invite_file($code), $inv);
}
}
function invite_list_by_user(string $username): array {
$list = [];
foreach (glob(INVITES_DIR . '/*.json') as $f) {
$inv = storage_read($f);
if ($inv && $inv['created_by'] === $username) $list[] = $inv;
}
usort($list, fn($a, $b) => $b['created'] - $a['created']);
return $list;
}
function invite_list_all(): array {
$list = [];
foreach (glob(INVITES_DIR . '/*.json') as $f) {
$inv = storage_read($f);
if ($inv) $list[] = $inv;
}
usort($list, fn($a, $b) => $b['created'] - $a['created']);
return $list;
}
// ── Files (metadata) ──────────────────────────────────────────────────────────
function file_meta_path(string $id): string {
return FILES_DIR . '/' . preg_replace('/[^a-zA-Z0-9_]/', '', $id) . '.json';
}
function file_meta_load(string $id): ?array {
return storage_read(file_meta_path($id));
}
function file_meta_save(array $meta): bool {
$ok = storage_write(file_meta_path($meta['id']), $meta);
if ($ok) catalog_sync($meta);
return $ok;
}
function file_meta_delete(string $id): void {
storage_delete(file_meta_path($id));
catalog_remove($id);
}
function file_meta_find_by_url_path(string $url_path): ?array {
foreach (glob(FILES_DIR . '/*.json') as $f) {
$m = storage_read($f);
if (!$m) continue;
$expected = category_upload_path($m['category'] ?? '') . '/' . $m['original_name'];
if ($expected === $url_path) return $m;
}
return null;
}
function file_meta_list(bool $approved_only = true, ?string $category = null): array {
$list = [];
foreach (glob(FILES_DIR . '/*.json') as $f) {
$m = storage_read($f);
if (!$m) continue;
if ($approved_only && empty($m['approved'])) continue;
if ($category !== null && ($m['category'] ?? '') !== $category) continue;
$list[] = $m;
}
usort($list, fn($a, $b) => $b['uploaded'] - $a['uploaded']);
return $list;
}
// ── File catalog (fast listing index) ────────────────────────────────────────
// A single compact JSON keyed by file ID. Maintained automatically via
// file_meta_save() and file_meta_delete(). Eliminates per-request glob+decode
// of every individual file JSON when only listing data is needed.
// Fields stored in the catalog (all fields needed by home/browse/admin listing)
const CATALOG_FIELDS = [
'id','original_name','title','description','category',
'size','uploaded','approved','uploader','version','downloads',
];
function catalog_load(?array $replace = null): array {
static $cache = null;
if ($replace !== null) { $cache = $replace; return $cache; }
if ($cache === null) { $cache = storage_read(CATALOG_FILE) ?? []; }
return $cache;
}
function catalog_sync(array $meta): void {
$cat = catalog_load();
$entry = [];
foreach (CATALOG_FIELDS as $k) {
$entry[$k] = $meta[$k] ?? null;
}
$cat[$meta['id']] = $entry;
storage_write(CATALOG_FILE, $cat, false); // compact JSON, not pretty-printed
catalog_load($cat); // update in-memory cache
}
function catalog_remove(string $id): void {
$cat = catalog_load();
unset($cat[$id]);
storage_write(CATALOG_FILE, $cat, false);
catalog_load($cat);
}
function catalog_list(bool $approved_only = true, ?string $category = null): array {
$result = [];
foreach (catalog_load() as $entry) {
if ($approved_only && empty($entry['approved'])) continue;
if ($category !== null && ($entry['category'] ?? '') !== $category) continue;
$result[] = $entry;
}
usort($result, fn($a, $b) => ($b['uploaded'] ?? 0) - ($a['uploaded'] ?? 0));
return $result;
}
// Full rebuild — run once after a bulk import or to repair the catalog.
function catalog_rebuild(): void {
$cat = [];
foreach (glob(FILES_DIR . '/*.json') ?: [] as $f) {
$m = storage_read($f);
if (!$m || empty($m['id'])) continue;
$entry = [];
foreach (CATALOG_FIELDS as $k) {
$entry[$k] = $m[$k] ?? null;
}
$cat[$m['id']] = $entry;
}
storage_write(CATALOG_FILE, $cat, false);
catalog_load($cat);
}
function file_physical_path(array $meta): string {
// New format: stored_name is a relative path containing '/'
// e.g. "utilities/archivers/myfile.zip"
if (str_contains($meta['stored_name'], '/')) {
return UPLOADS_DIR . '/' . $meta['stored_name'];
}
// Legacy format: uploads/{id}/{stored_name}
return UPLOADS_DIR . '/' . $meta['id'] . '/' . $meta['stored_name'];
}
// ── Categories (static cache) ─────────────────────────────────────────────────
function categories_load(?array $replace = null): array {
static $cache = null;
if ($replace !== null) { $cache = $replace; return $cache; }
if ($cache === null) {
$data = storage_read(CATS_FILE);
$cache = $data['categories'] ?? [];
}
return $cache;
}
function categories_save(array $cats): void {
storage_write(CATS_FILE, ['categories' => $cats]);
categories_load($cats); // update in-memory cache
}
function category_by_slug(string $slug): ?array {
foreach (categories_load() as $c) {
if ($c['slug'] === $slug) return $c;
}
return null;
}
function category_by_id(string $id): ?array {
foreach (categories_load() as $c) {
if ($c['id'] === $id) return $c;
}
return null;
}
// ── Pool (pending approval) ───────────────────────────────────────────────────
function pool_list(): array {
$items = [];
foreach (glob(POOL_DIR . '/*') ?: [] as $f) {
if (str_ends_with($f, '.meta.json')) continue;
if (is_dir($f)) {
$items[] = [
'pool_file' => $f,
'name' => basename($f),
'size' => 0,
'mtime' => filemtime($f),
'meta' => [],
'source' => 'ftp_folder',
'file_count' => pool_folder_file_count($f),
];
continue;
}
$name = basename($f);
$meta_file = $f . '.meta.json';
$meta = file_exists($meta_file) ? storage_read($meta_file) : [];
$items[] = [
'pool_file' => $f,
'name' => $name,
'size' => filesize($f),
'mtime' => filemtime($f),
'meta' => $meta ?? [],
'source' => 'ftp',
];
}
foreach (glob(FILES_DIR . '/*.json') ?: [] as $f) {
$m = storage_read($f);
if ($m && empty($m['approved'])) {
$items[] = [
'pool_file' => null,
'name' => $m['original_name'],
'size' => $m['size'] ?? 0,
'mtime' => $m['uploaded'],
'meta' => $m,
'source' => $m['source'] ?? 'web',
'id' => $m['id'],
];
}
}
usort($items, fn($a, $b) => $b['mtime'] - $a['mtime']);
return $items;
}
// ── Download log ──────────────────────────────────────────────────────────────
// Rolling log of programmatic (Basic Auth) downloads — max DLLOG_MAX entries.
// Each entry: {ts, file_id, filename, username, ua, ip}
const DLLOG_MAX = 5000;
function dllog_append(string $file_id, string $filename, string $username, string $ua, string $ip): void {
$log = storage_read(DLLOG_FILE) ?? [];
$log[] = [
'ts' => time(),
'file_id' => $file_id,
'filename' => $filename,
'username' => $username,
'ua' => mb_substr($ua, 0, 200),
'ip' => $ip,
];
// Keep only the most recent entries
if (count($log) > DLLOG_MAX) {
$log = array_slice($log, -DLLOG_MAX);
}
storage_write(DLLOG_FILE, $log, false);
}
function dllog_load(): array {
return array_reverse(storage_read(DLLOG_FILE) ?? []);
}
// ── Archive.org mirror metadata ───────────────────────────────────────────────
// Stored per-file inside the normal file metadata JSON.
// Fields: archiveorg_id (identifier), archiveorg_ts (unix timestamp of last upload)
// ── Pool folder helpers ────────────────────────────────────────────────────────
function pool_folder_file_count(string $dir): int {
if (!is_dir($dir)) return 0;
$count = 0;
$it = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS)
);
foreach ($it as $f) {
if ($f->isFile()) $count++;
}
return $count;
}
// Returns flat list of files in $dir: ['abs'=>…, 'rel'=>…, 'name'=>…]
// rel is the path relative to $dir using forward slashes.
function pool_folder_files(string $dir): array {
$files = [];
if (!is_dir($dir)) return $files;
$base = rtrim(str_replace('\\', '/', realpath($dir)), '/');
$it = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS),
RecursiveIteratorIterator::LEAVES_ONLY
);
foreach ($it as $file) {
if ($file->isFile()) {
$abs = str_replace('\\', '/', $file->getPathname());
$rel = ltrim(substr($abs, strlen($base)), '/');
$files[] = ['abs' => $abs, 'rel' => $rel, 'name' => $file->getFilename()];
}
}
return $files;
}