false, 'error' => $message] + $extra, $status); } function request_json(int $maxBytes): array { $raw = file_get_contents('php://input') ?: ''; if ($raw === '') return []; if (strlen($raw) > $maxBytes) fail('request_too_large', 413); $data = json_decode($raw, true); if (!is_array($data)) fail('invalid_json', 400); return $data; } function now_ms(): int { return (int) floor(microtime(true) * 1000); } 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; if (function_exists('mb_substr')) return mb_substr($text, 0, $max, 'UTF-8'); return substr($text, 0, $max); } function clean_id($value, string $fallback = ''): string { $id = trim((string) ($value ?? '')); if ($id === '') return $fallback; $id = preg_replace('/[^A-Za-z0-9_:\-.]/', '', $id) ?? ''; return substr($id, 0, 96); } function db(array $config): PDO { $path = $config['db_path']; $dir = dirname($path); if (!is_dir($dir) && !mkdir($dir, 0775, true) && !is_dir($dir)) fail('db_directory_unavailable', 500); $pdo = new PDO('sqlite:' . $path, null, null, [ PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, ]); $pdo->exec('PRAGMA foreign_keys = ON'); $pdo->exec('PRAGMA busy_timeout = 5000'); $pdo->exec('PRAGMA journal_mode = WAL'); migrate($pdo); return $pdo; } function migrate(PDO $db): void { $db->exec(<<prepare('SELECT * FROM accounts WHERE id = ?'); $stmt->execute([$id]); $row = $stmt->fetch(); $now = now_ms(); if (!$row) { if (!$createIfMissing) fail('account_not_found', 401); $hash = password_hash($password, PASSWORD_DEFAULT); $db->prepare('INSERT INTO accounts(id, name, password_hash, created_at, updated_at) VALUES(?,?,?,?,?)') ->execute([$id, $name, $hash, $now, $now]); return ['id' => $id, 'name' => $name, 'created_at' => $now, 'updated_at' => $now]; } if (!empty($row['disabled_at'])) fail('account_disabled', 403); if (!password_verify($password, $row['password_hash'])) fail('invalid_account_password', 401); if ($name !== '' && $name !== $row['name']) { $db->prepare('UPDATE accounts SET name = ?, updated_at = ? WHERE id = ?')->execute([$name, $now, $id]); $row['name'] = $name; $row['updated_at'] = $now; } return $row; } function record_event(PDO $db, string $type, array $event): int { $now = now_ms(); $db->prepare('INSERT INTO events(type, json, created_at) VALUES(?,?,?)') ->execute([$type, json_encode($event, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), $now]); return (int) $db->lastInsertId(); } function decode_json_row(?string $json): array { $decoded = json_decode((string) $json, true); return is_array($decoded) ? $decoded : []; } function normalize_asset(array $asset, array $actor, array $config): array { $id = clean_id($asset['id'] ?? ''); if ($id === '') fail('asset_id_required'); $category = (($asset['category'] ?? '') === 'dynamic') ? 'dynamic' : 'static'; $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))); $asset['id'] = $id; $asset['name'] = clean_text($asset['name'] ?? 'Untitled', 'Untitled', 32); $asset['category'] = $category; $asset['subtype'] = clean_text($asset['subtype'] ?? ($category === 'dynamic' ? 'human' : 'other'), 'other', 24); $asset['size'] = max($width, $height); $asset['width'] = $width; $asset['height'] = $height; $asset['author'] = clean_text($actor['name'] ?? $actor['id'], $actor['id'], 32); $asset['ownerAccountId'] = $actor['id']; $asset['version'] = max(1, (int) ($asset['version'] ?? 1)); $asset['createdAt'] = (int) ($asset['createdAt'] ?? now_ms()); $asset['updatedAt'] = now_ms(); $asset['parentAssetId'] = isset($asset['parentAssetId']) ? clean_id($asset['parentAssetId'], '') : null; $asset['originalAssetId'] = isset($asset['originalAssetId']) ? clean_id($asset['originalAssetId'], '') : null; if (!isset($asset['pixels']) && !isset($asset['faces'])) fail('asset_pixels_required'); $json = json_encode($asset, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); if ($json === false || strlen($json) > 250000) fail('asset_too_large', 413); return $asset; } function normalize_object(array $object, string $kind, array $actor, array $config): array { $id = clean_id($object['id'] ?? ''); $assetId = clean_id($object['assetId'] ?? ''); if ($id === '' || $assetId === '') fail('object_id_or_asset_required'); $object['id'] = $id; $object['assetId'] = $assetId; $object['ownerAccountId'] = $actor['id']; $object['version'] = max(1, (int) ($object['version'] ?? 1)); $object['status'] = 'active'; $object['publishedAt'] = (int) ($object['publishedAt'] ?? now_ms()); if ($kind === 'dynamic') { $object['homeX'] = max(0, min((int) $config['world_width'] - 1, (int) round((float) ($object['homeX'] ?? $object['x'] ?? 0)))); $object['homeY'] = max(0, min((int) $config['world_height'] - 1, (int) round((float) ($object['homeY'] ?? $object['y'] ?? 0)))); $object['createdAt'] = (int) ($object['createdAt'] ?? now_ms()); } else { $object['x'] = max(0, min((int) $config['world_width'] - 1, (int) round((float) ($object['x'] ?? 0)))); $object['y'] = max(0, min((int) $config['world_height'] - 1, (int) round((float) ($object['y'] ?? 0)))); $object['placedAt'] = (int) ($object['placedAt'] ?? now_ms()); } $json = json_encode($object, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); if ($json === false || strlen($json) > 50000) fail('object_too_large', 413); return $object; } function ensure_asset_owner(PDO $db, string $assetId, string $actorId): array { $stmt = $db->prepare('SELECT * FROM assets WHERE id = ? AND deleted_at IS NULL'); $stmt->execute([$assetId]); $asset = $stmt->fetch(); if (!$asset) fail('asset_not_found', 404); if ($asset['owner_account_id'] !== $actorId) fail('asset_owner_required', 403); return $asset; } function publish_quota_available(PDO $db, array $actor, array $config): bool { $now = now_ms(); $created = (int) ($actor['created_at'] ?? $now); $limit = ($now - $created) < (int) $config['trusted_after_ms'] ? (int) $config['publish_limit_first_day'] : (int) $config['publish_limit_trusted']; $since = $now - 60 * 60 * 1000; $stmt = $db->prepare('SELECT COUNT(*) AS c FROM objects WHERE owner_account_id = ? AND published_at >= ? AND deleted_at IS NULL'); $stmt->execute([$actor['id'], $since]); return ((int) ($stmt->fetch()['c'] ?? 0)) < $limit; } function process_command(PDO $db, array $command, array $actor, array $config): array { $cmdId = clean_id($command['id'] ?? ''); $type = (string) ($command['type'] ?? ''); if ($cmdId === '' || $type === '') return ['ok' => false, 'id' => $cmdId, 'error' => 'invalid_command']; $stmt = $db->prepare('SELECT id FROM commands WHERE id = ?'); $stmt->execute([$cmdId]); if ($stmt->fetch()) return ['ok' => true, 'id' => $cmdId, 'duplicate' => true]; $now = now_ms(); if ($type === 'asset.create') { $asset = normalize_asset(is_array($command['asset'] ?? null) ? $command['asset'] : [], $actor, $config); $existing = $db->prepare('SELECT owner_account_id, deleted_at FROM assets WHERE id = ?'); $existing->execute([$asset['id']]); $row = $existing->fetch(); if ($row && $row['owner_account_id'] !== $actor['id']) fail('asset_id_already_owned', 403); $json = json_encode($asset, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); $db->prepare('INSERT INTO assets(id, owner_account_id, author_name, json, version, created_at, updated_at, deleted_at, deleted_by, content_hash) VALUES(?,?,?,?,?,?,?,?,?,?) ON CONFLICT(id) DO UPDATE SET author_name=excluded.author_name, json=excluded.json, version=excluded.version, updated_at=excluded.updated_at, deleted_at=NULL, deleted_by=NULL, content_hash=excluded.content_hash') ->execute([$asset['id'], $actor['id'], $asset['author'], $json, $asset['version'], $asset['createdAt'], $asset['updatedAt'], null, null, $asset['contentHash'] ?? null]); record_event($db, 'asset.upsert', ['type' => 'asset.upsert', 'assetId' => $asset['id'], 'actorAccountId' => $actor['id']]); } elseif ($type === 'asset.delete') { $assetId = clean_id($command['assetId'] ?? ''); ensure_asset_owner($db, $assetId, $actor['id']); $db->prepare('UPDATE assets SET deleted_at = ?, deleted_by = ?, updated_at = ? WHERE id = ?') ->execute([$now, $actor['id'], $now, $assetId]); $db->prepare('UPDATE objects SET deleted_at = ?, deleted_by = ?, updated_at = ? WHERE asset_id = ? AND deleted_at IS NULL') ->execute([$now, $actor['id'], $now, $assetId]); record_event($db, 'asset.delete', ['type' => 'asset.delete', 'assetId' => $assetId, 'actorAccountId' => $actor['id']]); } elseif ($type === 'object.publish' || $type === 'object.move') { $kind = (($command['kind'] ?? '') === 'dynamic') ? 'dynamic' : 'static'; $object = normalize_object(is_array($command['object'] ?? null) ? $command['object'] : [], $kind, $actor, $config); ensure_asset_owner($db, $object['assetId'], $actor['id']); if ($type === 'object.publish' && !publish_quota_available($db, $actor, $config)) fail('publish_limit_reached', 429); $activeCount = (int) $db->query("SELECT COUNT(*) AS c FROM objects WHERE deleted_at IS NULL AND moderation_status = 'active'")->fetch()['c']; if ($type === 'object.publish' && $activeCount >= (int) $config['max_world_objects']) fail('world_object_limit_reached', 409); $json = json_encode($object, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); $publishedAt = (int) ($object['publishedAt'] ?? $now); $db->prepare('INSERT INTO objects(id, kind, asset_id, owner_account_id, json, version, published_at, updated_at, deleted_at, deleted_by, moderation_status) VALUES(?,?,?,?,?,?,?,?,?,?,?) ON CONFLICT(id) DO UPDATE SET json=excluded.json, version=excluded.version, published_at=excluded.published_at, updated_at=excluded.updated_at, deleted_at=NULL, deleted_by=NULL, moderation_status=\'active\'') ->execute([$object['id'], $kind, $object['assetId'], $actor['id'], $json, $object['version'], $publishedAt, $now, null, null, 'active']); record_event($db, 'object.upsert', ['type' => 'object.upsert', 'kind' => $kind, 'objectId' => $object['id'], 'actorAccountId' => $actor['id']]); } elseif ($type === 'object.delete') { $kind = (($command['kind'] ?? '') === 'dynamic') ? 'dynamic' : 'static'; $objectId = clean_id($command['objectId'] ?? ''); $stmt = $db->prepare('SELECT * FROM objects WHERE id = ? AND kind = ? AND deleted_at IS NULL'); $stmt->execute([$objectId, $kind]); $obj = $stmt->fetch(); if (!$obj) fail('object_not_found', 404); if ($obj['owner_account_id'] !== $actor['id']) fail('object_owner_required', 403); $db->prepare('UPDATE objects SET deleted_at = ?, deleted_by = ?, updated_at = ? WHERE id = ?') ->execute([$now, $actor['id'], $now, $objectId]); record_event($db, 'object.delete', ['type' => 'object.delete', 'kind' => $kind, 'objectId' => $objectId, 'actorAccountId' => $actor['id']]); } elseif ($type === 'vote.asset' || $type === 'vote.object') { $targetType = $type === 'vote.asset' ? 'asset' : 'object'; $targetId = clean_id($command[$targetType . 'Id'] ?? $command['targetId'] ?? ''); $value = max(-1, min(1, (int) ($command['value'] ?? $command['delta'] ?? 0))); if ($targetId === '') fail('vote_target_required'); if ($value === 0) { $db->prepare('DELETE FROM votes WHERE target_type = ? AND target_id = ? AND voter_account_id = ?')->execute([$targetType, $targetId, $actor['id']]); } else { $db->prepare('INSERT INTO votes(target_type, target_id, voter_account_id, value, updated_at) VALUES(?,?,?,?,?) ON CONFLICT(target_type, target_id, voter_account_id) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at') ->execute([$targetType, $targetId, $actor['id'], $value, $now]); } record_event($db, 'vote.' . $targetType, ['type' => 'vote.' . $targetType, 'targetId' => $targetId, 'actorAccountId' => $actor['id'], 'value' => $value]); } elseif ($type === 'report.object') { $objectId = clean_id($command['objectId'] ?? ''); $assetId = clean_id($command['assetId'] ?? ''); $kind = (($command['objectKind'] ?? $command['kind'] ?? '') === 'dynamic') ? 'dynamic' : 'static'; $reason = clean_text($command['reason'] ?? 'other', 'other', 48); if ($objectId === '' || $assetId === '') fail('report_target_required'); $reportId = clean_id($command['reportId'] ?? $command['id'] ?? ('report_' . bin2hex(random_bytes(6)))); $db->prepare('INSERT OR IGNORE INTO reports(id, object_id, asset_id, object_kind, reporter_account_id, reason, created_at, status) VALUES(?,?,?,?,?,?,?,?)') ->execute([$reportId, $objectId, $assetId, $kind, $actor['id'], $reason, $now, 'open']); record_event($db, 'report.object', ['type' => 'report.object', 'objectId' => $objectId, 'assetId' => $assetId, 'actorAccountId' => $actor['id'], 'reason' => $reason]); } else { return ['ok' => false, 'id' => $cmdId, 'error' => 'unsupported_command']; } $db->prepare('INSERT INTO commands(id, actor_account_id, created_at, applied_at) VALUES(?,?,?,?)') ->execute([$cmdId, $actor['id'], (int) ($command['createdAt'] ?? $now), $now]); return ['ok' => true, 'id' => $cmdId]; } function vote_snapshot(PDO $db, string $targetType): array { $stmt = $db->prepare('SELECT target_id, voter_account_id, value FROM votes WHERE target_type = ? AND value != 0'); $stmt->execute([$targetType]); $out = []; while ($row = $stmt->fetch()) { $id = $row['target_id']; $value = (int) $row['value']; if (!isset($out[$id])) $out[$id] = ['up' => 0, 'down' => 0, 'voters' => []]; if ($value > 0) $out[$id]['up']++; if ($value < 0) $out[$id]['down']++; $out[$id]['voters'][$row['voter_account_id']] = $value; } return $out; } function build_snapshot(PDO $db): array { $assets = []; foreach ($db->query('SELECT json FROM assets WHERE deleted_at IS NULL ORDER BY updated_at DESC LIMIT 2000') as $row) { $asset = decode_json_row($row['json']); if ($asset) $assets[] = $asset; } $placed = []; $dynamic = []; foreach ($db->query("SELECT kind, json FROM objects WHERE deleted_at IS NULL AND moderation_status = 'active' ORDER BY published_at DESC LIMIT 1000") as $row) { $obj = decode_json_row($row['json']); if (!$obj) continue; if ($row['kind'] === 'dynamic') $dynamic[] = $obj; else $placed[] = $obj; } $assetTombstones = []; foreach ($db->query('SELECT id, deleted_at, deleted_by, version FROM assets WHERE deleted_at IS NOT NULL') as $row) { $assetTombstones[$row['id']] = ['id' => $row['id'], 'deletedAt' => (int) $row['deleted_at'], 'deletedBy' => $row['deleted_by'], 'version' => (int) $row['version']]; } $objectTombstones = []; foreach ($db->query('SELECT id, asset_id, deleted_at, deleted_by, version FROM objects WHERE deleted_at IS NOT NULL') as $row) { $objectTombstones[$row['id']] = ['id' => $row['id'], 'assetId' => $row['asset_id'], 'deletedAt' => (int) $row['deleted_at'], 'deletedBy' => $row['deleted_by'], 'version' => (int) $row['version']]; } $reports = []; foreach ($db->query("SELECT * FROM reports WHERE status = 'open' ORDER BY created_at DESC LIMIT 500") as $row) { $reports[] = [ 'id' => $row['id'], 'objectId' => $row['object_id'], 'assetId' => $row['asset_id'], 'objectKind' => $row['object_kind'], 'reporter' => $row['reporter_account_id'], 'reason' => $row['reason'], 'createdAt' => (int) $row['created_at'], ]; } $last = $db->query('SELECT COALESCE(MAX(id), 0) AS id FROM events')->fetch(); return [ 'ok' => true, 'schema' => 1, 'serverNow' => now_ms(), 'lastEventId' => (int) ($last['id'] ?? 0), 'authority' => ['publish' => 'server', 'objectMove' => 'server', 'dayNight' => 'local', 'dynamicMotion' => 'local'], 'assets' => $assets, 'placed' => $placed, 'dynamicSummons' => $dynamic, 'assetVotes' => vote_snapshot($db, 'asset'), 'objectVotes' => vote_snapshot($db, 'object'), 'moderationReports' => $reports, 'tombstones' => ['assets' => $assetTombstones, 'objects' => $objectTombstones], ]; } try { $action = $_GET['action'] ?? ''; if ($action === '') { $path = trim((string) parse_url($_SERVER['REQUEST_URI'] ?? '', PHP_URL_PATH), '/'); $action = basename($path) === 'index.php' ? '' : basename($path); } $database = db($config); if ($action === 'health' || $action === '') { respond(['ok' => true, 'service' => 'pixel-island-api', 'serverNow' => now_ms(), 'sqlite' => true]); } if ($action === 'snapshot') { respond(build_snapshot($database)); } if ($action === 'account') { $data = request_json((int) $config['max_json_bytes']); $account = authenticate($database, is_array($data['account'] ?? null) ? $data['account'] : $data, true); respond(['ok' => true, 'account' => ['id' => $account['id'], 'name' => $account['name'], 'createdAt' => (int) $account['created_at']]]); } if ($action === 'commands') { $data = request_json((int) $config['max_json_bytes']); $actor = authenticate($database, is_array($data['account'] ?? null) ? $data['account'] : [], true); $commands = is_array($data['commands'] ?? null) ? $data['commands'] : []; if (count($commands) > 100) fail('too_many_commands', 413); $applied = []; $rejected = []; foreach ($commands as $command) { if (!is_array($command)) continue; try { $database->beginTransaction(); $result = process_command($database, $command, $actor, $config); if ($result['ok'] ?? false) { $database->commit(); $applied[] = $result['id']; } else { $database->rollBack(); $rejected[] = $result; } } catch (Throwable $e) { if ($database->inTransaction()) $database->rollBack(); $rejected[] = ['ok' => false, 'id' => clean_id($command['id'] ?? ''), 'error' => $e->getMessage()]; } } respond(['ok' => true, 'appliedCommandIds' => $applied, 'rejectedCommands' => $rejected, 'snapshot' => build_snapshot($database)]); } fail('unknown_action', 404); } catch (Throwable $e) { fail($e->getMessage(), 500); }