190 lines
10 KiB
PHP
190 lines
10 KiB
PHP
|
|
<?php
|
||
|
|
declare(strict_types=1);
|
||
|
|
|
||
|
|
$config = require dirname(__DIR__) . '/api/config.php';
|
||
|
|
$token = (string)($config['admin_token'] ?? '');
|
||
|
|
$given = (string)($_GET['token'] ?? $_POST['token'] ?? '');
|
||
|
|
header('X-Content-Type-Options: nosniff');
|
||
|
|
|
||
|
|
function h($s): string { return htmlspecialchars((string)$s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); }
|
||
|
|
function clean_object_id($value): string { return substr(preg_replace('/[^A-Za-z0-9_:\-.]/', '', (string)$value) ?? '', 0, 96); }
|
||
|
|
function data_dir(array $config): string { return dirname((string)$config['db_path']); }
|
||
|
|
function filedb_path(array $config): string { return data_dir($config) . DIRECTORY_SEPARATOR . 'pixel_island_filedb.json'; }
|
||
|
|
function sqlite_available(): bool { return class_exists('PDO') && in_array('sqlite', PDO::getAvailableDrivers(), true); }
|
||
|
|
function sqlite_tables_ready(PDO $db): bool {
|
||
|
|
$stmt = $db->query("SELECT name FROM sqlite_master WHERE type='table' AND name IN ('reports','objects','assets')");
|
||
|
|
$names = $stmt ? array_column($stmt->fetchAll(PDO::FETCH_ASSOC), 'name') : [];
|
||
|
|
return in_array('reports', $names, true) && in_array('objects', $names, true) && in_array('assets', $names, true);
|
||
|
|
}
|
||
|
|
function choose_store(array $config): array {
|
||
|
|
$sqlitePath = (string)$config['db_path'];
|
||
|
|
if (sqlite_available() && is_file($sqlitePath)) return ['type' => 'sqlite', 'path' => $sqlitePath];
|
||
|
|
$jsonPath = filedb_path($config);
|
||
|
|
if (is_file($jsonPath)) return ['type' => 'filedb', 'path' => $jsonPath];
|
||
|
|
if (sqlite_available()) return ['type' => 'sqlite_missing', 'path' => $sqlitePath];
|
||
|
|
return ['type' => 'filedb_missing', 'path' => $jsonPath];
|
||
|
|
}
|
||
|
|
function read_filedb(string $path): array {
|
||
|
|
if (!is_file($path)) return [];
|
||
|
|
$raw = file_get_contents($path);
|
||
|
|
$db = $raw ? json_decode($raw, true) : null;
|
||
|
|
return is_array($db) ? $db : [];
|
||
|
|
}
|
||
|
|
function write_filedb(string $path, callable $mutator): void {
|
||
|
|
$dir = dirname($path);
|
||
|
|
if (!is_dir($dir) && !mkdir($dir, 0775, true) && !is_dir($dir)) throw new RuntimeException('data directory unavailable');
|
||
|
|
$fp = fopen($path, 'c+');
|
||
|
|
if (!$fp) throw new RuntimeException('file db unavailable');
|
||
|
|
try {
|
||
|
|
if (!flock($fp, LOCK_EX)) throw new RuntimeException('file db lock failed');
|
||
|
|
$raw = stream_get_contents($fp);
|
||
|
|
$db = $raw ? json_decode($raw, true) : null;
|
||
|
|
if (!is_array($db)) $db = ['schema'=>1,'accounts'=>[],'assets'=>[],'objects'=>[],'votes'=>[],'reports'=>[],'events'=>[],'commands'=>[],'nextEventId'=>1];
|
||
|
|
$mutator($db);
|
||
|
|
ftruncate($fp, 0);
|
||
|
|
rewind($fp);
|
||
|
|
fwrite($fp, json_encode($db, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
|
||
|
|
fflush($fp);
|
||
|
|
flock($fp, LOCK_UN);
|
||
|
|
} finally {
|
||
|
|
fclose($fp);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
function asset_name_from_json($json, string $fallback = 'Untitled'): string {
|
||
|
|
if (is_array($json)) return (string)($json['name'] ?? $fallback);
|
||
|
|
$d = json_decode((string)$json, true);
|
||
|
|
return is_array($d) ? (string)($d['name'] ?? $fallback) : $fallback;
|
||
|
|
}
|
||
|
|
function list_sqlite_reports(string $path): array {
|
||
|
|
$db = new PDO('sqlite:' . $path, null, null, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC]);
|
||
|
|
$db->exec('PRAGMA busy_timeout = 5000');
|
||
|
|
if (!sqlite_tables_ready($db)) return [];
|
||
|
|
$rows = $db->query("SELECT r.*, o.moderation_status, o.json AS object_json, a.json AS asset_json FROM reports r LEFT JOIN objects o ON o.id = r.object_id LEFT JOIN assets a ON a.id = r.asset_id WHERE r.status = 'open' ORDER BY r.created_at DESC LIMIT 200")->fetchAll();
|
||
|
|
return array_map(function($r) {
|
||
|
|
return [
|
||
|
|
'id' => $r['id'] ?? '',
|
||
|
|
'object_id' => $r['object_id'] ?? '',
|
||
|
|
'asset_id' => $r['asset_id'] ?? '',
|
||
|
|
'reason' => $r['reason'] ?? '',
|
||
|
|
'reporter' => $r['reporter_account_id'] ?? '',
|
||
|
|
'created_at' => (int)($r['created_at'] ?? 0),
|
||
|
|
'moderation_status' => $r['moderation_status'] ?? 'missing',
|
||
|
|
'asset_name' => asset_name_from_json($r['asset_json'] ?? '', 'Untitled'),
|
||
|
|
];
|
||
|
|
}, $rows ?: []);
|
||
|
|
}
|
||
|
|
function list_filedb_reports(string $path): array {
|
||
|
|
$db = read_filedb($path);
|
||
|
|
$reports = is_array($db['reports'] ?? null) ? $db['reports'] : [];
|
||
|
|
$objects = is_array($db['objects'] ?? null) ? $db['objects'] : [];
|
||
|
|
$assets = is_array($db['assets'] ?? null) ? $db['assets'] : [];
|
||
|
|
$out = [];
|
||
|
|
foreach ($reports as $r) {
|
||
|
|
if (($r['status'] ?? 'open') !== 'open') continue;
|
||
|
|
$objectId = (string)($r['object_id'] ?? '');
|
||
|
|
$assetId = (string)($r['asset_id'] ?? '');
|
||
|
|
$object = $objects[$objectId] ?? [];
|
||
|
|
$asset = $assets[$assetId] ?? [];
|
||
|
|
$out[] = [
|
||
|
|
'id' => $r['id'] ?? '',
|
||
|
|
'object_id' => $objectId,
|
||
|
|
'asset_id' => $assetId,
|
||
|
|
'reason' => $r['reason'] ?? '',
|
||
|
|
'reporter' => $r['reporter_account_id'] ?? '',
|
||
|
|
'created_at' => (int)($r['created_at'] ?? 0),
|
||
|
|
'moderation_status' => $object['moderation_status'] ?? 'missing',
|
||
|
|
'asset_name' => asset_name_from_json($asset['json'] ?? [], 'Untitled'),
|
||
|
|
];
|
||
|
|
}
|
||
|
|
usort($out, fn($a, $b) => ((int)$b['created_at']) <=> ((int)$a['created_at']));
|
||
|
|
return array_slice($out, 0, 200);
|
||
|
|
}
|
||
|
|
function apply_sqlite_action(string $path, string $objectId, string $action): void {
|
||
|
|
$db = new PDO('sqlite:' . $path, null, null, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC]);
|
||
|
|
$db->exec('PRAGMA busy_timeout = 5000');
|
||
|
|
if (!sqlite_tables_ready($db)) return;
|
||
|
|
$now = (int)floor(microtime(true) * 1000);
|
||
|
|
if ($action === 'hide') {
|
||
|
|
$db->prepare("UPDATE objects SET moderation_status = 'violation_hidden', updated_at = ? WHERE id = ?")->execute([$now, $objectId]);
|
||
|
|
} elseif ($action === 'restore') {
|
||
|
|
$db->prepare("UPDATE objects SET moderation_status = 'active', updated_at = ? WHERE id = ?")->execute([$now, $objectId]);
|
||
|
|
} elseif ($action === 'close_reports') {
|
||
|
|
$db->prepare("UPDATE reports SET status = 'closed' WHERE object_id = ?")->execute([$objectId]);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
function apply_filedb_action(string $path, string $objectId, string $action): void {
|
||
|
|
write_filedb($path, function(array &$db) use ($objectId, $action) {
|
||
|
|
$now = (int)floor(microtime(true) * 1000);
|
||
|
|
if (($action === 'hide' || $action === 'restore') && isset($db['objects'][$objectId])) {
|
||
|
|
$db['objects'][$objectId]['moderation_status'] = $action === 'hide' ? 'violation_hidden' : 'active';
|
||
|
|
$db['objects'][$objectId]['updated_at'] = $now;
|
||
|
|
} elseif ($action === 'close_reports' && is_array($db['reports'] ?? null)) {
|
||
|
|
foreach ($db['reports'] as &$report) {
|
||
|
|
if (($report['object_id'] ?? '') === $objectId) $report['status'] = 'closed';
|
||
|
|
}
|
||
|
|
unset($report);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
if ($token === '') {
|
||
|
|
http_response_code(403);
|
||
|
|
echo '<!doctype html><meta charset="utf-8"><title>Admin disabled</title><h1>Admin disabled</h1><p>Set <code>admin_token</code> in <code>api/config.php</code> to enable this page.</p>';
|
||
|
|
exit;
|
||
|
|
}
|
||
|
|
if (!hash_equals($token, $given)) {
|
||
|
|
http_response_code(403);
|
||
|
|
echo '<!doctype html><meta charset="utf-8"><title>Forbidden</title><h1>Forbidden</h1><p>Invalid admin token.</p>';
|
||
|
|
exit;
|
||
|
|
}
|
||
|
|
|
||
|
|
$store = choose_store($config);
|
||
|
|
$error = '';
|
||
|
|
try {
|
||
|
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||
|
|
$objectId = clean_object_id($_POST['object_id'] ?? '');
|
||
|
|
$action = (string)($_POST['moderation_action'] ?? '');
|
||
|
|
if ($objectId !== '') {
|
||
|
|
if ($store['type'] === 'sqlite') apply_sqlite_action($store['path'], $objectId, $action);
|
||
|
|
elseif ($store['type'] === 'filedb') apply_filedb_action($store['path'], $objectId, $action);
|
||
|
|
}
|
||
|
|
header('Location: ./?token=' . rawurlencode($given));
|
||
|
|
exit;
|
||
|
|
}
|
||
|
|
if ($store['type'] === 'sqlite') $reports = list_sqlite_reports($store['path']);
|
||
|
|
elseif ($store['type'] === 'filedb') $reports = list_filedb_reports($store['path']);
|
||
|
|
else $reports = [];
|
||
|
|
} catch (Throwable $e) {
|
||
|
|
$reports = [];
|
||
|
|
$error = $e->getMessage();
|
||
|
|
}
|
||
|
|
$storeLabel = $store['type'] === 'sqlite' ? 'SQLite' : ($store['type'] === 'filedb' ? 'JSON file DB' : 'DB not initialized yet');
|
||
|
|
?>
|
||
|
|
<!doctype html>
|
||
|
|
<meta charset="utf-8">
|
||
|
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||
|
|
<title>Pixel Island Admin</title>
|
||
|
|
<style>
|
||
|
|
body{font-family:system-ui,sans-serif;margin:24px;background:#f7f4ea;color:#2b2b2b}.card{background:#fff;border:1px solid #ddd;border-radius:12px;padding:16px;margin:12px 0}button{margin-right:8px;padding:6px 10px}.danger{background:#b92323;color:white;border:0;border-radius:6px}.ok{background:#267a3e;color:white;border:0;border-radius:6px}.muted{color:#666;font-size:13px}.error{background:#fff1f1;border:1px solid #e3aaaa;padding:12px;border-radius:8px}code{background:#eee;padding:1px 4px;border-radius:4px}
|
||
|
|
</style>
|
||
|
|
<h1>Pixel Island Admin</h1>
|
||
|
|
<p class="muted">Store: <?=h($storeLabel)?> / Open reports: <?=count($reports)?>. Hiding an object removes it from public snapshots; restore makes it visible again.</p>
|
||
|
|
<?php if ($error): ?><p class="error">Admin error: <?=h($error)?></p><?php endif; ?>
|
||
|
|
<?php if ($store['type'] === 'sqlite_missing' || $store['type'] === 'filedb_missing'): ?>
|
||
|
|
<p class="muted">No DB has been initialized yet. Open <code>../api/index.php?action=health</code> and use the app once, then return here.</p>
|
||
|
|
<?php endif; ?>
|
||
|
|
<?php foreach ($reports as $r): ?>
|
||
|
|
<div class="card">
|
||
|
|
<h2><?=h($r['asset_name'] ?? 'Untitled')?></h2>
|
||
|
|
<p><strong>Reason:</strong> <?=h($r['reason'] ?? '')?> / <strong>Reporter:</strong> <?=h($r['reporter'] ?? '')?></p>
|
||
|
|
<p class="muted">Object: <code><?=h($r['object_id'] ?? '')?></code> / Asset: <code><?=h($r['asset_id'] ?? '')?></code> / Status: <code><?=h($r['moderation_status'] ?? 'missing')?></code></p>
|
||
|
|
<form method="post">
|
||
|
|
<input type="hidden" name="token" value="<?=h($given)?>">
|
||
|
|
<input type="hidden" name="object_id" value="<?=h($r['object_id'] ?? '')?>">
|
||
|
|
<button class="danger" name="moderation_action" value="hide">Hide object</button>
|
||
|
|
<button class="ok" name="moderation_action" value="restore">Restore object</button>
|
||
|
|
<button name="moderation_action" value="close_reports">Close reports</button>
|
||
|
|
</form>
|
||
|
|
</div>
|
||
|
|
<?php endforeach; ?>
|
||
|
|
<?php if (!$reports): ?><p>No open reports.</p><?php endif; ?>
|