GitGram — functions.php — GitGram
Hobbes_OS2_Archive / main / v1.07 / includes / functions.php14,441 B↓ Raw
<?php
if (!defined('HOBBES')) { http_response_code(403); exit; }

// ── Unique ID ─────────────────────────────────────────────────────────────────
function hobbes_id(): string {
    return sprintf('%x_%s', time(), bin2hex(random_bytes(4)));
}

// ── Role helpers ──────────────────────────────────────────────────────────────
function role_weight(string $role): int {
    $w = array_flip(ROLES);
    return $w[$role] ?? 0;
}

function role_gte(string $have, string $need): bool {
    return role_weight($have) >= role_weight($need);
}

// ── File size formatting ──────────────────────────────────────────────────────
function format_size(int $bytes): string {
    if ($bytes < 1024)       return $bytes . ' B';
    if ($bytes < 1048576)    return round($bytes / 1024, 1) . ' KB';
    if ($bytes < 1073741824) return round($bytes / 1048576, 1) . ' MB';
    return round($bytes / 1073741824, 2) . ' GB';
}

// ── Safe HTML output ──────────────────────────────────────────────────────────
function h(string $s): string {
    return htmlspecialchars($s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}

// ── Redirect ──────────────────────────────────────────────────────────────────
function redirect(string $path): never {
    header('Location: ' . $path);
    exit;
}

// ── Slug generation ───────────────────────────────────────────────────────────
function slugify(string $s): string {
    $s = strtolower(trim($s));
    $s = preg_replace('/[^a-z0-9]+/', '-', $s);
    return trim($s, '-');
}

// ── Extension helpers ─────────────────────────────────────────────────────────
function get_extension(string $filename): string {
    return strtolower(pathinfo($filename, PATHINFO_EXTENSION));
}

function is_media_file(string $filename): bool {
    return in_array(get_extension($filename), MEDIA_EXTENSIONS, true);
}

function is_os2_file(string $filename): bool {
    return in_array(get_extension($filename), OS2_EXTENSIONS, true);
}

function is_doc_file(string $filename): bool {
    return in_array(get_extension($filename), DOC_EXTENSIONS, true);
}

function is_allowed_file(string $filename): bool {
    return is_media_file($filename) || is_os2_file($filename) || is_doc_file($filename);
}

function max_allowed_size(string $filename): int {
    return is_media_file($filename) ? MAX_MEDIA_SIZE : MAX_FILE_SIZE;
}

// ── CSRF ──────────────────────────────────────────────────────────────────────
function csrf_token(): string {
    if (empty($_SESSION['csrf_token'])) {
        $_SESSION['csrf_token'] = bin2hex(random_bytes(16));
    }
    return $_SESSION['csrf_token'];
}

function csrf_field(): string {
    return '<input type="hidden" name="csrf_token" value="' . h(csrf_token()) . '">';
}

function csrf_check(): void {
    $token = $_POST['csrf_token'] ?? '';
    if (!hash_equals(csrf_token(), $token)) {
        flash('error', 'Invalid form submission. Please try again.');
        redirect('/');
    }
}

// ── Flash messages ────────────────────────────────────────────────────────────
function flash(string $type, string $msg): void {
    $_SESSION['flash'][] = ['type' => $type, 'msg' => $msg];
}

function get_flashes(): array {
    $f = $_SESSION['flash'] ?? [];
    $_SESSION['flash'] = [];
    return $f;
}

// ── Pagination ────────────────────────────────────────────────────────────────
function paginate(array $items, int $per_page, int $page): array {
    $total = count($items);
    $pages = max(1, (int) ceil($total / $per_page));
    $page  = max(1, min($page, $pages));
    $slice = array_slice($items, ($page - 1) * $per_page, $per_page);
    return ['items' => $slice, 'total' => $total, 'pages' => $pages, 'page' => $page];
}

function pagination_links(int $page, int $pages, string $base): string {
    if ($pages <= 1) return '';
    $html = '<div class="pagination">';
    for ($i = 1; $i <= $pages; $i++) {
        $cls = ($i === $page) ? ' class="current"' : '';
        $html .= '<a href="' . h($base . '?page=' . $i) . '"' . $cls . '>' . $i . '</a> ';
    }
    return $html . '</div>';
}

// ── Date formatting ───────────────────────────────────────────────────────────
function fmt_date(int $ts): string {
    return date('Y-m-d', $ts);
}

function fmt_datetime(int $ts): string {
    return date('Y-m-d H:i', $ts);
}

// ── Sanitize filename for storage ─────────────────────────────────────────────
function safe_filename(string $name): string {
    $name = preg_replace('/[^a-zA-Z0-9._-]/', '_', basename($name));
    $name = ltrim($name, '.');
    return $name ?: 'file';
}

// ── OS/2 User Agent detection ─────────────────────────────────────────────────
function is_os2_browser(): bool {
    $ua = $_SERVER['HTTP_USER_AGENT'] ?? '';
    foreach (OS2_UA_PATTERNS as $pat) {
        if (stripos($ua, $pat) !== false) return true;
    }
    return false;
}

// ── Category path breadcrumb ──────────────────────────────────────────────────
function category_breadcrumb(array $categories, ?string $id): array {
    $path = [];
    while ($id !== null) {
        foreach ($categories as $c) {
            if ($c['id'] === $id) {
                array_unshift($path, $c);
                $id = $c['parent'] ?? null;
                continue 2;
            }
        }
        break;
    }
    return $path;
}

// ── Get all categories as tree ────────────────────────────────────────────────
function build_category_tree(array $cats, ?string $parent = null): array {
    $tree = [];
    foreach ($cats as $c) {
        if (($c['parent'] ?? null) === $parent) {
            $c['children'] = build_category_tree($cats, $c['id']);
            $tree[] = $c;
        }
    }
    usort($tree, fn($a, $b) => strcmp($a['name'], $b['name']));
    return $tree;
}

// ── Check if any node in the subtree matches the active id ────────────────────
function cat_tree_has_active(array $node, string $active_id): bool {
    if ($node['id'] === $active_id) return true;
    foreach (($node['children'] ?? []) as $child) {
        if (cat_tree_has_active($child, $active_id)) return true;
    }
    return false;
}

// ── Render category tree as HTML list ─────────────────────────────────────────
// Uses <details>/<summary> for native browser expand/collapse with no JavaScript.
// Old browsers that do not understand <details> display all content (always open).
// The path to the active category is auto-expanded via the `open` attribute.
function render_category_tree(array $tree, string $active_id = ''): string {
    if (empty($tree)) return '';
    $html = '<ul class="cat-tree">';
    foreach ($tree as $c) {
        $has_children   = !empty($c['children']);
        $in_active_path = $has_children && cat_tree_has_active($c, $active_id);
        $is_active      = ($c['id'] === $active_id);

        $cls = $is_active ? ' class="active"' : '';
        $html .= '<li' . $cls . '>';

        if ($has_children) {
            // open attribute auto-expands when this branch contains the active page
            $open  = $in_active_path ? ' open' : '';
            $html .= '<details' . $open . '>'
                   . '<summary class="cat-summary">'
                   . '<a href="/browse/' . h($c['slug']) . '">' . h($c['name']) . '</a>'
                   . '</summary>';
            $html .= render_category_tree($c['children'], $active_id);
            $html .= '</details>';
        } else {
            $html .= '<a href="/browse/' . h($c['slug']) . '">' . h($c['name']) . '</a>';
        }

        $html .= '</li>';
    }
    return $html . '</ul>';
}

// ── File URL helpers ──────────────────────────────────────────────────────────
function file_url(array $meta): string {
    return '/files/' . category_upload_path($meta['category'] ?? '') . '/' . $meta['original_name'];
}

function download_url(array $meta): string {
    return '/download/' . category_upload_path($meta['category'] ?? '') . '/' . $meta['original_name'];
}

// ── Category upload path ──────────────────────────────────────────────────────
// Returns a relative path from UPLOADS_DIR built from category slugs.
// e.g. category "Utilities > Archivers" → "utilities/archivers"
function category_upload_path(string $cat_id): string {
    if (!$cat_id) return 'uncategorized';
    $cats       = categories_load();
    $breadcrumb = category_breadcrumb($cats, $cat_id);
    if (empty($breadcrumb)) return 'uncategorized';
    return implode('/', array_map(fn($c) => $c['slug'], $breadcrumb));
}

// ── Category select options (recursive) ──────────────────────────────────────
// Shows full path for each option, e.g. "OS2 / Apps / Calc"
function render_cat_options(array $tree, string $selected = '', int $depth = 0, string $path = ''): void {
    foreach ($tree as $c) {
        $full_path = $path !== '' ? $path . ' / ' . $c['name'] : $c['name'];
        $sel       = ($selected === $c['id']) ? ' selected' : '';
        echo '<option value="' . h($c['id']) . '"' . $sel . '>' . h($full_path) . '</option>';
        if (!empty($c['children'])) render_cat_options($c['children'], $selected, $depth + 1, $full_path);
    }
}

// ── Category find-or-create (for batch import) ────────────────────────────────
// Looks up a category by name+parent in $cats; creates it if absent.
// Mutates $cats in place and returns the category ID.
function category_find_or_create(array &$cats, string $name, ?string $parent_id): string {
    $name = trim($name) ?: 'Unknown';
    foreach ($cats as $c) {
        if (strtolower($c['name']) === strtolower($name) && ($c['parent'] ?? null) === $parent_id) {
            return $c['id'];
        }
    }
    $base_slug = slugify($name) ?: 'category';
    $slug      = $base_slug;
    $suffix    = 2;
    while (true) {
        $taken = false;
        foreach ($cats as $c) {
            if ($c['slug'] === $slug) { $taken = true; break; }
        }
        if (!$taken) break;
        $slug = $base_slug . '-' . $suffix++;
    }
    $id     = hobbes_id();
    $cats[] = ['id' => $id, 'name' => $name, 'slug' => $slug, 'parent' => $parent_id, 'desc' => ''];
    return $id;
}

// ── Category upload path from an explicit cats array ──────────────────────────
// Like category_upload_path() but uses the supplied array instead of the
// static-cached categories_load() — needed during batch import when the
// category list has been modified but not yet flushed to disk.
function category_upload_path_from_cats(array $cats, string $cat_id): string {
    if (!$cat_id) return 'uncategorized';
    $breadcrumb = category_breadcrumb($cats, $cat_id);
    if (empty($breadcrumb)) return 'uncategorized';
    return implode('/', array_map(fn($c) => $c['slug'], $breadcrumb));
}

// ── Recursive directory removal ────────────────────────────────────────────────
function rmdir_recursive(string $dir): void {
    if (!is_dir($dir)) return;
    $it = new RecursiveIteratorIterator(
        new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS),
        RecursiveIteratorIterator::CHILD_FIRST
    );
    foreach ($it as $f) {
        $f->isDir() ? @rmdir($f->getPathname()) : @unlink($f->getPathname());
    }
    @rmdir($dir);
}

// ── File icon by extension ────────────────────────────────────────────────────
function file_icon(string $ext): string {
    $icons = [
        'zip' => '[ZIP]', 'wpi' => '[WPI]', 'exe' => '[EXE]',
        'cmd' => '[CMD]', 'bat' => '[BAT]', 'inf' => '[INF]',
        'rpm' => '[RPM]', 'tar' => '[TAR]', 'gz'  => '[GZ ]',
        'lzh' => '[LZH]', 'arj' => '[ARJ]', '7z'  => '[7Z ]',
        'jpg' => '[IMG]', 'jpeg'=> '[IMG]', 'png' => '[IMG]',
        'gif' => '[GIF]', 'bmp' => '[BMP]',
        'wav' => '[WAV]', 'mp3' => '[MP3]', 'mid' => '[MID]',
        'avi' => '[AVI]', 'mp4' => '[MP4]', 'mpg' => '[MPG]',
        'txt' => '[TXT]', 'nfo' => '[NFO]', 'doc' => '[DOC]',
        'pdf' => '[PDF]', 'htm' => '[HTM]', 'html'=> '[HTM]',
    ];
    return $icons[$ext] ?? '[   ]';
}
Ready
GitGram