more stable

This commit is contained in:
33333-33333 2026-06-04 15:08:25 +09:00
commit 88b5669961
3 changed files with 372 additions and 111 deletions

View file

@ -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 '<div class="thumb empty"></div>';
$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 .= '<i style="left:' . h($x) . 'px;top:' . h($y) . 'px;background:' . h($color) . '"></i>';
}
}
return '<div class="thumb" style="--w:' . h($width) . ';--h:' . h($height) . '">' . $cells . '</div>';
}
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 '<!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>';
echo '<!doctype html><meta charset="utf-8"><title>Admin disabled</title><h1>Admin disabled</h1>';
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);
?>
<!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}
body{font-family:system-ui,sans-serif;margin:24px;background:#f7f4ea;color:#2b2b2b}h1{margin-bottom:4px}.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(310px,1fr));gap:12px}.card{background:#fff;border:1px solid #ddd;border-radius:10px;padding:14px;margin:10px 0}.media{display:flex;gap:12px;align-items:flex-start}.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}input{max-width:210px;padding:6px;margin:2px 4px 2px 0}button{margin:2px 4px 2px 0;padding:6px 9px;border:1px solid #bbb;border-radius:6px;background:#f5f5f5}.danger{background:#b92323;color:white;border:0}.ok{background:#267a3e;color:white;border:0}.warn{background:#8b5b00;color:white;border:0}.pill{display:inline-block;padding:2px 7px;border-radius:999px;background:#eee;font-size:12px}.nav a{margin-right:10px}.thumb{position:relative;flex:0 0 auto;width:72px;height:72px;background:#f2eadb;border:1px solid #ddd;image-rendering:pixelated}.thumb i{position:absolute;width:calc(72px / var(--w));height:calc(72px / var(--h));transform:scale(1.02);transform-origin:0 0}.thumb.empty:after{content:'no image';position:absolute;inset:0;display:grid;place-items:center;color:#777;font-size:11px}
</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>
<p class="muted">Store: <?=h($storeLabel)?> / Users: <?=count($accounts)?> / Pictures: <?=count($assets)?> / Reports: <?=count($reports)?></p>
<p class="nav"><a href="#accounts">Users</a><a href="#pictures">Pictures</a><a href="#reports">Reports</a><?php if ($ownerFilter !== ''): ?><a href="<?=h($baseUrl)?>">Show all</a><?php endif; ?></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): ?>
<h2 id="accounts">Users</h2>
<div class="grid">
<?php foreach ($accounts as $a): ?>
<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>
<h3><?=h($a['name'] ?? $a['id'])?> <?php if (!empty($a['disabled_at'])): ?><span class="pill">disabled</span><?php endif; ?></h3>
<p class="muted">ID: <code><?=h($a['id'] ?? '')?></code> / Pictures: <?=h($a['asset_count'] ?? 0)?> / Objects: <?=h($a['object_count'] ?? 0)?> / Reports: <?=h($a['open_report_count'] ?? 0)?></p>
<p><a href="<?=h($baseUrl . '&owner=' . rawurlencode((string)($a['id'] ?? '')))?>">Pictures</a></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>
<input type="hidden" name="owner" value="<?=h($ownerFilter)?>">
<input type="hidden" name="account_id" value="<?=h($a['id'] ?? '')?>">
<input name="name" value="<?=h($a['name'] ?? '')?>" maxlength="32">
<button class="ok" name="admin_action" value="rename_account">Save</button>
<?php if (empty($a['disabled_at'])): ?><button class="warn" name="admin_action" value="disable_account">Disable</button><?php else: ?><button class="ok" name="admin_action" value="enable_account">Enable</button><?php endif; ?>
<?php if (($a['id'] ?? '') !== 'island-team'): ?><button class="danger" name="admin_action" value="delete_account" onclick="return confirm('Delete this user and hide their pictures?')">Delete</button><?php endif; ?>
</form>
</div>
<?php endforeach; ?>
</div>
<?php if (!$accounts): ?><p>No accounts.</p><?php endif; ?>
<h2 id="pictures">Pictures<?= $ownerFilter !== '' ? ' by ' . h($ownerFilter) : '' ?></h2>
<div class="grid">
<?php foreach ($assets as $asset): ?>
<div class="card">
<div class="media">
<?=thumbnail_html($asset['json'] ?? [])?>
<div>
<h3><?=h($asset['name'] ?? 'Untitled')?></h3>
<p class="muted">ID: <code><?=h($asset['id'] ?? '')?></code><br>User: <code><?=h($asset['owner_account_id'] ?? '')?></code> <?=h($asset['owner_name'] ?? '')?><br>Type: <?=h(trim(($asset['category'] ?? '') . ' ' . ($asset['subtype'] ?? '')))?> / Uses: <?=h($asset['object_count'] ?? 0)?></p>
<form method="post">
<input type="hidden" name="token" value="<?=h($given)?>">
<input type="hidden" name="owner" value="<?=h($ownerFilter)?>">
<input type="hidden" name="asset_id" value="<?=h($asset['id'] ?? '')?>">
<input name="name" value="<?=h($asset['name'] ?? '')?>" maxlength="32">
<button class="ok" name="admin_action" value="rename_asset">Save</button>
<button class="danger" name="admin_action" value="delete_asset" onclick="return confirm('Delete this picture?')">Delete</button>
</form>
</div>
</div>
</div>
<?php endforeach; ?>
</div>
<?php if (!$assets): ?><p>No pictures in this view.</p><?php endif; ?>
<h2 id="reports">Reports</h2>
<?php foreach ($reports as $r): ?>
<div class="card">
<div class="media">
<?=thumbnail_html($r['asset_json'] ?? [])?>
<div>
<h3><?=h($r['asset_name'] ?? 'Untitled')?></h3>
<p><strong>Reason:</strong> <?=h($r['reason'] ?? '')?> / <strong>By:</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="owner" value="<?=h($ownerFilter)?>">
<input type="hidden" name="object_id" value="<?=h($r['object_id'] ?? '')?>">
<button class="danger" name="admin_action" value="hide">Hide</button>
<button class="ok" name="admin_action" value="restore">Restore</button>
<button name="admin_action" value="close_reports">Close</button>
</form>
</div>
</div>
</div>
<?php endforeach; ?>
<?php if (!$reports): ?><p>No open reports.</p><?php endif; ?>

View file

@ -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',
];

50
app.js
View file

@ -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 },