2026-06-04 02:03:59 +09:00
< ? 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 ;
}
2026-06-04 14:37:03 +09:00
function fb_fail ( string $message , int $status = 400 , array $extra = []) : void {
if ( ! empty ( $GLOBALS [ 'PIXEL_ISLAND_COMMAND_CONTEXT' ])) throw new RuntimeException ( $message , $status );
fb_respond ([ 'ok' => false , 'error' => $message ] + $extra , $status );
}
2026-06-04 02:03:59 +09:00
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 ];
}
2026-06-04 14:37:03 +09:00
function fb_seed_path ( array $config ) : string {
return dirname ( $config [ 'db_path' ]) . DIRECTORY_SEPARATOR . 'default_gallery_seed.json' ;
}
function fb_seed_defaults ( array & $db , array $config ) : void {
$path = fb_seed_path ( $config );
if ( ! is_file ( $path )) return ;
$seed = json_decode (( string ) file_get_contents ( $path ), true );
if ( ! is_array ( $seed )) return ;
$now = fb_now ();
if ( ! isset ( $db [ 'accounts' ][ 'island-team' ])) $db [ 'accounts' ][ 'island-team' ] = [ 'id' => 'island-team' , 'name' => 'Pixel Island' , 'password_hash' => password_hash ( bin2hex ( random_bytes ( 16 )), PASSWORD_DEFAULT ), 'created_at' => $now , 'updated_at' => $now , 'disabled_at' => null ];
foreach ( is_array ( $seed [ 'assets' ] ? ? null ) ? $seed [ 'assets' ] : [] as $asset ) {
if ( ! is_array ( $asset )) continue ;
$id = fb_clean_id ( $asset [ 'id' ] ? ? '' );
if ( $id === '' || isset ( $db [ 'assets' ][ $id ])) continue ;
$asset [ 'ownerAccountId' ] = 'island-team' ;
$asset [ 'author' ] = $asset [ 'author' ] ? ? 'Pixel Island' ;
$db [ 'assets' ][ $id ] = [ 'id' => $id , 'owner_account_id' => 'island-team' , 'author_name' => fb_clean_text ( $asset [ 'author' ] ? ? 'Pixel Island' , 'Pixel Island' , 32 ), 'json' => $asset , 'version' => max ( 1 ,( int )( $asset [ 'version' ] ? ? 1 )), 'created_at' => ( int )( $asset [ 'createdAt' ] ? ? $now ), 'updated_at' => ( int )( $asset [ 'updatedAt' ] ? ? $now ), 'deleted_at' => null , 'deleted_by' => null , 'content_hash' => $asset [ 'contentHash' ] ? ? null ];
}
$insertObject = function ( array $object , string $kind ) use ( & $db , $now ) : void {
$id = fb_clean_id ( $object [ 'id' ] ? ? '' );
$assetId = fb_clean_id ( $object [ 'assetId' ] ? ? '' );
if ( $id === '' || $assetId === '' || isset ( $db [ 'objects' ][ $id ]) || ! isset ( $db [ 'assets' ][ $assetId ]) || ! empty ( $db [ 'assets' ][ $assetId ][ 'deleted_at' ])) return ;
$object [ 'ownerAccountId' ] = 'island-team' ;
$object [ 'status' ] = 'active' ;
$object [ 'publishedAt' ] = ( int )( $object [ 'publishedAt' ] ? ? $now );
$db [ 'objects' ][ $id ] = [ 'id' => $id , 'kind' => $kind , 'asset_id' => $assetId , 'owner_account_id' => 'island-team' , 'json' => $object , 'version' => max ( 1 ,( int )( $object [ 'version' ] ? ? 1 )), 'published_at' => ( int ) $object [ 'publishedAt' ], 'updated_at' => $now , 'deleted_at' => null , 'deleted_by' => null , 'moderation_status' => 'active' ];
};
foreach ( is_array ( $seed [ 'placed' ] ? ? null ) ? $seed [ 'placed' ] : [] as $object ) if ( is_array ( $object )) $insertObject ( $object , 'static' );
foreach ( is_array ( $seed [ 'dynamicSummons' ] ? ? null ) ? $seed [ 'dynamicSummons' ] : [] as $object ) if ( is_array ( $object )) $insertObject ( $object , 'dynamic' );
}
2026-06-04 02:03:59 +09:00
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 ;
}
2026-06-04 14:37:03 +09:00
function fb_command_result ( array & $db , string $cmdId , string $actorId , int $createdAt , array $result ) : void {
$db [ 'commands' ][ $cmdId ] = [ 'id' => $cmdId , 'actor_account_id' => $actorId , 'created_at' => $createdAt , 'applied_at' => fb_now (), 'result' => $result ];
}
2026-06-04 02:03:59 +09:00
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' ];
2026-06-04 14:37:03 +09:00
if ( isset ( $db [ 'commands' ][ $cmdId ])) {
$result = is_array ( $db [ 'commands' ][ $cmdId ][ 'result' ] ? ? null ) ? $db [ 'commands' ][ $cmdId ][ 'result' ] : [ 'ok' => true , 'id' => $cmdId ];
$result [ 'duplicate' ] = true ;
return $result ;
}
2026-06-04 02:03:59 +09:00
$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' ]]);
2026-06-04 14:37:03 +09:00
} elseif ( $type === 'publish.asset_object' ) {
$asset = fb_normalize_asset ( is_array ( $command [ 'asset' ] ? ? null ) ? $command [ 'asset' ] : [], $actor );
$kind = (( $command [ 'kind' ] ? ? $asset [ 'category' ] ? ? '' ) === 'dynamic' ) ? 'dynamic' : 'static' ;
$object = fb_normalize_object ( is_array ( $command [ 'object' ] ? ? null ) ? $command [ 'object' ] : [], $kind , $actor , $config );
if (( $object [ 'assetId' ] ? ? '' ) !== ( $asset [ 'id' ] ? ? '' )) fb_fail ( 'publish_asset_mismatch' , 400 );
if ( isset ( $db [ 'assets' ][ $asset [ 'id' ]]) && empty ( $db [ 'assets' ][ $asset [ 'id' ]][ 'deleted_at' ]) && ( $db [ 'assets' ][ $asset [ 'id' ]][ 'owner_account_id' ] ? ? '' ) !== $actor [ 'id' ]) fb_fail ( 'asset_id_already_owned' , 403 );
if ( ! 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 ( $active >= ( int ) $config [ 'max_world_objects' ]) fb_fail ( 'world_object_limit_reached' , 409 );
$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 ];
$object [ 'publishedAt' ] = $now ;
if ( $kind === 'dynamic' ) $object [ 'createdAt' ] = $now ; else $object [ 'placedAt' ] = $now ;
$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' => $now , 'updated_at' => $now , 'deleted_at' => null , 'deleted_by' => null , 'moderation_status' => 'active' ];
fb_event ( $db , 'asset.upsert' ,[ 'type' => 'asset.upsert' , 'assetId' => $asset [ 'id' ], 'actorAccountId' => $actor [ 'id' ]]);
fb_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 ];
fb_command_result ( $db , $cmdId , $actor [ 'id' ],( int )( $command [ 'createdAt' ] ? ? $now ), $result );
return $result ;
2026-06-04 02:03:59 +09:00
} 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 );
2026-06-04 14:37:03 +09:00
if ( $type === 'object.publish' ) $object [ 'publishedAt' ] = $now ;
2026-06-04 02:03:59 +09:00
$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' ];
2026-06-04 14:37:03 +09:00
$result = [ 'ok' => true , 'id' => $cmdId , 'type' => $type ];
fb_command_result ( $db , $cmdId , $actor [ 'id' ],( int )( $command [ 'createdAt' ] ? ? $now ), $result );
return $result ;
2026-06-04 02:03:59 +09:00
}
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 ) {
2026-06-04 14:37:03 +09:00
fb_seed_defaults ( $db , $config );
2026-06-04 02:03:59 +09:00
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' ]]]; }
2026-06-04 14:37:03 +09:00
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 { $GLOBALS [ 'PIXEL_ISLAND_COMMAND_CONTEXT' ] = true ; $result = fb_process ( $db , $command , $actor , $config ); $GLOBALS [ 'PIXEL_ISLAND_COMMAND_CONTEXT' ] = false ; if ( $result [ 'ok' ] ? ? false ) $applied [] = $result [ 'id' ]; else $rejected [] = $result ; } catch ( Throwable $e ){ $GLOBALS [ 'PIXEL_ISLAND_COMMAND_CONTEXT' ] = false ; $id = fb_clean_id ( $command [ 'id' ] ? ? '' ); $result = [ 'ok' => false , 'id' => $id , 'type' => ( string )( $command [ 'type' ] ? ? '' ), 'error' => $e -> getMessage ()]; if ( $id !== '' ) fb_command_result ( $db , $id , $actor [ 'id' ],( int )( $command [ 'createdAt' ] ? ? fb_now ()), $result ); $rejected [] = $result ; } } return [ 'ok' => true , 'appliedCommandIds' => $applied , 'rejectedCommands' => $rejected , 'snapshot' => fb_snapshot ( $db )]; }
2026-06-04 02:03:59 +09:00
return [ 'ok' => false , 'error' => 'unknown_action' ];
});