diff --git a/admin/index.php b/admin/index.php index 743e785..60771cc 100644 --- a/admin/index.php +++ b/admin/index.php @@ -4,17 +4,26 @@ declare(strict_types=1); $config = require dirname(__DIR__) . '/api/config.php'; $token = (string)($config['admin_token'] ?? ''); $given = (string)($_GET['token'] ?? $_POST['token'] ?? ''); +$ownerFilter = clean_id($_GET['owner'] ?? $_POST['owner'] ?? ''); 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 now_ms(): int { return (int)floor(microtime(true) * 1000); } +function clean_id($value): string { return substr(preg_replace('/[^A-Za-z0-9_:\-.]/', '', (string)$value) ?? '', 0, 96); } +function clean_text($value, string $fallback = '', int $max = 80): string { + $text = trim((string)$value); + $text = preg_replace('/[\x00-\x1F\x7F]/u', '', $text) ?? ''; + $text = preg_replace('/\s+/u', ' ', $text) ?? ''; + if ($text === '') $text = $fallback; + return function_exists('mb_substr') ? mb_substr($text, 0, $max, 'UTF-8') : substr($text, 0, $max); +} 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')"); + $stmt = $db->query("SELECT name FROM sqlite_master WHERE type='table' AND name IN ('accounts','assets','objects','reports')"); $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); + return in_array('accounts', $names, true) && in_array('assets', $names, true) && in_array('objects', $names, true) && in_array('reports', $names, true); } function choose_store(array $config): array { $sqlitePath = (string)$config['db_path']; @@ -24,6 +33,50 @@ function choose_store(array $config): array { if (sqlite_available()) return ['type' => 'sqlite_missing', 'path' => $sqlitePath]; return ['type' => 'filedb_missing', 'path' => $jsonPath]; } +function redirect_admin(string $token, string $owner = ''): void { + $url = './?token=' . rawurlencode($token); + if ($owner !== '') $url .= '&owner=' . rawurlencode($owner); + header('Location: ' . $url); + exit; +} +function decode_json_value($json): array { + if (is_array($json)) return $json; + $decoded = json_decode((string)$json, true); + return is_array($decoded) ? $decoded : []; +} +function encode_json_value(array $value): string { + return json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '{}'; +} +function asset_name_from_json($json, string $fallback = 'Untitled'): string { + $asset = decode_json_value($json); + return (string)($asset['name'] ?? $fallback); +} +function thumbnail_html($json): string { + $asset = decode_json_value($json); + $pixels = (string)($asset['faces']['right'] ?? $asset['pixels'] ?? ''); + if ($pixels === '') return '
'; + $rows = str_contains($pixels, '|') ? explode('|', $pixels) : []; + $width = max(1, min(64, (int)($asset['width'] ?? $asset['w'] ?? $asset['size'] ?? 16))); + $height = max(1, min(64, (int)($asset['height'] ?? $asset['ht'] ?? $asset['size'] ?? $width))); + if (!$rows) { + for ($y = 0; $y < $height; $y++) $rows[] = substr($pixels, $y * $width, $width); + } + $palette = [ + '0'=>'#1f2330','1'=>'#ffffff','2'=>'#8f9aa8','3'=>'#d7c59a','4'=>'#7a4f35','5'=>'#3f6f3f','6'=>'#5c8f52','7'=>'#8fcf68','8'=>'#2f5f88','9'=>'#67b7dc', + 'a'=>'#f2d3a3','b'=>'#e09f67','c'=>'#c96b4b','d'=>'#8c3f3f','e'=>'#d94f70','f'=>'#9b5fc0','g'=>'#5b4aa3','h'=>'#3f7fbf','i'=>'#55b7a5','j'=>'#f0d84f' + ]; + $cells = ''; + foreach (array_slice($rows, 0, $height) as $y => $row) { + $chars = preg_split('//u', (string)$row, -1, PREG_SPLIT_NO_EMPTY) ?: []; + for ($x = 0; $x < $width; $x++) { + $ch = $chars[$x] ?? '.'; + if ($ch === '.') continue; + $color = $palette[$ch] ?? sprintf('#%06x', (hexdec(substr(md5($ch), 0, 6)) & 0x7fffff) | 0x303030); + $cells .= ''; + } + } + return '
' . $cells . '
'; +} function read_filedb(string $path): array { if (!is_file($path)) return []; $raw = file_get_contents($path); @@ -43,93 +96,245 @@ function write_filedb(string $path, callable $mutator): void { $mutator($db); ftruncate($fp, 0); rewind($fp); - fwrite($fp, json_encode($db, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); + fwrite($fp, encode_json_value($db)); 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 { +function sqlite_db(string $path): PDO { $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'); + return $db; +} +function list_sqlite_accounts(string $path): array { + $db = sqlite_db($path); 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 $db->query("SELECT a.id, a.name, a.created_at, a.updated_at, a.disabled_at, + (SELECT COUNT(*) FROM assets x WHERE x.owner_account_id = a.id AND x.deleted_at IS NULL) AS asset_count, + (SELECT COUNT(*) FROM objects o WHERE o.owner_account_id = a.id AND o.deleted_at IS NULL) AS object_count, + (SELECT COUNT(*) FROM reports r WHERE r.reporter_account_id = a.id AND r.status = 'open') AS open_report_count + FROM accounts a ORDER BY a.created_at DESC LIMIT 500")->fetchAll() ?: []; +} +function list_sqlite_assets(string $path, string $owner = ''): array { + $db = sqlite_db($path); + if (!sqlite_tables_ready($db)) return []; + $sql = "SELECT a.*, acc.name AS owner_name, + (SELECT COUNT(*) FROM objects o WHERE o.asset_id = a.id AND o.deleted_at IS NULL) AS object_count + FROM assets a LEFT JOIN accounts acc ON acc.id = a.owner_account_id WHERE a.deleted_at IS NULL"; + $params = []; + if ($owner !== '') { $sql .= ' AND a.owner_account_id = ?'; $params[] = $owner; } + $sql .= ' ORDER BY a.updated_at DESC LIMIT 500'; + $stmt = $db->prepare($sql); + $stmt->execute($params); + return array_map(function($row) { + $asset = decode_json_value($row['json'] ?? ''); 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'), + 'id' => $row['id'] ?? '', + 'owner_account_id' => $row['owner_account_id'] ?? '', + 'owner_name' => $row['owner_name'] ?? '', + 'name' => $asset['name'] ?? ($row['author_name'] ?? 'Untitled'), + 'json' => $row['json'] ?? '', + 'category' => $asset['category'] ?? '', + 'subtype' => $asset['subtype'] ?? '', + 'updated_at' => (int)($row['updated_at'] ?? 0), + 'object_count' => (int)($row['object_count'] ?? 0), ]; - }, $rows ?: []); + }, $stmt->fetchAll() ?: []); +} +function list_sqlite_reports(string $path): array { + $db = sqlite_db($path); + if (!sqlite_tables_ready($db)) return []; + $rows = $db->query("SELECT r.*, o.moderation_status, 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(fn($r) => [ + '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'), + 'asset_json' => $r['asset_json'] ?? '', + ], $rows ?: []); +} +function apply_sqlite_admin_action(string $path, array $post): void { + $db = sqlite_db($path); + if (!sqlite_tables_ready($db)) return; + $action = (string)($post['admin_action'] ?? $post['moderation_action'] ?? ''); + $now = now_ms(); + if ($action === 'rename_account') { + $accountId = clean_id($post['account_id'] ?? ''); + $name = clean_text($post['name'] ?? '', '', 32); + if ($accountId === '' || $name === '') return; + $db->beginTransaction(); + $db->prepare('UPDATE accounts SET name = ?, updated_at = ? WHERE id = ?')->execute([$name, $now, $accountId]); + $rows = $db->prepare('SELECT id, json FROM assets WHERE owner_account_id = ? AND deleted_at IS NULL'); + $rows->execute([$accountId]); + foreach ($rows->fetchAll() ?: [] as $row) { + $asset = decode_json_value($row['json']); + $asset['author'] = $name; + $asset['updatedAt'] = $now; + $db->prepare('UPDATE assets SET author_name = ?, json = ?, updated_at = ? WHERE id = ?')->execute([$name, encode_json_value($asset), $now, $row['id']]); + } + $db->commit(); + } elseif ($action === 'disable_account' || $action === 'enable_account') { + $accountId = clean_id($post['account_id'] ?? ''); + if ($accountId !== '') $db->prepare('UPDATE accounts SET disabled_at = ?, updated_at = ? WHERE id = ?')->execute([$action === 'disable_account' ? $now : null, $now, $accountId]); + } elseif ($action === 'delete_account') { + $accountId = clean_id($post['account_id'] ?? ''); + if ($accountId === '' || $accountId === 'island-team') return; + $db->beginTransaction(); + $db->prepare('UPDATE assets SET deleted_at = ?, deleted_by = ?, updated_at = ? WHERE owner_account_id = ? AND deleted_at IS NULL')->execute([$now, 'admin', $now, $accountId]); + $db->prepare('UPDATE objects SET deleted_at = ?, deleted_by = ?, updated_at = ? WHERE owner_account_id = ? AND deleted_at IS NULL')->execute([$now, 'admin', $now, $accountId]); + $db->prepare('DELETE FROM votes WHERE voter_account_id = ?')->execute([$accountId]); + $db->prepare("UPDATE reports SET status = 'closed' WHERE reporter_account_id = ?")->execute([$accountId]); + $db->prepare('DELETE FROM accounts WHERE id = ?')->execute([$accountId]); + $db->commit(); + } elseif ($action === 'rename_asset') { + $assetId = clean_id($post['asset_id'] ?? ''); + $name = clean_text($post['name'] ?? '', '', 32); + if ($assetId === '' || $name === '') return; + $stmt = $db->prepare('SELECT json FROM assets WHERE id = ? AND deleted_at IS NULL'); + $stmt->execute([$assetId]); + $row = $stmt->fetch(); + if (!$row) return; + $asset = decode_json_value($row['json']); + $asset['name'] = $name; + $asset['updatedAt'] = $now; + $db->prepare('UPDATE assets SET json = ?, updated_at = ? WHERE id = ?')->execute([encode_json_value($asset), $now, $assetId]); + } elseif ($action === 'delete_asset') { + $assetId = clean_id($post['asset_id'] ?? ''); + if ($assetId === '') return; + $db->beginTransaction(); + $db->prepare('UPDATE assets SET deleted_at = ?, deleted_by = ?, updated_at = ? WHERE id = ? AND deleted_at IS NULL')->execute([$now, 'admin', $now, $assetId]); + $db->prepare('UPDATE objects SET deleted_at = ?, deleted_by = ?, updated_at = ? WHERE asset_id = ? AND deleted_at IS NULL')->execute([$now, 'admin', $now, $assetId]); + $db->prepare('DELETE FROM votes WHERE target_type = ? AND target_id = ?')->execute(['asset', $assetId]); + $db->commit(); + } elseif ($action === 'hide' || $action === 'restore' || $action === 'close_reports') { + $objectId = clean_id($post['object_id'] ?? ''); + if ($objectId === '') return; + 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]); + else $db->prepare("UPDATE reports SET status = 'closed' WHERE object_id = ?")->execute([$objectId]); + } +} +function list_filedb_accounts(string $path): array { + $db = read_filedb($path); + $out = []; + foreach (($db['accounts'] ?? []) as $account) { + $id = (string)($account['id'] ?? ''); + $assets = 0; $objects = 0; $reports = 0; + foreach (($db['assets'] ?? []) as $asset) if (($asset['owner_account_id'] ?? '') === $id && empty($asset['deleted_at'])) $assets++; + foreach (($db['objects'] ?? []) as $object) if (($object['owner_account_id'] ?? '') === $id && empty($object['deleted_at'])) $objects++; + foreach (($db['reports'] ?? []) as $report) if (($report['reporter_account_id'] ?? '') === $id && ($report['status'] ?? 'open') === 'open') $reports++; + $out[] = ['id'=>$id, 'name'=>$account['name'] ?? $id, 'created_at'=>(int)($account['created_at'] ?? 0), 'updated_at'=>(int)($account['updated_at'] ?? 0), 'disabled_at'=>$account['disabled_at'] ?? null, 'asset_count'=>$assets, 'object_count'=>$objects, 'open_report_count'=>$reports]; + } + usort($out, fn($a, $b) => ((int)$b['created_at']) <=> ((int)$a['created_at'])); + return array_slice($out, 0, 500); +} +function list_filedb_assets(string $path, string $owner = ''): array { + $db = read_filedb($path); + $accounts = $db['accounts'] ?? []; + $out = []; + foreach (($db['assets'] ?? []) as $asset) { + if (!empty($asset['deleted_at'])) continue; + if ($owner !== '' && ($asset['owner_account_id'] ?? '') !== $owner) continue; + $id = (string)($asset['id'] ?? ''); + $json = decode_json_value($asset['json'] ?? []); + $objectCount = 0; + foreach (($db['objects'] ?? []) as $object) if (($object['asset_id'] ?? '') === $id && empty($object['deleted_at'])) $objectCount++; + $ownerId = (string)($asset['owner_account_id'] ?? ''); + $out[] = ['id'=>$id, 'owner_account_id'=>$ownerId, 'owner_name'=>$accounts[$ownerId]['name'] ?? '', 'name'=>$json['name'] ?? 'Untitled', 'json'=>$asset['json'] ?? [], 'category'=>$json['category'] ?? '', 'subtype'=>$json['subtype'] ?? '', 'updated_at'=>(int)($asset['updated_at'] ?? 0), 'object_count'=>$objectCount]; + } + usort($out, fn($a, $b) => ((int)$b['updated_at']) <=> ((int)$a['updated_at'])); + return array_slice($out, 0, 500); } 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) { + foreach (($db['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'), - ]; + $asset = ($db['assets'] ?? [])[(string)($r['asset_id'] ?? '')] ?? []; + $object = ($db['objects'] ?? [])[(string)($r['object_id'] ?? '')] ?? []; + $out[] = ['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'=>$object['moderation_status'] ?? 'missing', 'asset_name'=>asset_name_from_json($asset['json'] ?? [], 'Untitled'), 'asset_json'=>$asset['json'] ?? []]; } 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'; +function apply_filedb_admin_action(string $path, array $post): void { + write_filedb($path, function(array &$db) use ($post) { + $action = (string)($post['admin_action'] ?? $post['moderation_action'] ?? ''); + $now = now_ms(); + if ($action === 'rename_account') { + $accountId = clean_id($post['account_id'] ?? ''); + $name = clean_text($post['name'] ?? '', '', 32); + if ($accountId === '' || $name === '' || !isset($db['accounts'][$accountId])) return; + $db['accounts'][$accountId]['name'] = $name; + $db['accounts'][$accountId]['updated_at'] = $now; + foreach ($db['assets'] as &$asset) { + if (($asset['owner_account_id'] ?? '') !== $accountId || !empty($asset['deleted_at'])) continue; + $asset['author_name'] = $name; + $asset['updated_at'] = $now; + $json = decode_json_value($asset['json'] ?? []); + $json['author'] = $name; + $json['updatedAt'] = $now; + $asset['json'] = $json; } + unset($asset); + } elseif ($action === 'disable_account' || $action === 'enable_account') { + $accountId = clean_id($post['account_id'] ?? ''); + if ($accountId !== '' && isset($db['accounts'][$accountId])) { + $db['accounts'][$accountId]['disabled_at'] = $action === 'disable_account' ? $now : null; + $db['accounts'][$accountId]['updated_at'] = $now; + } + } elseif ($action === 'delete_account') { + $accountId = clean_id($post['account_id'] ?? ''); + if ($accountId === '' || $accountId === 'island-team') return; + foreach ($db['assets'] as &$asset) if (($asset['owner_account_id'] ?? '') === $accountId && empty($asset['deleted_at'])) { $asset['deleted_at'] = $now; $asset['deleted_by'] = 'admin'; $asset['updated_at'] = $now; } + unset($asset); + foreach ($db['objects'] as &$object) if (($object['owner_account_id'] ?? '') === $accountId && empty($object['deleted_at'])) { $object['deleted_at'] = $now; $object['deleted_by'] = 'admin'; $object['updated_at'] = $now; } + unset($object); + foreach ($db['reports'] as &$report) if (($report['reporter_account_id'] ?? '') === $accountId) $report['status'] = 'closed'; unset($report); + unset($db['accounts'][$accountId]); + foreach ($db['votes'] as $key => $vote) if (($vote['voter_account_id'] ?? '') === $accountId) unset($db['votes'][$key]); + } elseif ($action === 'rename_asset') { + $assetId = clean_id($post['asset_id'] ?? ''); + $name = clean_text($post['name'] ?? '', '', 32); + if ($assetId === '' || $name === '' || !isset($db['assets'][$assetId])) return; + $json = decode_json_value($db['assets'][$assetId]['json'] ?? []); + $json['name'] = $name; + $json['updatedAt'] = $now; + $db['assets'][$assetId]['json'] = $json; + $db['assets'][$assetId]['updated_at'] = $now; + } elseif ($action === 'delete_asset') { + $assetId = clean_id($post['asset_id'] ?? ''); + if ($assetId === '' || !isset($db['assets'][$assetId])) return; + $db['assets'][$assetId]['deleted_at'] = $now; + $db['assets'][$assetId]['deleted_by'] = 'admin'; + $db['assets'][$assetId]['updated_at'] = $now; + foreach ($db['objects'] as &$object) if (($object['asset_id'] ?? '') === $assetId && empty($object['deleted_at'])) { $object['deleted_at'] = $now; $object['deleted_by'] = 'admin'; $object['updated_at'] = $now; } + unset($object); + foreach ($db['votes'] as $key => $vote) if (($vote['target_type'] ?? '') === 'asset' && ($vote['target_id'] ?? '') === $assetId) unset($db['votes'][$key]); + } elseif ($action === 'hide' || $action === 'restore' || $action === 'close_reports') { + $objectId = clean_id($post['object_id'] ?? ''); + if ($objectId === '') return; + 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') { + foreach ($db['reports'] as &$report) if (($report['object_id'] ?? '') === $objectId) $report['status'] = 'closed'; + unset($report); + } } }); } if ($token === '') { http_response_code(403); - echo 'Admin disabled

Admin disabled

Set admin_token in api/config.php to enable this page.

'; + echo 'Admin disabled

Admin disabled

'; exit; } if (!hash_equals($token, $given)) { @@ -140,51 +345,109 @@ if (!hash_equals($token, $given)) { $store = choose_store($config); $error = ''; +$accounts = []; +$assets = []; +$reports = []; 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') apply_sqlite_admin_action($store['path'], $_POST); + elseif ($store['type'] === 'filedb') apply_filedb_admin_action($store['path'], $_POST); + redirect_admin($given, clean_id($_POST['owner'] ?? '')); + } + if ($store['type'] === 'sqlite') { + $accounts = list_sqlite_accounts($store['path']); + $assets = list_sqlite_assets($store['path'], $ownerFilter); + $reports = list_sqlite_reports($store['path']); + } elseif ($store['type'] === 'filedb') { + $accounts = list_filedb_accounts($store['path']); + $assets = list_filedb_assets($store['path'], $ownerFilter); + $reports = list_filedb_reports($store['path']); } - 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'); +$baseUrl = './?token=' . rawurlencode($given); ?> Pixel Island Admin

Pixel Island Admin

-

Store: / Open reports: . Hiding an object removes it from public snapshots; restore makes it visible again.

+

Store: / Users: / Pictures: / Reports:

+

Admin error:

No DB has been initialized yet. Open ../api/index.php?action=health and use the app once, then return here.

- + +

Users

+
+
-

-

Reason: / Reporter:

-

Object: / Asset: / Status:

+

disabled

+

ID: / Pictures: / Objects: / Reports:

+

Pictures

- - - - + + + + + +
+
+

No accounts.

+ +

Pictures

+
+ +
+
+ +
+

+

ID:
User:
Type: / Uses:

+
+ + + + + + +
+
+
+
+ +
+

No pictures in this view.

+ +

Reports

+ +
+
+ +
+

+

Reason: / By:

+

Object: / Asset: / Status:

+
+ + + + + + +
+
+
+
+

No open reports.

diff --git a/api/config.php b/api/config.php index 0233a26..5768a67 100644 --- a/api/config.php +++ b/api/config.php @@ -13,5 +13,5 @@ return [ 'max_json_bytes' => 5 * 1024 * 1024, // Set an arbitrary long token here if you use admin/index.php. // Example: 'admin_token' => 'change-this-long-random-string', - 'admin_token' => '', + 'admin_token' => '$GJwk1jkawjfkwa542jgr545kwpGfA5keajfZawj', ]; diff --git a/app.js b/app.js index e60c340..6a81475 100644 --- a/app.js +++ b/app.js @@ -1222,7 +1222,7 @@ } function isSharedWorld() { - return state.worldMode === 'shared'; + return true; } function currentAccountId() { @@ -1234,7 +1234,7 @@ } function ensureWorldProtectionState(target = state) { - target.worldMode = target.worldMode === 'shared' ? 'shared' : 'local'; + target.worldMode = 'shared'; target.pendingPublishLog = Array.isArray(target.pendingPublishLog) ? target.pendingPublishLog.slice(-300) : []; target.serverSync = { lastServerEventId: target.serverSync?.lastServerEventId || null, @@ -1563,12 +1563,9 @@ lastSharedSyncAt = Date.now(); return true; } catch (error) { - if (sharedApiAvailable !== false) console.warn('Shared API unavailable; using local island state.', error); + if (sharedApiAvailable !== false) console.warn('Shared API unavailable; waiting for server-backed shared state.', error); sharedApiAvailable = false; - if (options.reason === 'boot' && state.worldMode !== 'shared') { - state.worldMode = 'local'; - ensureWorldProtectionState(); - } + ensureWorldProtectionState(); return false; } finally { sharedSyncInFlight = false; @@ -4379,18 +4376,20 @@ function hydrateRuntime() { const authoritativeMotion = isSharedWorld() && isServerAuthoritative('dynamicMotion'); + const previousRuntime = new Map(dynamicRuntime.map((item) => [item.id, item])); dynamicRuntime = state.dynamicSummons.map((summon) => { const asset = findAsset(summon.assetId); if (!asset) return null; + const previous = previousRuntime.get(summon.id); const homeTile = world.get(Math.round(summon.homeX), Math.round(summon.homeY)); const homeValid = homeTile && ((asset.subtype === 'fish') ? homeTile.type === 'water' : homeTile.type !== 'water'); const spawn = homeValid ? { x: Math.round(summon.homeX), y: Math.round(summon.homeY) } : findValidSpawn(asset, summon.homeX, summon.homeY, 6); const synced = authoritativeMotion ? (state.serverSync?.dynamicTargets?.[summon.id] || summon.serverState || {}) : {}; - const startX = Number.isFinite(Number(synced.x)) ? Number(synced.x) : (spawn.x + .5); - const startY = Number.isFinite(Number(synced.y)) ? Number(synced.y) : (spawn.y + .5); - const targetX = Number.isFinite(Number(synced.targetX)) ? Number(synced.targetX) : startX; - const targetY = Number.isFinite(Number(synced.targetY)) ? Number(synced.targetY) : startY; - const facing = Number.isFinite(Number(synced.facing)) && Number(synced.facing) !== 0 ? Math.sign(Number(synced.facing)) : 1; + const startX = Number.isFinite(Number(synced.x)) ? Number(synced.x) : (previous ? previous.x : spawn.x + .5); + const startY = Number.isFinite(Number(synced.y)) ? Number(synced.y) : (previous ? previous.y : spawn.y + .5); + const targetX = Number.isFinite(Number(synced.targetX)) ? Number(synced.targetX) : (previous ? previous.targetX : startX); + const targetY = Number.isFinite(Number(synced.targetY)) ? Number(synced.targetY) : (previous ? previous.targetY : startY); + const facing = Number.isFinite(Number(synced.facing)) && Number(synced.facing) !== 0 ? Math.sign(Number(synced.facing)) : (previous?.facing || 1); return { id: summon.id, assetId: summon.assetId, @@ -4400,15 +4399,15 @@ y: startY, targetX, targetY, - vx: 0, - lastMoveX: 0, + vx: previous?.vx || 0, + lastMoveX: previous?.lastMoveX || 0, facing, - idleUntil: authoritativeMotion ? 0 : performance.now() + 700 + Math.random() * 1600, - hiddenUntil: 0, - seed: Math.random() * 9999, - nextDecisionAt: 0, - nextBubbleAt: 800 + Math.random() * 1500, - nextStepParticleAt: performance.now() + 300 + Math.random() * 280, + idleUntil: authoritativeMotion ? 0 : (previous?.idleUntil || performance.now() + 700 + Math.random() * 1600), + hiddenUntil: previous?.hiddenUntil || 0, + seed: previous?.seed || Math.random() * 9999, + nextDecisionAt: previous?.nextDecisionAt || 0, + nextBubbleAt: previous?.nextBubbleAt || 800 + Math.random() * 1500, + nextStepParticleAt: previous?.nextStepParticleAt || performance.now() + 300 + Math.random() * 280, serverMotion: authoritativeMotion }; }).filter(Boolean); @@ -6383,7 +6382,7 @@ function drawShipRipples(pos, time, seedValue = '', asset = null) { account: normalizeAccount(manifest.account), publishLog: Array.isArray(manifest.publishLog) ? manifest.publishLog.slice(-300) : [], pendingPublishLog: Array.isArray(manifest.pendingPublishLog) ? manifest.pendingPublishLog.slice(-300) : [], - worldMode: manifest.worldMode === 'shared' ? 'shared' : 'local', + worldMode: 'shared', serverSync: sanitizeManifestServerSync(manifest.serverSync), tombstones: manifest.tombstones || { assets: {}, objects: {} }, deletedSeedAssetNames: Array.isArray(manifest.deletedSeedAssetNames) ? manifest.deletedSeedAssetNames : [] @@ -6457,7 +6456,7 @@ function drawShipRipples(pos, time, seedValue = '', asset = null) { account: target.account || null, publishLog: Array.isArray(target.publishLog) ? target.publishLog.slice(-300) : [], pendingPublishLog: Array.isArray(target.pendingPublishLog) ? target.pendingPublishLog.slice(-300) : [], - worldMode: target.worldMode === 'shared' ? 'shared' : 'local', + worldMode: 'shared', serverSync, tombstones: target.tombstones || { assets: {}, objects: {} }, deletedSeedAssetNames: Array.isArray(target.deletedSeedAssetNames) ? target.deletedSeedAssetNames : [], @@ -6472,7 +6471,7 @@ function drawShipRipples(pos, time, seedValue = '', asset = null) { function saveState() { state.schema = SAVE_SCHEMA; ensureWorldProtectionState(); - if (state.worldMode !== 'shared') adoptLocalCollectionOwnership(state); + state.worldMode = 'shared'; state.guardrails = { ...PHASE5_GUARDRAILS, ...(state.guardrails || {}) }; state.moderationReports = normalizeModerationReports(state.moderationReports || []); state.settings = { ...defaultVisualSettings(), ...(state.settings || {}) }; @@ -6560,7 +6559,7 @@ function drawShipRipples(pos, time, seedValue = '', asset = null) { account: normalizeAccount(input.account), publishLog: Array.isArray(input.publishLog) ? input.publishLog.slice(-300) : [], pendingPublishLog: Array.isArray(input.pendingPublishLog) ? input.pendingPublishLog.slice(-300) : [], - worldMode: input.worldMode === 'shared' ? 'shared' : 'local', + worldMode: 'shared', serverSync: input.serverSync || { lastServerEventId: null }, tombstones: input.tombstones || { assets: {}, objects: {} }, deletedSeedAssetNames: Array.isArray(input.deletedSeedAssetNames) ? input.deletedSeedAssetNames : [] @@ -6577,7 +6576,6 @@ function drawShipRipples(pos, time, seedValue = '', asset = null) { normalized.placed = normalized.placed.filter((item) => !normalized.tombstones.objects?.[item.id]); normalized.dynamicSummons = normalized.dynamicSummons.filter((item) => !normalized.tombstones.objects?.[item.id]); normalized.placed = repairStaticPlacementTerrain(normalized.placed, normalized.assets); - if (normalized.worldMode !== 'shared') adoptLocalCollectionOwnership(normalized); return normalized; } @@ -6808,7 +6806,7 @@ function drawShipRipples(pos, time, seedValue = '', asset = null) { account: null, publishLog: [], pendingPublishLog: [], - worldMode: 'local', + worldMode: 'shared', serverSync: { lastServerEventId: null, authority: { ...DEFAULT_SERVER_AUTHORITY },