176 lines
16 KiB
PHP
176 lines
16 KiB
PHP
|
|
<?php
|
||
|
|
declare(strict_types=1);
|
||
|
|
// Fallback backend for hosts without pdo_sqlite. Uses a locked JSON file under _data/.
|
||
|
|
// Prefer SQLite for real traffic; this fallback is intended for small HTTPS test publication.
|
||
|
|
|
||
|
|
function fb_respond(array $payload, int $status = 200): void {
|
||
|
|
http_response_code($status);
|
||
|
|
echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||
|
|
exit;
|
||
|
|
}
|
||
|
|
function fb_fail(string $message, int $status = 400, array $extra = []): void { fb_respond(['ok' => false, 'error' => $message] + $extra, $status); }
|
||
|
|
function fb_now(): int { return (int) floor(microtime(true) * 1000); }
|
||
|
|
function fb_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 fb_clean_id($value, string $fallback = ''): string {
|
||
|
|
$id = trim((string)($value ?? ''));
|
||
|
|
if ($id === '') return $fallback;
|
||
|
|
return substr(preg_replace('/[^A-Za-z0-9_:\-.]/', '', $id) ?? '', 0, 96);
|
||
|
|
}
|
||
|
|
function fb_request_json(int $maxBytes): array {
|
||
|
|
$raw = file_get_contents('php://input') ?: '';
|
||
|
|
if ($raw === '') return [];
|
||
|
|
if (strlen($raw) > $maxBytes) fb_fail('request_too_large', 413);
|
||
|
|
$data = json_decode($raw, true);
|
||
|
|
if (!is_array($data)) fb_fail('invalid_json', 400);
|
||
|
|
return $data;
|
||
|
|
}
|
||
|
|
function fb_default_db(): array {
|
||
|
|
return ['schema'=>1,'accounts'=>[],'assets'=>[],'objects'=>[],'votes'=>[],'reports'=>[],'events'=>[],'commands'=>[],'nextEventId'=>1];
|
||
|
|
}
|
||
|
|
function fb_db_path(array $config): string {
|
||
|
|
return dirname($config['db_path']) . DIRECTORY_SEPARATOR . 'pixel_island_filedb.json';
|
||
|
|
}
|
||
|
|
function fb_with_db(array $config, callable $callback): void {
|
||
|
|
$path = fb_db_path($config);
|
||
|
|
$dir = dirname($path);
|
||
|
|
if (!is_dir($dir) && !mkdir($dir, 0775, true) && !is_dir($dir)) fb_fail('db_directory_unavailable', 500);
|
||
|
|
$fp = fopen($path, 'c+');
|
||
|
|
if (!$fp) fb_fail('filedb_unavailable', 500);
|
||
|
|
try {
|
||
|
|
if (!flock($fp, LOCK_EX)) fb_fail('filedb_lock_failed', 503);
|
||
|
|
$raw = stream_get_contents($fp);
|
||
|
|
$db = $raw ? json_decode($raw, true) : null;
|
||
|
|
if (!is_array($db)) $db = fb_default_db();
|
||
|
|
$result = $callback($db);
|
||
|
|
ftruncate($fp, 0);
|
||
|
|
rewind($fp);
|
||
|
|
fwrite($fp, json_encode($db, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
|
||
|
|
fflush($fp);
|
||
|
|
flock($fp, LOCK_UN);
|
||
|
|
fclose($fp);
|
||
|
|
fb_respond($result);
|
||
|
|
} catch (Throwable $e) {
|
||
|
|
flock($fp, LOCK_UN);
|
||
|
|
fclose($fp);
|
||
|
|
fb_fail($e->getMessage(), 500);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
function fb_auth(array &$db, array $account, bool $create = true): array {
|
||
|
|
$id = fb_clean_id($account['id'] ?? '');
|
||
|
|
$password = (string)($account['password'] ?? $account['pass'] ?? '');
|
||
|
|
$name = fb_clean_text($account['name'] ?? $id, $id, 32);
|
||
|
|
if ($id === '' || $password === '') fb_fail('account_required', 401);
|
||
|
|
$now = fb_now();
|
||
|
|
if (!isset($db['accounts'][$id])) {
|
||
|
|
if (!$create) fb_fail('account_not_found', 401);
|
||
|
|
$db['accounts'][$id] = ['id'=>$id,'name'=>$name,'password_hash'=>password_hash($password, PASSWORD_DEFAULT),'created_at'=>$now,'updated_at'=>$now,'disabled_at'=>null];
|
||
|
|
} else {
|
||
|
|
$row = $db['accounts'][$id];
|
||
|
|
if (!empty($row['disabled_at'])) fb_fail('account_disabled', 403);
|
||
|
|
if (!password_verify($password, $row['password_hash'] ?? '')) fb_fail('invalid_account_password', 401);
|
||
|
|
if ($name !== '' && $name !== ($row['name'] ?? '')) {
|
||
|
|
$db['accounts'][$id]['name'] = $name;
|
||
|
|
$db['accounts'][$id]['updated_at'] = $now;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return $db['accounts'][$id];
|
||
|
|
}
|
||
|
|
function fb_event(array &$db, string $type, array $event): int {
|
||
|
|
$id = (int)($db['nextEventId'] ?? 1);
|
||
|
|
$db['nextEventId'] = $id + 1;
|
||
|
|
$db['events'][] = ['id'=>$id,'type'=>$type,'json'=>$event,'created_at'=>fb_now()];
|
||
|
|
if (count($db['events']) > 5000) $db['events'] = array_slice($db['events'], -5000);
|
||
|
|
return $id;
|
||
|
|
}
|
||
|
|
function fb_normalize_asset(array $asset, array $actor): array {
|
||
|
|
$id = fb_clean_id($asset['id'] ?? '');
|
||
|
|
if ($id === '') fb_fail('asset_id_required');
|
||
|
|
$category = (($asset['category'] ?? '') === 'dynamic') ? 'dynamic' : 'static';
|
||
|
|
$w = max(1, min(64, (int)($asset['width'] ?? $asset['w'] ?? $asset['size'] ?? 16)));
|
||
|
|
$h = max(1, min(64, (int)($asset['height'] ?? $asset['ht'] ?? $asset['size'] ?? $w)));
|
||
|
|
$asset['id']=$id; $asset['name']=fb_clean_text($asset['name'] ?? 'Untitled','Untitled',32);
|
||
|
|
$asset['category']=$category; $asset['subtype']=fb_clean_text($asset['subtype'] ?? ($category==='dynamic'?'human':'other'),'other',24);
|
||
|
|
$asset['size']=max($w,$h); $asset['width']=$w; $asset['height']=$h;
|
||
|
|
$asset['author']=fb_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'] ?? fb_now()); $asset['updatedAt']=fb_now();
|
||
|
|
if (!isset($asset['pixels']) && !isset($asset['faces'])) fb_fail('asset_pixels_required');
|
||
|
|
if (strlen(json_encode($asset, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)) > 250000) fb_fail('asset_too_large', 413);
|
||
|
|
return $asset;
|
||
|
|
}
|
||
|
|
function fb_normalize_object(array $object, string $kind, array $actor, array $config): array {
|
||
|
|
$id=fb_clean_id($object['id'] ?? ''); $assetId=fb_clean_id($object['assetId'] ?? '');
|
||
|
|
if ($id==='' || $assetId==='') fb_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'] ?? fb_now());
|
||
|
|
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'] ?? fb_now()); }
|
||
|
|
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'] ?? fb_now()); }
|
||
|
|
return $object;
|
||
|
|
}
|
||
|
|
function fb_asset_owner(array $db, string $assetId, string $actorId): void {
|
||
|
|
$row = $db['assets'][$assetId] ?? null;
|
||
|
|
if (!$row || !empty($row['deleted_at'])) fb_fail('asset_not_found', 404);
|
||
|
|
if (($row['owner_account_id'] ?? '') !== $actorId) fb_fail('asset_owner_required', 403);
|
||
|
|
}
|
||
|
|
function fb_quota(array $db, array $actor, array $config): bool {
|
||
|
|
$now=fb_now(); $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-3600000; $used=0;
|
||
|
|
foreach ($db['objects'] as $o) if (($o['owner_account_id'] ?? '')===$actor['id'] && empty($o['deleted_at']) && (int)($o['published_at'] ?? 0) >= $since) $used++;
|
||
|
|
return $used < $limit;
|
||
|
|
}
|
||
|
|
function fb_process(array &$db, array $command, array $actor, array $config): array {
|
||
|
|
$cmdId=fb_clean_id($command['id'] ?? ''); $type=(string)($command['type'] ?? '');
|
||
|
|
if ($cmdId==='' || $type==='') return ['ok'=>false,'id'=>$cmdId,'error'=>'invalid_command'];
|
||
|
|
if (isset($db['commands'][$cmdId])) return ['ok'=>true,'id'=>$cmdId,'duplicate'=>true];
|
||
|
|
$now=fb_now();
|
||
|
|
if ($type==='asset.create') {
|
||
|
|
$asset=fb_normalize_asset(is_array($command['asset'] ?? null)?$command['asset']:[], $actor);
|
||
|
|
if (isset($db['assets'][$asset['id']]) && ($db['assets'][$asset['id']]['owner_account_id'] ?? '') !== $actor['id']) fb_fail('asset_id_already_owned',403);
|
||
|
|
$db['assets'][$asset['id']]=['id'=>$asset['id'],'owner_account_id'=>$actor['id'],'author_name'=>$asset['author'],'json'=>$asset,'version'=>$asset['version'],'created_at'=>$asset['createdAt'],'updated_at'=>$asset['updatedAt'],'deleted_at'=>null,'deleted_by'=>null,'content_hash'=>$asset['contentHash'] ?? null];
|
||
|
|
fb_event($db,'asset.upsert',['type'=>'asset.upsert','assetId'=>$asset['id'],'actorAccountId'=>$actor['id']]);
|
||
|
|
} elseif ($type==='asset.delete') {
|
||
|
|
$assetId=fb_clean_id($command['assetId'] ?? ''); fb_asset_owner($db,$assetId,$actor['id']);
|
||
|
|
$db['assets'][$assetId]['deleted_at']=$now; $db['assets'][$assetId]['deleted_by']=$actor['id']; $db['assets'][$assetId]['updated_at']=$now;
|
||
|
|
foreach ($db['objects'] as &$o) if (($o['asset_id'] ?? '')===$assetId && empty($o['deleted_at'])) { $o['deleted_at']=$now; $o['deleted_by']=$actor['id']; $o['updated_at']=$now; } unset($o);
|
||
|
|
fb_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=fb_normalize_object(is_array($command['object'] ?? null)?$command['object']:[], $kind, $actor, $config); fb_asset_owner($db,$object['assetId'],$actor['id']);
|
||
|
|
if ($type==='object.publish' && !fb_quota($db,$actor,$config)) fb_fail('publish_limit_reached',429);
|
||
|
|
$active=0; foreach($db['objects'] as $o) if (empty($o['deleted_at']) && ($o['moderation_status'] ?? 'active')==='active') $active++;
|
||
|
|
if ($type==='object.publish' && $active >= (int)$config['max_world_objects']) fb_fail('world_object_limit_reached',409);
|
||
|
|
$db['objects'][$object['id']]=['id'=>$object['id'],'kind'=>$kind,'asset_id'=>$object['assetId'],'owner_account_id'=>$actor['id'],'json'=>$object,'version'=>$object['version'],'published_at'=>(int)$object['publishedAt'],'updated_at'=>$now,'deleted_at'=>null,'deleted_by'=>null,'moderation_status'=>'active'];
|
||
|
|
fb_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=fb_clean_id($command['objectId'] ?? ''); $o=$db['objects'][$objectId] ?? null; if (!$o || ($o['kind'] ?? '')!==$kind || !empty($o['deleted_at'])) fb_fail('object_not_found',404); if (($o['owner_account_id'] ?? '')!==$actor['id']) fb_fail('object_owner_required',403);
|
||
|
|
$db['objects'][$objectId]['deleted_at']=$now; $db['objects'][$objectId]['deleted_by']=$actor['id']; $db['objects'][$objectId]['updated_at']=$now; fb_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=fb_clean_id($command[$targetType.'Id'] ?? $command['targetId'] ?? ''); $value=max(-1,min(1,(int)($command['value'] ?? $command['delta'] ?? 0))); if($targetId==='') fb_fail('vote_target_required'); $key=$targetType.'|'.$targetId.'|'.$actor['id']; if($value===0) unset($db['votes'][$key]); else $db['votes'][$key]=['target_type'=>$targetType,'target_id'=>$targetId,'voter_account_id'=>$actor['id'],'value'=>$value,'updated_at'=>$now]; fb_event($db,'vote.'.$targetType,['type'=>'vote.'.$targetType,'targetId'=>$targetId,'actorAccountId'=>$actor['id'],'value'=>$value]);
|
||
|
|
} elseif ($type==='report.object') {
|
||
|
|
$objectId=fb_clean_id($command['objectId'] ?? ''); $assetId=fb_clean_id($command['assetId'] ?? ''); $kind=(($command['objectKind'] ?? $command['kind'] ?? '')==='dynamic')?'dynamic':'static'; $reason=fb_clean_text($command['reason'] ?? 'other','other',48); if($objectId==='' || $assetId==='') fb_fail('report_target_required'); $reportId=fb_clean_id($command['reportId'] ?? $command['id'] ?? ('report_'.bin2hex(random_bytes(6)))); $db['reports'][$reportId]=['id'=>$reportId,'object_id'=>$objectId,'asset_id'=>$assetId,'object_kind'=>$kind,'reporter_account_id'=>$actor['id'],'reason'=>$reason,'created_at'=>$now,'status'=>'open']; fb_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['commands'][$cmdId]=['id'=>$cmdId,'actor_account_id'=>$actor['id'],'created_at'=>(int)($command['createdAt'] ?? $now),'applied_at'=>$now];
|
||
|
|
return ['ok'=>true,'id'=>$cmdId];
|
||
|
|
}
|
||
|
|
function fb_votes(array $db, string $targetType): array { $out=[]; foreach($db['votes'] as $v){ if(($v['target_type'] ?? '')!==$targetType || (int)($v['value'] ?? 0)===0) continue; $id=$v['target_id']; $val=(int)$v['value']; if(!isset($out[$id])) $out[$id]=['up'=>0,'down'=>0,'voters'=>[]]; if($val>0)$out[$id]['up']++; if($val<0)$out[$id]['down']++; $out[$id]['voters'][$v['voter_account_id']]=$val; } return $out; }
|
||
|
|
function fb_snapshot(array $db): array {
|
||
|
|
$assets=[]; foreach($db['assets'] as $a) if(empty($a['deleted_at'])) $assets[]=$a['json'];
|
||
|
|
usort($assets, fn($a,$b)=>(int)($b['updatedAt'] ?? 0) <=> (int)($a['updatedAt'] ?? 0));
|
||
|
|
$placed=[]; $dynamic=[]; foreach($db['objects'] as $o){ if(!empty($o['deleted_at']) || ($o['moderation_status'] ?? 'active')!=='active') continue; if(($o['kind'] ?? '')==='dynamic') $dynamic[]=$o['json']; else $placed[]=$o['json']; }
|
||
|
|
$assetT=[]; foreach($db['assets'] as $a) if(!empty($a['deleted_at'])) $assetT[$a['id']]=['id'=>$a['id'],'deletedAt'=>(int)$a['deleted_at'],'deletedBy'=>$a['deleted_by'] ?? '', 'version'=>(int)($a['version'] ?? 1)];
|
||
|
|
$objectT=[]; foreach($db['objects'] as $o) if(!empty($o['deleted_at'])) $objectT[$o['id']]=['id'=>$o['id'],'assetId'=>$o['asset_id'],'deletedAt'=>(int)$o['deleted_at'],'deletedBy'=>$o['deleted_by'] ?? '', 'version'=>(int)($o['version'] ?? 1)];
|
||
|
|
$reports=[]; foreach($db['reports'] as $r) if(($r['status'] ?? 'open')==='open') $reports[]=['id'=>$r['id'],'objectId'=>$r['object_id'],'assetId'=>$r['asset_id'],'objectKind'=>$r['object_kind'],'reporter'=>$r['reporter_account_id'],'reason'=>$r['reason'],'createdAt'=>(int)$r['created_at']];
|
||
|
|
$last=0; foreach($db['events'] as $e) $last=max($last,(int)($e['id'] ?? 0));
|
||
|
|
return ['ok'=>true,'schema'=>1,'fileDb'=>true,'serverNow'=>fb_now(),'lastEventId'=>$last,'authority'=>['publish'=>'server','objectMove'=>'server','dayNight'=>'local','dynamicMotion'=>'local'],'assets'=>array_values($assets),'placed'=>array_values($placed),'dynamicSummons'=>array_values($dynamic),'assetVotes'=>fb_votes($db,'asset'),'objectVotes'=>fb_votes($db,'object'),'moderationReports'=>$reports,'tombstones'=>['assets'=>$assetT,'objects'=>$objectT]];
|
||
|
|
}
|
||
|
|
|
||
|
|
$action = $_GET['action'] ?? '';
|
||
|
|
if ($action === 'health' || $action === '') fb_respond(['ok'=>true,'service'=>'pixel-island-api','serverNow'=>fb_now(),'sqlite'=>false,'fileDb'=>true,'note'=>'pdo_sqlite is unavailable; using locked JSON fallback']);
|
||
|
|
fb_with_db($config, function(array &$db) use ($action, $config) {
|
||
|
|
if ($action === 'snapshot') return fb_snapshot($db);
|
||
|
|
if ($action === 'account') { $data=fb_request_json((int)$config['max_json_bytes']); $account=fb_auth($db, is_array($data['account'] ?? null)?$data['account']:$data, true); return ['ok'=>true,'account'=>['id'=>$account['id'],'name'=>$account['name'],'createdAt'=>(int)$account['created_at']]]; }
|
||
|
|
if ($action === 'commands') { $data=fb_request_json((int)$config['max_json_bytes']); $actor=fb_auth($db, is_array($data['account'] ?? null)?$data['account']:[], true); $commands=is_array($data['commands'] ?? null)?$data['commands']:[]; if(count($commands)>100) fb_fail('too_many_commands',413); $applied=[]; $rejected=[]; foreach($commands as $command){ if(!is_array($command)) continue; try{ $result=fb_process($db,$command,$actor,$config); if($result['ok'] ?? false) $applied[]=$result['id']; else $rejected[]=$result; } catch(Throwable $e){ $rejected[]=['ok'=>false,'id'=>fb_clean_id($command['id'] ?? ''),'error'=>$e->getMessage()]; } } return ['ok'=>true,'appliedCommandIds'=>$applied,'rejectedCommands'=>$rejected,'snapshot'=>fb_snapshot($db)]; }
|
||
|
|
return ['ok'=>false,'error'=>'unknown_action'];
|
||
|
|
});
|