<?php
define('HOBBES', true);
require_once __DIR__ . '/config.php';
require_once __DIR__ . '/includes/functions.php';
require_once __DIR__ . '/includes/storage.php';
require_once __DIR__ . '/includes/auth.php';
require_once __DIR__ . '/includes/search.php';
require_once __DIR__ . '/includes/markdown.php';
// ── Shared hosting: verify data directory is NOT web-accessible ───────────────
// If this check is reachable, .htaccess on data/ is working.
// (No action needed here — the data/.htaccess handles blocking.)
// Start session; then check for HTTP Basic Auth (wget/curl support)
auth_start();
auth_try_basic();
// ── Parse URL ─────────────────────────────────────────────────────────────────
// Support ?_r=path fallback for hosts where mod_rewrite is not available.
// e.g. index.php?_r=setup → treated as /setup
$uri = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
if ($uri === '/index.php' && !empty($_GET['_r'])) {
$uri = '/' . ltrim(preg_replace('#[^a-zA-Z0-9/_-]#', '', $_GET['_r']), '/');
}
$uri = '/' . trim($uri, '/');
$parts = explode('/', trim($uri, '/'));
$route_params = [];
// ── First-run gate: redirect everything to /setup if no admin exists ──────────
$always_allow = ['login', 'logout', 'register', 'setup', 'check'];
$first_seg = $parts[0] ?? '';
$is_setup_done = !empty(glob(USERS_DIR . '/*.json'));
if (!$is_setup_done && $first_seg !== 'setup' && $first_seg !== 'check') {
redirect('/setup');
}
// ── Access gate ───────────────────────────────────────────────────────────────
// Non-OS/2, non-authenticated visitors may see the landing page and auth pages.
// Browsing, searching, and downloading require an OS/2 browser or an account.
// Exception: /download/.../.json metadata sidecars and /catalog.json are always public.
$browse_only_routes = ['browse', 'search', 'file', 'files', 'download'];
$is_public_meta = ($first_seg === 'download' && str_ends_with($uri, '.json'));
if (!$is_public_meta && in_array($first_seg, $browse_only_routes, true) && !can_access()) {
// For download routes: send a 401 challenge so wget/curl know to use credentials.
// Browsers get the normal redirect to the home page.
if ($first_seg === 'download') {
$settings_tmp = settings_load();
header('WWW-Authenticate: Basic realm="' . addslashes($settings_tmp['site_name']) . '"');
http_response_code(401);
header('Content-Type: text/plain; charset=UTF-8');
die("401 Unauthorized\n\nThis archive requires authentication.\n\nUsage:\n curl -u USERNAME:PASSWORD " . $_SERVER['REQUEST_URI'] . "\n wget --user=USERNAME --password=PASSWORD " . $_SERVER['REQUEST_URI'] . "\n");
}
flash('info', 'An account is required to browse and download files. Please log in or register.');
redirect('/');
}
// ── Router ─────────────────────────────────────────────────────────────────────
$page = null;
// Home
if ($uri === '/') {
$page = 'home';
// Browse: /browse or /browse/{slug}
} elseif ($uri === '/browse') {
$page = 'browse';
} elseif (preg_match('#^/browse/((?:[a-z0-9-]+)(?:/[a-z0-9-]+)*)$#', $uri, $m)) {
$route_params['slug'] = $m[1];
$page = 'browse';
// File detail: /files/{cat/path}/{filename}
} elseif (preg_match('#^/files/(.+)$#', $uri, $m)) {
$route_params['path'] = $m[1];
$page = 'file';
// File edit: /file/edit/{id} (admin, ID-based is fine)
} elseif (preg_match('#^/file/edit/([a-zA-Z0-9_]+)$#', $uri, $m)) {
$route_params['id'] = $m[1];
$page = 'file/edit';
// Legacy: /file/{id} — 301 redirect to canonical URL
} elseif (preg_match('#^/file/([a-zA-Z0-9_]+)$#', $uri, $m)) {
$meta = file_meta_load($m[1]);
if ($meta && !empty($meta['approved'])) {
header('Location: ' . file_url($meta), true, 301);
exit;
}
http_response_code(404);
$page = '404';
// File metadata JSON sidecar (public): /download/{cat/path}/{filename}.json
} elseif (preg_match('#^/download/(.+)\.json$#', $uri, $m)) {
$route_params['path'] = $m[1];
$page = 'download_meta';
// Download: /download/{cat/path}/{filename}
} elseif (preg_match('#^/download/(.+)$#', $uri, $m)) {
$route_params['path'] = $m[1];
$page = 'download';
// Public catalog for mirrors
} elseif ($uri === '/catalog.json') {
$page = 'catalog';
// Mirror info page
} elseif ($uri === '/mirror') {
$page = 'mirror';
// Search
} elseif ($uri === '/search') {
$page = 'search';
// Upload
} elseif ($uri === '/upload') {
$page = 'upload';
// Pool: /pool or /pool/{action}/{id}
} elseif ($uri === '/pool') {
$page = 'pool';
} elseif (preg_match('#^/pool/([a-z_]+)(?:/([a-zA-Z0-9_]+))?$#', $uri, $m)) {
$route_params['action'] = $m[1];
$route_params['id'] = $m[2] ?? '';
$page = 'pool';
// Auth
} elseif ($uri === '/login') {
$page = 'login';
} elseif ($uri === '/logout') {
$page = 'logout';
} elseif ($uri === '/register') {
$page = 'register';
} elseif (preg_match('#^/register/invite(?:/([a-f0-9]+))?$#', $uri, $m)) {
$route_params['code'] = $m[1] ?? null;
$page = 'register';
// Invite management
} elseif ($uri === '/invite') {
$page = 'invite';
// Profile
} elseif ($uri === '/profile') {
$page = 'profile';
// Admin routes
} elseif ($uri === '/admin' || $uri === '/admin/') {
$page = 'admin/index';
} elseif ($uri === '/admin/settings') {
$page = 'admin/settings';
} elseif ($uri === '/admin/css') {
$page = 'admin/css';
} elseif ($uri === '/admin/users') {
$page = 'admin/users';
} elseif (preg_match('#^/admin/user-limits/([a-zA-Z0-9_-]+)$#', $uri, $m)) {
$route_params['username'] = $m[1];
$page = 'admin/user_limits';
} elseif ($uri === '/admin/categories') {
$page = 'admin/categories';
} elseif ($uri === '/admin/landing') {
$page = 'admin/landing';
} elseif ($uri === '/admin/splash') {
$page = 'admin/splash';
} elseif ($uri === '/admin/meta-merge') {
$page = 'admin/meta_merge';
} elseif ($uri === '/admin/bulk-delete') {
$page = 'admin/bulk_delete';
} elseif (preg_match('#^/admin/file-delete/([a-zA-Z0-9_]+)$#', $uri, $m) && $_SERVER['REQUEST_METHOD'] === 'POST') {
require_role('admin');
csrf_check();
$fm = file_meta_load($m[1]);
$cat_id = $fm['category'] ?? '';
if ($fm) {
$path = file_physical_path($fm);
if (file_exists($path)) {
@unlink($path);
$dir = dirname($path);
if ($dir !== UPLOADS_DIR && is_dir($dir) && count(glob($dir . '/*')) === 0) @rmdir($dir);
}
search_remove_file($fm['id']);
file_meta_delete($fm['id']);
flash('success', 'File "' . h($fm['original_name']) . '" permanently deleted.');
} else {
flash('error', 'File not found.');
}
$cat = $cat_id ? category_by_id($cat_id) : null;
redirect($cat ? '/browse/' . category_path_slug(categories_load(), $cat['id']) : '/browse');
} elseif (in_array($uri, ['/admin/rebuild-index', '/admin/rebuild-catalog'], true)) {
// Legacy routes — actions now handled in pages/admin/index.php
redirect('/admin');
} elseif ($uri === '/admin/repair') {
$page = 'admin/repair';
} elseif ($uri === '/admin/reports') {
$page = 'admin/reports';
} elseif ($uri === '/admin/mirror') {
$page = 'admin/mirror';
} elseif ($uri === '/admin/field-replace') {
$page = 'admin/field_replace';
// Setup (first-run)
} elseif ($uri === '/setup') {
$page = 'setup';
// 404
} else {
http_response_code(404);
$page = '404';
}
// ── Dispatch ───────────────────────────────────────────────────────────────────
$page_file = ROOT_DIR . '/pages/' . $page . '.php';
if ($page && file_exists($page_file)) {
include $page_file;
} elseif ($page === '404') {
define('HOBBES_TEMPLATE_OPEN', true);
$page_title = '404 Not Found';
$_page = '';
include ROOT_DIR . '/templates/header.php';
echo '<div class="panel"><div class="panel-title">404 Not Found</div>';
echo '<div class="panel-body"><p>The page you requested was not found.</p>';
echo '<p><a href="/">Return to home</a></p></div></div>';
include ROOT_DIR . '/templates/footer.php';
} else {
http_response_code(500);
echo 'Internal error: page not found.';
}