581 lines
31 KiB
PHP
581 lines
31 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
$config = require __DIR__ . '/config.php';
|
|
|
|
header('Content-Type: application/json; charset=utf-8');
|
|
header('X-Content-Type-Options: nosniff');
|
|
|
|
$drivers = class_exists('PDO') ? PDO::getAvailableDrivers() : [];
|
|
if (!in_array('sqlite', $drivers, true)) {
|
|
require __DIR__ . '/filedb.php';
|
|
exit;
|
|
}
|
|
|
|
function respond(array $payload, int $status = 200): void {
|
|
http_response_code($status);
|
|
echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
|
exit;
|
|
}
|
|
|
|
function fail(string $message, int $status = 400, array $extra = []): void {
|
|
if (!empty($GLOBALS['PIXEL_ISLAND_COMMAND_CONTEXT'])) {
|
|
throw new RuntimeException($message, $status);
|
|
}
|
|
respond(['ok' => 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(<<<SQL
|
|
CREATE TABLE IF NOT EXISTS accounts (
|
|
id TEXT PRIMARY KEY,
|
|
name TEXT NOT NULL,
|
|
password_hash TEXT NOT NULL,
|
|
created_at INTEGER NOT NULL,
|
|
updated_at INTEGER NOT NULL,
|
|
disabled_at INTEGER
|
|
);
|
|
CREATE TABLE IF NOT EXISTS assets (
|
|
id TEXT PRIMARY KEY,
|
|
owner_account_id TEXT NOT NULL,
|
|
author_name TEXT NOT NULL,
|
|
json TEXT NOT NULL,
|
|
version INTEGER NOT NULL DEFAULT 1,
|
|
created_at INTEGER NOT NULL,
|
|
updated_at INTEGER NOT NULL,
|
|
deleted_at INTEGER,
|
|
deleted_by TEXT,
|
|
content_hash TEXT
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_assets_owner ON assets(owner_account_id);
|
|
CREATE TABLE IF NOT EXISTS objects (
|
|
id TEXT PRIMARY KEY,
|
|
kind TEXT NOT NULL CHECK(kind IN ('static','dynamic')),
|
|
asset_id TEXT NOT NULL,
|
|
owner_account_id TEXT NOT NULL,
|
|
json TEXT NOT NULL,
|
|
version INTEGER NOT NULL DEFAULT 1,
|
|
published_at INTEGER NOT NULL,
|
|
updated_at INTEGER NOT NULL,
|
|
deleted_at INTEGER,
|
|
deleted_by TEXT,
|
|
moderation_status TEXT NOT NULL DEFAULT 'active'
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_objects_asset ON objects(asset_id);
|
|
CREATE INDEX IF NOT EXISTS idx_objects_owner ON objects(owner_account_id);
|
|
CREATE INDEX IF NOT EXISTS idx_objects_active ON objects(kind, deleted_at, moderation_status);
|
|
CREATE TABLE IF NOT EXISTS votes (
|
|
target_type TEXT NOT NULL CHECK(target_type IN ('asset','object')),
|
|
target_id TEXT NOT NULL,
|
|
voter_account_id TEXT NOT NULL,
|
|
value INTEGER NOT NULL CHECK(value IN (-1,0,1)),
|
|
updated_at INTEGER NOT NULL,
|
|
PRIMARY KEY(target_type, target_id, voter_account_id)
|
|
);
|
|
CREATE TABLE IF NOT EXISTS reports (
|
|
id TEXT PRIMARY KEY,
|
|
object_id TEXT NOT NULL,
|
|
asset_id TEXT NOT NULL,
|
|
object_kind TEXT NOT NULL CHECK(object_kind IN ('static','dynamic')),
|
|
reporter_account_id TEXT NOT NULL,
|
|
reason TEXT NOT NULL,
|
|
created_at INTEGER NOT NULL,
|
|
status TEXT NOT NULL DEFAULT 'open'
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_reports_object ON reports(object_id);
|
|
CREATE TABLE IF NOT EXISTS events (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
type TEXT NOT NULL,
|
|
json TEXT NOT NULL,
|
|
created_at INTEGER NOT NULL
|
|
);
|
|
CREATE TABLE IF NOT EXISTS commands (
|
|
id TEXT PRIMARY KEY,
|
|
actor_account_id TEXT NOT NULL,
|
|
created_at INTEGER NOT NULL,
|
|
applied_at INTEGER NOT NULL,
|
|
result_json TEXT
|
|
);
|
|
SQL);
|
|
$columns = [];
|
|
foreach ($db->query('PRAGMA table_info(commands)') as $row) $columns[$row['name']] = true;
|
|
if (empty($columns['result_json'])) $db->exec('ALTER TABLE commands ADD COLUMN result_json TEXT');
|
|
}
|
|
|
|
function authenticate(PDO $db, array $account, bool $createIfMissing = true): array {
|
|
$id = clean_id($account['id'] ?? '');
|
|
$password = (string) ($account['password'] ?? $account['pass'] ?? '');
|
|
$name = clean_text($account['name'] ?? $id, $id, 32);
|
|
if ($id === '' || $password === '') fail('account_required', 401);
|
|
|
|
$stmt = $db->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 default_seed_path(): string {
|
|
return dirname(__DIR__) . DIRECTORY_SEPARATOR . '_data' . DIRECTORY_SEPARATOR . 'default_gallery_seed.json';
|
|
}
|
|
|
|
function read_default_seed(): array {
|
|
$path = default_seed_path();
|
|
if (!is_file($path)) return ['assets' => [], 'placed' => [], 'dynamicSummons' => []];
|
|
$raw = file_get_contents($path);
|
|
if (!is_string($raw) || $raw === '') return ['assets' => [], 'placed' => [], 'dynamicSummons' => []];
|
|
$data = json_decode($raw, true);
|
|
return is_array($data) ? $data : ['assets' => [], 'placed' => [], 'dynamicSummons' => []];
|
|
}
|
|
|
|
function seed_default_gallery(PDO $db): void {
|
|
static $done = false;
|
|
if ($done) return;
|
|
$done = true;
|
|
$seed = read_default_seed();
|
|
$assets = is_array($seed['assets'] ?? null) ? $seed['assets'] : [];
|
|
$placed = is_array($seed['placed'] ?? null) ? $seed['placed'] : [];
|
|
$dynamic = is_array($seed['dynamicSummons'] ?? null) ? $seed['dynamicSummons'] : [];
|
|
if (!$assets && !$placed && !$dynamic) return;
|
|
$now = now_ms();
|
|
$db->prepare('INSERT OR IGNORE INTO accounts(id, name, password_hash, created_at, updated_at) VALUES(?,?,?,?,?)')
|
|
->execute(['island-team', 'Pixel Island', password_hash(bin2hex(random_bytes(16)), PASSWORD_DEFAULT), $now, $now]);
|
|
foreach ($assets as $asset) {
|
|
if (!is_array($asset)) continue;
|
|
$id = clean_id($asset['id'] ?? '');
|
|
if ($id === '') continue;
|
|
$exists = $db->prepare('SELECT id FROM assets WHERE id = ?');
|
|
$exists->execute([$id]);
|
|
if ($exists->fetch()) continue;
|
|
$asset['ownerAccountId'] = 'island-team';
|
|
$asset['author'] = $asset['author'] ?? 'Pixel Island';
|
|
$asset['contentHash'] = $asset['contentHash'] ?? null;
|
|
$json = json_encode($asset, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
|
if ($json === false) continue;
|
|
$db->prepare('INSERT INTO assets(id, owner_account_id, author_name, json, version, created_at, updated_at, deleted_at, deleted_by, content_hash) VALUES(?,?,?,?,?,?,?,?,?,?)')
|
|
->execute([$id, 'island-team', clean_text($asset['author'] ?? 'Pixel Island', 'Pixel Island', 32), $json, max(1, (int)($asset['version'] ?? 1)), (int)($asset['createdAt'] ?? $now), (int)($asset['updatedAt'] ?? $now), null, null, $asset['contentHash'] ?? null]);
|
|
}
|
|
$insertObject = function(array $object, string $kind) use ($db, $now): void {
|
|
$id = clean_id($object['id'] ?? '');
|
|
$assetId = clean_id($object['assetId'] ?? '');
|
|
if ($id === '' || $assetId === '') return;
|
|
$exists = $db->prepare('SELECT id FROM objects WHERE id = ?');
|
|
$exists->execute([$id]);
|
|
if ($exists->fetch()) return;
|
|
$assetExists = $db->prepare('SELECT id FROM assets WHERE id = ? AND deleted_at IS NULL');
|
|
$assetExists->execute([$assetId]);
|
|
if (!$assetExists->fetch()) return;
|
|
$object['ownerAccountId'] = 'island-team';
|
|
$object['status'] = 'active';
|
|
$object['publishedAt'] = (int)($object['publishedAt'] ?? $now);
|
|
$json = json_encode($object, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
|
if ($json === false) return;
|
|
$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(?,?,?,?,?,?,?,?,?,?,?)')
|
|
->execute([$id, $kind, $assetId, 'island-team', $json, max(1, (int)($object['version'] ?? 1)), (int)$object['publishedAt'], $now, null, null, 'active']);
|
|
};
|
|
foreach ($placed as $object) if (is_array($object)) $insertObject($object, 'static');
|
|
foreach ($dynamic as $object) if (is_array($object)) $insertObject($object, 'dynamic');
|
|
}
|
|
|
|
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 store_command_result(PDO $db, string $cmdId, string $actorId, int $createdAt, array $result): void {
|
|
$json = json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
|
$db->prepare('INSERT INTO commands(id, actor_account_id, created_at, applied_at, result_json) VALUES(?,?,?,?,?)')
|
|
->execute([$cmdId, $actorId, $createdAt, now_ms(), $json === false ? null : $json]);
|
|
}
|
|
|
|
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 result_json FROM commands WHERE id = ?');
|
|
$stmt->execute([$cmdId]);
|
|
$stored = $stmt->fetch();
|
|
if ($stored) {
|
|
$result = decode_json_row($stored['result_json'] ?? '');
|
|
if (!$result) $result = ['ok' => true, 'id' => $cmdId];
|
|
$result['duplicate'] = true;
|
|
return $result;
|
|
}
|
|
|
|
$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 === 'publish.asset_object') {
|
|
$asset = normalize_asset(is_array($command['asset'] ?? null) ? $command['asset'] : [], $actor, $config);
|
|
$kind = (($command['kind'] ?? $asset['category'] ?? '') === 'dynamic') ? 'dynamic' : 'static';
|
|
$object = normalize_object(is_array($command['object'] ?? null) ? $command['object'] : [], $kind, $actor, $config);
|
|
if ($object['assetId'] !== $asset['id']) fail('publish_asset_mismatch', 400);
|
|
$existingAsset = $db->prepare('SELECT owner_account_id FROM assets WHERE id = ? AND deleted_at IS NULL');
|
|
$existingAsset->execute([$asset['id']]);
|
|
$assetRow = $existingAsset->fetch();
|
|
if ($assetRow && $assetRow['owner_account_id'] !== $actor['id']) fail('asset_id_already_owned', 403);
|
|
if (!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 ($activeCount >= (int) $config['max_world_objects']) fail('world_object_limit_reached', 409);
|
|
|
|
$assetJson = json_encode($asset, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
|
if ($assetJson === false) fail('asset_too_large', 413);
|
|
$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'], $assetJson, $asset['version'], $asset['createdAt'], $asset['updatedAt'], null, null, $asset['contentHash'] ?? null]);
|
|
|
|
$object['publishedAt'] = $now;
|
|
if ($kind === 'dynamic') $object['createdAt'] = $now;
|
|
else $object['placedAt'] = $now;
|
|
$objectJson = json_encode($object, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
|
if ($objectJson === false) fail('object_too_large', 413);
|
|
$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(?,?,?,?,?,?,?,?,?,?,?)')
|
|
->execute([$object['id'], $kind, $object['assetId'], $actor['id'], $objectJson, $object['version'], $now, $now, null, null, 'active']);
|
|
record_event($db, 'asset.upsert', ['type' => 'asset.upsert', 'assetId' => $asset['id'], 'actorAccountId' => $actor['id']]);
|
|
record_event($db, 'object.upsert', ['type' => 'object.upsert', 'kind' => $kind, 'objectId' => $object['id'], 'actorAccountId' => $actor['id']]);
|
|
$result = ['ok' => true, 'id' => $cmdId, 'type' => $type, 'assetId' => $asset['id'], 'objectId' => $object['id'], 'kind' => $kind, 'publishedAt' => $now, 'object' => $object];
|
|
store_command_result($db, $cmdId, $actor['id'], (int) ($command['createdAt'] ?? $now), $result);
|
|
return $result;
|
|
} 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);
|
|
if ($type === 'object.publish') $object['publishedAt'] = $now;
|
|
$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'];
|
|
}
|
|
|
|
$result = ['ok' => true, 'id' => $cmdId, 'type' => $type];
|
|
store_command_result($db, $cmdId, $actor['id'], (int) ($command['createdAt'] ?? $now), $result);
|
|
return $result;
|
|
}
|
|
|
|
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 {
|
|
seed_default_gallery($db);
|
|
$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();
|
|
$GLOBALS['PIXEL_ISLAND_COMMAND_CONTEXT'] = true;
|
|
$result = process_command($database, $command, $actor, $config);
|
|
$GLOBALS['PIXEL_ISLAND_COMMAND_CONTEXT'] = false;
|
|
if ($result['ok'] ?? false) {
|
|
$database->commit();
|
|
$applied[] = $result['id'];
|
|
} else {
|
|
$database->rollBack();
|
|
$rejected[] = $result;
|
|
}
|
|
} catch (Throwable $e) {
|
|
$GLOBALS['PIXEL_ISLAND_COMMAND_CONTEXT'] = false;
|
|
if ($database->inTransaction()) $database->rollBack();
|
|
$id = clean_id($command['id'] ?? '');
|
|
$rejection = ['ok' => false, 'id' => $id, 'type' => (string)($command['type'] ?? ''), 'error' => $e->getMessage()];
|
|
if ($id !== '') {
|
|
try { store_command_result($database, $id, $actor['id'], (int)($command['createdAt'] ?? now_ms()), $rejection); }
|
|
catch (Throwable $ignored) {}
|
|
}
|
|
$rejected[] = $rejection;
|
|
}
|
|
}
|
|
respond(['ok' => true, 'appliedCommandIds' => $applied, 'rejectedCommands' => $rejected, 'snapshot' => build_snapshot($database)]);
|
|
}
|
|
|
|
fail('unknown_action', 404);
|
|
} catch (Throwable $e) {
|
|
fail($e->getMessage(), 500);
|
|
}
|