<?php
if (!defined('HOBBES')) { http_response_code(403); exit; }
require_role('editor');
$cats = categories_load();
$action = $route_params['action'] ?? 'list';
$item_id = $route_params['id'] ?? '';
// Extensions excluded from bulk pool operations (folder import, approve-all).
// HTML/HTM files are description sidecars from the old Hobbes archive structure;
// PHP files are never safe to auto-approve. Individual imports are unaffected.
define('POOL_BULK_EXCLUDE', ['html', 'htm', 'php']);
// ── Handle approvals ────────────────────────────────────────────────────────
// Approving a web-upload item also saves any metadata edits made inline.
// Category changes move the physical file so stored_name stays in sync.
if ($_SERVER['REQUEST_METHOD'] === 'POST' && $action === 'approve') {
csrf_check();
$meta = file_meta_load($item_id);
if ($meta && empty($meta['approved'])) {
$user = current_user();
// Save metadata fields submitted alongside the approval form
if (isset($_POST['title'])) {
$old_category = $meta['category'] ?? '';
$new_category = trim($_POST['category'] ?? $old_category);
$old_path = file_physical_path($meta);
$meta['title'] = trim($_POST['title'] ?? $meta['title']);
$meta['description'] = trim($_POST['desc'] ?? $meta['description']);
$meta['version'] = trim($_POST['version'] ?? '');
$meta['author'] = trim($_POST['author'] ?? '');
$meta['homepage'] = trim($_POST['homepage'] ?? '');
$meta['category'] = $new_category;
$meta['os2_version'] = trim($_POST['os2_version'] ?? '');
$meta['tags'] = trim($_POST['tags'] ?? '');
$meta['license'] = trim($_POST['license'] ?? '');
$meta['requirements'] = trim($_POST['requirements'] ?? '');
if ($old_category !== $new_category && file_exists($old_path)) {
$new_cat_path = category_upload_path($new_category);
$new_dir = UPLOADS_DIR . '/' . $new_cat_path;
if (!is_dir($new_dir)) mkdir($new_dir, 0755, true);
$new_file = $new_dir . '/' . $meta['original_name'];
if (file_exists($new_file) && realpath($new_file) !== realpath($old_path)) {
flash('error', 'A file named "' . h($meta['original_name']) . '" already exists in the target category. Category not changed.');
$meta['category'] = $old_category;
} elseif (@rename($old_path, $new_file)) {
$meta['stored_name'] = $new_cat_path . '/' . $meta['original_name'];
$old_dir = dirname($old_path);
if ($old_dir !== UPLOADS_DIR && is_dir($old_dir) && !glob($old_dir . '/*')) {
@rmdir($old_dir);
}
} else {
flash('error', 'Could not move the file on disk. Category not changed.');
$meta['category'] = $old_category;
}
}
}
$meta['approved'] = true;
$meta['approved_by'] = $user['username'];
$meta['approved_at'] = time();
file_meta_save($meta);
search_index_file($meta);
flash('success', 'File "' . h($meta['original_name']) . '" approved and added to archive.');
}
redirect('/pool');
}
if ($_SERVER['REQUEST_METHOD'] === 'POST' && $action === 'reject') {
csrf_check();
$meta = file_meta_load($item_id);
if ($meta && empty($meta['approved'])) {
$path = file_physical_path($meta);
if (file_exists($path)) unlink($path);
// Legacy: clean up the per-file id directory if it exists and is empty
if (!str_contains($meta['stored_name'], '/')) {
$dir = UPLOADS_DIR . '/' . $meta['id'];
if (is_dir($dir)) @rmdir($dir);
}
search_remove_file($meta['id']);
file_meta_delete($meta['id']);
flash('success', 'File rejected and removed.');
}
redirect('/pool');
}
// ── Handle FTP pool import (single file) ────────────────────────────────────
if ($_SERVER['REQUEST_METHOD'] === 'POST' && $action === 'import') {
csrf_check();
$pool_file = POOL_DIR . '/' . safe_filename($_POST['pool_file'] ?? '');
if (!file_exists($pool_file)) {
flash('error', 'Pool file not found.');
redirect('/pool');
}
$orig = safe_filename(basename($pool_file));
$title = trim($_POST['title'] ?? '');
$desc = trim($_POST['desc'] ?? '');
$version = trim($_POST['version'] ?? '');
$author = trim($_POST['author'] ?? '');
$homepage = trim($_POST['homepage'] ?? '');
$cat_id = trim($_POST['category'] ?? '');
$os2_ver = trim($_POST['os2_version'] ?? '');
$tags = trim($_POST['tags'] ?? '');
$license = trim($_POST['license'] ?? '');
$requires = trim($_POST['requirements'] ?? '');
$approve = !empty($_POST['approve_now']);
$errs = [];
if (!$title) $errs[] = 'Title required.';
if (!$desc) $errs[] = 'Description required.';
if (!$author) $errs[] = 'Author required.';
if (!$cat_id) $errs[] = 'Category required.';
if (!is_allowed_file($orig)) $errs[] = 'File type not allowed.';
if (empty($errs)) {
$id = hobbes_id();
$cat_path = category_upload_path($cat_id);
$dest_dir = UPLOADS_DIR . '/' . $cat_path;
if (!is_dir($dest_dir)) mkdir($dest_dir, 0755, true);
$dest_file = $dest_dir . '/' . $orig;
if (file_exists($dest_file)) {
$errs[] = 'A file named "' . h($orig) . '" already exists in that category.';
} elseif (!rename($pool_file, $dest_file)) {
$errs[] = 'Failed to move file from pool.';
} else {
@unlink($pool_file . '.meta.json');
$user = current_user();
$meta = [
'id' => $id,
'original_name' => $orig,
'stored_name' => $cat_path . '/' . $orig,
'title' => $title,
'description' => $desc,
'version' => $version,
'author' => $author,
'homepage' => $homepage,
'category' => $cat_id,
'os2_version' => $os2_ver,
'tags' => $tags,
'license' => $license,
'requirements' => $requires,
'size' => filesize($dest_file),
'uploader' => $user['username'],
'uploaded' => time(),
'approved' => $approve,
'approved_by' => $approve ? $user['username'] : null,
'approved_at' => $approve ? time() : null,
'downloads' => 0,
'source' => 'ftp',
];
file_meta_save($meta);
if ($approve) search_index_file($meta);
flash('success', 'Pool file imported' . ($approve ? ' and approved' : ' (pending approval)') . '.');
redirect('/pool');
}
}
if (!empty($errs)) {
foreach ($errs as $e) flash('error', $e);
redirect('/pool');
}
}
// ── Handle FTP pool folder batch import ─────────────────────────────────────
if ($_SERVER['REQUEST_METHOD'] === 'POST' && $action === 'import_folder') {
csrf_check();
set_time_limit(600);
$folder_name = basename($_POST['pool_folder'] ?? '');
$folder_path = POOL_DIR . '/' . $folder_name;
// Security: ensure path resolves inside POOL_DIR
$real_pool = realpath(POOL_DIR);
$real_folder = $folder_name ? realpath($folder_path) : false;
if (!$real_folder || !str_starts_with($real_folder, $real_pool . DIRECTORY_SEPARATOR) || !is_dir($folder_path)) {
flash('error', 'Folder not found in pool.');
redirect('/pool');
}
$approve = !empty($_POST['approve_now']);
$user = current_user();
// Load categories fresh, bypassing static cache
$cats_data = storage_read(CATS_FILE);
$cats = $cats_data['categories'] ?? [];
// Root folder → top-level category
$root_cat_name = ucwords(str_replace(['-', '_'], ' ', $folder_name));
$root_cat_id = category_find_or_create($cats, $root_cat_name, null);
// Pre-scan folder structure to create all categories upfront, then persist
// before touching any files. This ensures category IDs are always saved to
// disk even if the import is interrupted mid-way.
$all_files = pool_folder_files($folder_path);
foreach ($all_files as $finfo) {
$rel_dir = dirname($finfo['rel']);
if ($rel_dir === '.') continue;
$parts = explode('/', str_replace('\\', '/', $rel_dir));
$par_id = $root_cat_id;
foreach ($parts as $part) {
$cat_name = ucwords(str_replace(['-', '_'], ' ', $part));
$par_id = category_find_or_create($cats, $cat_name, $par_id);
}
}
categories_save($cats); // persist categories before any files are moved
$imported = 0;
$skipped = 0;
$errors = [];
foreach ($all_files as $finfo) {
$filename = $finfo['name'];
$rel = $finfo['rel']; // e.g. "subdir/file.zip" or "file.zip"
$abs = $finfo['abs'];
if (!is_allowed_file($filename) || in_array(get_extension($filename), POOL_BULK_EXCLUDE, true)) {
$skipped++;
continue;
}
// Determine target category from subdirectory path
$rel_dir = dirname($rel);
$cat_id = $root_cat_id;
if ($rel_dir !== '.') {
$parts = explode('/', str_replace('\\', '/', $rel_dir));
$par_id = $root_cat_id;
foreach ($parts as $part) {
$cat_name = ucwords(str_replace(['-', '_'], ' ', $part));
$par_id = category_find_or_create($cats, $cat_name, $par_id);
}
$cat_id = $par_id;
}
$orig = safe_filename($filename);
$cat_path = category_upload_path_from_cats($cats, $cat_id);
$dest_dir = UPLOADS_DIR . '/' . $cat_path;
if (!is_dir($dest_dir)) mkdir($dest_dir, 0755, true);
$dest_file = $dest_dir . '/' . $orig;
if (file_exists($dest_file)) {
$errors[] = 'Skipped (duplicate): ' . $rel;
$skipped++;
continue;
}
if (!rename($abs, $dest_file)) {
$errors[] = 'Failed to move: ' . $rel;
$skipped++;
continue;
}
// Derive a readable title from filename; fall back to "Unknown"
$name_part = pathinfo($filename, PATHINFO_FILENAME);
$derived_title = ucwords(str_replace(['-', '_', '.'], ' ', $name_part));
$id = hobbes_id();
$meta = [
'id' => $id,
'original_name' => $orig,
'stored_name' => $cat_path . '/' . $orig,
'title' => $derived_title ?: 'Unknown',
'description' => 'Unknown',
'version' => 'Unknown',
'author' => 'Unknown',
'homepage' => '',
'category' => $cat_id,
'os2_version' => '',
'tags' => '',
'license' => '',
'requirements' => '',
'size' => filesize($dest_file),
'uploader' => $user['username'],
'uploaded' => time(),
'approved' => $approve,
'approved_by' => $approve ? $user['username'] : null,
'approved_at' => $approve ? time() : null,
'downloads' => 0,
'source' => 'ftp',
];
file_meta_save($meta);
if ($approve) search_index_file($meta);
$imported++;
}
// Persist the updated (possibly expanded) category tree
categories_save($cats);
// Remove the now-empty pool folder
rmdir_recursive($folder_path);
$msg = "Folder \"{$folder_name}\" imported: {$imported} file(s) added";
if ($skipped) $msg .= ", {$skipped} skipped";
flash('success', $msg . '.');
foreach (array_slice($errors, 0, 10) as $e) flash('error', $e);
redirect('/pool');
}
// ── Handle FTP pool folder rejection ────────────────────────────────────────
if ($_SERVER['REQUEST_METHOD'] === 'POST' && $action === 'reject_folder') {
csrf_check();
$folder_name = basename($_POST['pool_folder'] ?? '');
$folder_path = POOL_DIR . '/' . $folder_name;
$real_pool = realpath(POOL_DIR);
$real_folder = $folder_name ? realpath($folder_path) : false;
if ($real_folder && str_starts_with($real_folder, $real_pool . DIRECTORY_SEPARATOR) && is_dir($folder_path)) {
rmdir_recursive($folder_path);
flash('success', 'Folder "' . $folder_name . '" deleted from pool.');
} else {
flash('error', 'Folder not found in pool.');
}
redirect('/pool');
}
// ── Approve all pending web uploads ─────────────────────────────────────────
if ($_SERVER['REQUEST_METHOD'] === 'POST' && $action === 'approve_all') {
csrf_check();
$user = current_user();
$now = time();
$count = 0;
// Approve every record in data/files/ that is still pending.
// This includes both web uploads (source=web) and FTP files that
// were imported without immediate approval (source=ftp).
// Unimported FTP pool files and folders have no files/ record yet
// and are therefore unaffected.
foreach (file_meta_list(false) as $meta) {
if (!empty($meta['approved'])) continue;
if (in_array(get_extension($meta['original_name'] ?? ''), POOL_BULK_EXCLUDE, true)) continue;
$meta['approved'] = true;
$meta['approved_by'] = $user['username'];
$meta['approved_at'] = $now;
file_meta_save($meta);
search_index_file($meta);
$count++;
}
flash('success', $count ? "{$count} file(s) approved." : 'Nothing pending approval.');
redirect('/pool');
}
$pool_items = pool_list();
$page_title = 'Approval Pool';
$_page = 'pool';
include ROOT_DIR . '/templates/header.php';
?>
<div class="panel">
<div class="panel-title">Approval Pool (<?php echo count($pool_items); ?> items)</div>
<div class="panel-body">
<p class="muted" style="margin-bottom:8px;">
Files uploaded via the web form or FTP appear here for review.
Editors and Admins can approve, reject, or edit metadata before publishing to the archive.
FTP folders are batch-imported: sub-folders become sub-categories and required fields
default to <em>Unknown</em> when no metadata is available.
</p>
<?php
// Count every item that has a file record (imported or web-uploaded) and
// is still pending — these are exactly what approve_all will act on.
$n_approvable = count(array_filter($pool_items, fn($i) =>
!empty($i['id']) && !in_array(get_extension($i['name'] ?? ''), POOL_BULK_EXCLUDE, true)
));
?>
<?php if ($n_approvable > 0): ?>
<div style="display:flex;align-items:center;gap:12px;padding:8px 10px;
background:#e8ffe8;border:1px solid #006600;margin-bottom:12px;">
<span><?php echo $n_approvable; ?> file(s) pending approval.</span>
<form method="post" action="/pool/approve_all" style="margin:0;">
<?php echo csrf_field(); ?>
<button type="submit" class="btn btn-primary"
onclick="return confirm('Approve all <?php echo $n_approvable; ?> pending file(s)?');">
Approve All
</button>
</form>
</div>
<?php endif; ?>
<?php if (empty($pool_items)): ?>
<p>No files pending approval.</p>
<?php else: ?>
<?php foreach ($pool_items as $item): ?>
<div class="panel" style="margin-bottom:14px;">
<div class="panel-title">
<?php if ($item['source'] === 'ftp_folder'): ?>
[DIR] <?php echo h($item['name']); ?>
— <?php echo (int)$item['file_count']; ?> file(s)
— <span class="muted">FTP Folder</span>
— <?php echo fmt_datetime($item['mtime']); ?>
<?php else: ?>
<?php echo h($item['name']); ?>
— <?php echo format_size($item['size']); ?>
— <span class="muted"><?php echo $item['source'] === 'ftp' ? 'FTP Upload' : 'Web Upload'; ?></span>
— <?php echo fmt_datetime($item['mtime']); ?>
<?php endif; ?>
</div>
<div class="panel-body">
<?php if ($item['source'] === 'ftp_folder'): ?>
<p class="muted" style="margin-bottom:8px;">
Folder from FTP pool. Sub-folders will be created as sub-categories mirroring the
directory structure. Required fields with no available metadata will be set to
<strong>Unknown</strong>. Titles are derived from filenames.
</p>
<form method="post" action="/pool/import_folder" class="std">
<?php echo csrf_field(); ?>
<input type="hidden" name="pool_folder" value="<?php echo h($item['name']); ?>">
<div style="margin-top:6px;">
<label style="display:inline;font-weight:normal;">
<input type="checkbox" name="approve_now" value="1"> Approve all files immediately
</label>
</div>
<div style="margin-top:8px;">
<button type="submit" class="btn btn-primary">Batch Import Folder</button>
</div>
</form>
<form method="post" action="/pool/reject_folder" style="margin-top:8px;"
onsubmit="return confirm('Permanently delete this folder and all its contents?');">
<?php echo csrf_field(); ?>
<input type="hidden" name="pool_folder" value="<?php echo h($item['name']); ?>">
<button type="submit" class="btn btn-danger">Reject & Delete Folder</button>
</form>
<?php elseif (!empty($item['id'])): ?>
<?php
$meta = $item['meta'];
$sub_bc = category_breadcrumb($cats, $meta['category'] ?? '');
$sub_path = $sub_bc ? implode(' › ', array_column($sub_bc, 'name')) : '—';
?>
<p class="muted" style="margin-bottom:10px;font-size:0.85rem;">
Submitted by <strong><?php echo h($meta['uploader'] ?? ''); ?></strong>
to <strong><?php echo h($sub_path); ?></strong>
</p>
<form method="post" action="/pool/approve/<?php echo h($item['id']); ?>" class="std">
<?php echo csrf_field(); ?>
<label>Title *
<input type="text" name="title" value="<?php echo h($meta['title'] ?? ''); ?>" required maxlength="200">
</label>
<label>Description *
<textarea name="desc" rows="4"><?php echo h($meta['description'] ?? ''); ?></textarea>
</label>
<label>Author *
<input type="text" name="author" value="<?php echo h($meta['author'] ?? ''); ?>" maxlength="200">
</label>
<label>Version
<input type="text" name="version" value="<?php echo h($meta['version'] ?? ''); ?>" maxlength="50">
</label>
<label>Homepage
<input type="url" name="homepage" value="<?php echo h($meta['homepage'] ?? ''); ?>" maxlength="500">
</label>
<label>Category
<select name="category">
<option value="">-- Uncategorized --</option>
<?php render_cat_options(build_category_tree($cats), $meta['category'] ?? ''); ?>
</select>
</label>
<label>OS/2 Version
<input type="text" name="os2_version" value="<?php echo h($meta['os2_version'] ?? ''); ?>" maxlength="100">
</label>
<label>License
<input type="text" name="license" value="<?php echo h($meta['license'] ?? ''); ?>" maxlength="100">
</label>
<label>Requirements
<input type="text" name="requirements" value="<?php echo h($meta['requirements'] ?? ''); ?>" maxlength="300">
</label>
<label>Tags
<input type="text" name="tags" value="<?php echo h($meta['tags'] ?? ''); ?>" maxlength="300">
</label>
<div class="row" style="gap:8px;margin-top:4px;">
<button type="submit" class="btn btn-primary">Approve</button>
</div>
</form>
<form method="post" action="/pool/reject/<?php echo h($item['id']); ?>"
style="margin-top:8px;"
onsubmit="return confirm('Permanently reject and delete "<?php echo addslashes($item['name']); ?>"?');">
<?php echo csrf_field(); ?>
<button type="submit" class="btn btn-danger">Reject & Delete</button>
</form>
<?php else: // ftp single file ?>
<p class="muted" style="margin-bottom:8px;">File from FTP pool. Please fill in the required information below before importing.</p>
<form method="post" action="/pool/import" class="std">
<?php echo csrf_field(); ?>
<input type="hidden" name="pool_file" value="<?php echo h(basename($item['pool_file'])); ?>">
<label>Title * <input type="text" name="title" value="<?php echo h($item['meta']['title'] ?? ''); ?>" required></label>
<label>Description * <textarea name="desc" rows="3"><?php echo h($item['meta']['desc'] ?? ''); ?></textarea></label>
<label>Version <input type="text" name="version" value="<?php echo h($item['meta']['version'] ?? ''); ?>"></label>
<label>Author * <input type="text" name="author" value="<?php echo h($item['meta']['author'] ?? ''); ?>" required></label>
<label>Homepage <input type="url" name="homepage" value="<?php echo h($item['meta']['homepage'] ?? ''); ?>"></label>
<label>Category *
<select name="category" required>
<option value="">-- Select --</option>
<?php render_cat_options(build_category_tree($cats), $_POST['category'] ?? ''); ?>
</select>
</label>
<label>OS/2 Version <input type="text" name="os2_version" value="<?php echo h($item['meta']['os2_version'] ?? ''); ?>"></label>
<label>License <input type="text" name="license" value=""></label>
<label>Requirements <input type="text" name="requirements" value=""></label>
<label>Tags <input type="text" name="tags" value="<?php echo h($item['meta']['tags'] ?? ''); ?>"></label>
<div style="margin-top:10px;">
<label style="display:inline;font-weight:normal;">
<input type="checkbox" name="approve_now" value="1"> Approve immediately
</label>
</div>
<div style="margin-top:10px;">
<button type="submit" class="btn btn-primary">Import from Pool</button>
</div>
</form>
<?php endif; ?>
</div>
</div>
<?php endforeach; ?>
<?php endif; ?>
</div>
</div>
<?php include ROOT_DIR . '/templates/footer.php'; ?>