pixel/admin/index.php

190 lines
10 KiB
PHP
Raw Normal View History

2026-06-04 02:03:59 +09:00
<?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; ?>