<?php
// =============================================================================
// /api/restore.php — accept a ZIP from backup.php and replace panel state.
// IMPORTANT: this REPLACES the entire DB. Operator must double-confirm.
//
// POST multipart/form-data: file (.zip), confirm=YES
// =============================================================================
require_once __DIR__ . '/_common.php';
require_csrf_unless_get();

require_method('POST');

$confirm = (string)($_POST['confirm'] ?? '');
if ($confirm !== 'YES') json_error('confirm=YES required to overwrite live data', 400);

if (empty($_FILES['file']) || $_FILES['file']['error'] !== UPLOAD_ERR_OK) {
    json_error('zip upload required', 400);
}
if (!class_exists('ZipArchive')) json_error('ZipArchive extension required', 500);

$f = $_FILES['file'];
$tmp = $f['tmp_name'];
$zip = new ZipArchive();
if ($zip->open($tmp) !== true) json_error('invalid zip', 400);

$meta_idx = $zip->locateName('meta.json');
if ($meta_idx === false) { $zip->close(); json_error('not a panel backup (no meta.json)', 400); }

// Stage: manually extract with path-traversal vetting. Backups should only
// contain entries under `data/` and `uploads/`. Anything else, or any name
// that breaks out of the stage dir (../ tricks, absolute paths, drive prefixes,
// null bytes), is rejected outright. Silent skip would let a hostile backup
// drop arbitrary .php into the panel root.
$stage = sys_get_temp_dir() . '/b3restore_' . bin2hex(random_bytes(4));
@mkdir($stage, 0775, true);
$stage_real = realpath($stage);
if ($stage_real === false) { $zip->close(); json_error('staging dir failed', 500); }

for ($i = 0; $i < $zip->numFiles; $i++) {
    $name = $zip->getNameIndex($i);
    if ($name === '' || substr($name, -1) === '/') continue;
    if ($name[0] === '/' || strpos($name, '..') !== false
        || strpos($name, "\0") !== false
        || preg_match('/^[a-z]:/i', $name)) {
        $zip->close();
        json_error('backup contains a disallowed path: ' . $name, 400);
    }
    // Only allow entries under data/ or uploads/ (plus the top-level meta.json).
    if ($name !== 'meta.json'
        && strpos($name, 'data/') !== 0
        && strpos($name, 'uploads/') !== 0) {
        $zip->close();
        json_error('backup contains an unexpected entry: ' . $name, 400);
    }
    $dest = $stage . '/' . $name;
    @mkdir(dirname($dest), 0775, true);
    // Defence-in-depth: ensure resolved dest still lives under $stage_real.
    $dest_real = realpath(dirname($dest));
    if ($dest_real === false || strpos($dest_real, $stage_real) !== 0) {
        $zip->close();
        json_error('path-traversal entry rejected: ' . $name, 400);
    }
    $data = $zip->getFromIndex($i);
    if ($data !== false) file_put_contents($dest, $data);
}
$zip->close();

$ok_db = false;
if (is_file($stage . '/data/panel.db')) {
    $target = __DIR__ . '/../data/panel.db';
    // Try a backup of the live one first.
    if (is_file($target)) @copy($target, $target . '.pre_restore_' . date('Ymd_His'));
    $ok_db = @rename($stage . '/data/panel.db', $target);
}

$uploaded = 0;
if (is_dir($stage . '/uploads')) {
    $uploads_root = realpath(__DIR__ . '/../uploads');
    if ($uploads_root === false) { @mkdir(__DIR__ . '/../uploads', 0775, true); $uploads_root = realpath(__DIR__ . '/../uploads'); }
    $stage_uploads = realpath($stage . '/uploads');
    $iter = new RecursiveIteratorIterator(
        new RecursiveDirectoryIterator($stage . '/uploads', FilesystemIterator::SKIP_DOTS));
    foreach ($iter as $src) {
        if (!$src->isFile()) continue;
        $rel  = substr($src->getPathname(), strlen($stage_uploads) + 1);
        // Re-validate the relative path before splicing it under uploads/.
        if ($rel === '' || strpos($rel, '..') !== false || $rel[0] === '/'
            || strpos($rel, "\0") !== false) continue;
        $dest = $uploads_root . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $rel);
        @mkdir(dirname($dest), 0775, true);
        $dest_dir_real = realpath(dirname($dest));
        if ($dest_dir_real === false || strpos($dest_dir_real, $uploads_root) !== 0) continue;
        if (@rename($src->getPathname(), $dest)) $uploaded++;
    }
}

// Cleanup stage dir
$ri = new RecursiveIteratorIterator(
    new RecursiveDirectoryIterator($stage, FilesystemIterator::SKIP_DOTS),
    RecursiveIteratorIterator::CHILD_FIRST);
foreach ($ri as $f) { if ($f->isDir()) @rmdir($f->getPathname()); else @unlink($f->getPathname()); }
@rmdir($stage);

json_response(['ok' => true, 'db_replaced' => $ok_db, 'uploads_restored' => $uploaded]);
