GitGram — storage.php — GitGram
Hobbes_OS2_Archive / main / v1.12 / includes / storage.php15,223 B↓ Raw
<?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;
}
Ready
GitGram