Add automated backup system

- api/backup.php: list/create/download/delete backups; streams zip directly
  for downloads; 7-backup rolling prune on each create
- Each backup is a single zip containing all of public_html + a full
  mysqldump of tomt_ttg_db
- Cron at 2 AM daily via /usr/local/bin/ttg-backup.sh (already installed)
- Admin UI: 💾 Backups nav item under System section; shows backup list
  with date/size, Download + Delete per row; Create Backup Now button
  with live status; auto-loads when section is opened

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-05 22:14:32 +00:00
parent c9cf26edca
commit f5a72c55f5
3 changed files with 252 additions and 0 deletions
+113
View File
@@ -0,0 +1,113 @@
<?php
ob_start();
require_once __DIR__ . '/../../includes/auth.php';
ob_end_clean();
$action = $_GET['action'] ?? 'list';
if ($action !== 'download') {
header('Content-Type: application/json');
}
if (!isLoggedIn() || empty($_SESSION['is_admin'])) {
if ($action !== 'download') echo json_encode(['success'=>false,'error'=>'Forbidden']);
else { http_response_code(403); echo 'Forbidden'; }
exit;
}
$backupDir = '/home/tomtomgames.com/backups';
if (!is_dir($backupDir)) @mkdir($backupDir, 0750, true);
switch ($action) {
case 'list':
$files = glob($backupDir . '/ttg_backup_*.zip') ?: [];
rsort($files);
$backups = array_map(fn($f) => [
'name' => basename($f),
'size' => filesize($f),
'created' => date('Y-m-d H:i:s', filemtime($f)),
], $files);
echo json_encode(['success'=>true, 'backups'=>$backups]);
break;
case 'create':
if ($_SERVER['REQUEST_METHOD'] !== 'POST') { echo json_encode(['success'=>false,'error'=>'POST required']); exit; }
set_time_limit(300);
ignore_user_abort(true);
$date = date('Y-m-d_H-i-s');
$sqlFile = "/tmp/ttg_db_{$date}.sql";
$zipFile = "{$backupDir}/ttg_backup_{$date}.zip";
$siteDir = '/home/tomtomgames.com/public_html';
// Export database
$dbCmd = sprintf(
'/usr/bin/mysqldump -u %s -p%s %s > %s 2>&1',
escapeshellarg(DB_USER), escapeshellarg(DB_PASS),
escapeshellarg(DB_NAME), escapeshellarg($sqlFile)
);
exec($dbCmd, $dbOut, $dbRc);
if ($dbRc !== 0 || !file_exists($sqlFile) || filesize($sqlFile) < 10) {
@unlink($sqlFile);
echo json_encode(['success'=>false,'error'=>'Database export failed']); exit;
}
// Zip site files + db dump into one archive
$zipCmd = sprintf(
'/usr/bin/zip -r %s %s %s -x "*/backups/*" 2>&1',
escapeshellarg($zipFile),
escapeshellarg($siteDir),
escapeshellarg($sqlFile)
);
exec($zipCmd, $zipOut, $zipRc);
@unlink($sqlFile);
if ($zipRc !== 0 || !file_exists($zipFile)) {
@unlink($zipFile);
echo json_encode(['success'=>false,'error'=>'Archive creation failed']); exit;
}
// Prune — keep only the 7 most recent
$all = glob($backupDir . '/ttg_backup_*.zip') ?: [];
rsort($all);
foreach (array_slice($all, 7) as $old) @unlink($old);
echo json_encode([
'success' => true,
'name' => basename($zipFile),
'size' => filesize($zipFile),
'created' => date('Y-m-d H:i:s'),
]);
break;
case 'download':
$name = basename($_GET['file'] ?? '');
if (!preg_match('/^ttg_backup_[\d_-]+\.zip$/', $name)) {
http_response_code(400); echo 'Invalid filename'; exit;
}
$path = $backupDir . '/' . $name;
if (!file_exists($path)) { http_response_code(404); echo 'Not found'; exit; }
header('Content-Type: application/zip');
header('Content-Disposition: attachment; filename="' . $name . '"');
header('Content-Length: ' . filesize($path));
header('Cache-Control: no-cache');
readfile($path);
exit;
case 'delete':
if ($_SERVER['REQUEST_METHOD'] !== 'POST') { echo json_encode(['success'=>false,'error'=>'POST required']); exit; }
$data = json_decode(file_get_contents('php://input'), true);
$name = basename($data['name'] ?? '');
if (!preg_match('/^ttg_backup_[\d_-]+\.zip$/', $name)) {
echo json_encode(['success'=>false,'error'=>'Invalid filename']); exit;
}
$path = $backupDir . '/' . $name;
if (file_exists($path)) @unlink($path);
echo json_encode(['success'=>true]);
break;
default:
echo json_encode(['success'=>false,'error'=>'Unknown action']);
}