<?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] ?? '[ ]';
}