2026-06-04 02:03:59 +09:00
< ? php
declare ( strict_types = 1 );
$config = require dirname ( __DIR__ ) . '/api/config.php' ;
$token = ( string )( $config [ 'admin_token' ] ? ? '' );
$given = ( string )( $_GET [ 'token' ] ? ? $_POST [ 'token' ] ? ? '' );
2026-06-04 15:08:25 +09:00
$ownerFilter = clean_id ( $_GET [ 'owner' ] ? ? $_POST [ 'owner' ] ? ? '' );
2026-06-04 02:03:59 +09:00
header ( 'X-Content-Type-Options: nosniff' );
function h ( $s ) : string { return htmlspecialchars (( string ) $s , ENT_QUOTES | ENT_SUBSTITUTE , 'UTF-8' ); }
2026-06-04 15:08:25 +09:00
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 );
}
2026-06-04 02:03:59 +09:00
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 {
2026-06-04 15:08:25 +09:00
$stmt = $db -> query ( " SELECT name FROM sqlite_master WHERE type='table' AND name IN ('accounts','assets','objects','reports') " );
2026-06-04 02:03:59 +09:00
$names = $stmt ? array_column ( $stmt -> fetchAll ( PDO :: FETCH_ASSOC ), 'name' ) : [];
2026-06-04 15:08:25 +09:00
return in_array ( 'accounts' , $names , true ) && in_array ( 'assets' , $names , true ) && in_array ( 'objects' , $names , true ) && in_array ( 'reports' , $names , true );
2026-06-04 02:03:59 +09:00
}
function choose_store ( array $config ) : array {
$sqlitePath = ( string ) $config [ 'db_path' ];
if ( sqlite_available () && is_file ( $sqlitePath )) return [ 'type' => 'sqlite' , 'path' => $sqlitePath ];
$jsonPath = filedb_path ( $config );
if ( is_file ( $jsonPath )) return [ 'type' => 'filedb' , 'path' => $jsonPath ];
if ( sqlite_available ()) return [ 'type' => 'sqlite_missing' , 'path' => $sqlitePath ];
return [ 'type' => 'filedb_missing' , 'path' => $jsonPath ];
}
2026-06-04 15:08:25 +09:00
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 );
}
2026-06-04 16:40:45 +09:00
$codes = str_split ( '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ' . " !# $ %&'()*+,-/:;<=>?@[]^_` { |}~ " );
$colors = [
'#111827' , '#374151' , '#6b7280' , '#d1d5db' , '#fff7ed' ,
'#7f1d1d' , '#dc2626' , '#f97316' , '#facc15' , '#84cc16' ,
'#16a34a' , '#14b8a6' , '#06b6d4' , '#2563eb' , '#4f46e5' ,
'#7c3aed' , '#c026d3' , '#ec4899' , '#f5d0a9' , '#7c4a2d' ,
'#fafafa' , '#f5f5f4' , '#e7e5e4' , '#d6d3d1' , '#a8a29e' , '#78716c' , '#57534e' , '#292524' , '#0c0a09' ,
'#fef3c7' , '#fde68a' , '#d6a25f' , '#9a6a3a' , '#5c3b24' ,
'#fee2e2' , '#fca5a5' , '#ef4444' , '#b91c1c' , '#7f1d1d' ,
'#ffedd5' , '#fdba74' , '#f97316' , '#c2410c' , '#7c2d12' ,
'#fef9c3' , '#fde047' , '#eab308' , '#a16207' , '#713f12' ,
'#ecfccb' , '#bef264' , '#84cc16' , '#4d7c0f' , '#365314' , '#dcfce7' , '#86efac' , '#15803d' ,
'#ccfbf1' , '#5eead4' , '#14b8a6' , '#0f766e' , '#134e4a' ,
'#cffafe' , '#67e8f9' , '#22d3ee' , '#06b6d4' , '#0e7490' , '#e0f2fe' , '#38bdf8' ,
'#dbeafe' , '#93c5fd' , '#3b82f6' , '#2563eb' , '#1d4ed8' , '#1e3a8a' ,
'#e0e7ff' , '#a5b4fc' , '#6366f1' , '#4f46e5' , '#3730a3' , '#ede9fe' , '#c4b5fd' , '#8b5cf6' ,
'#fae8ff' , '#e879f9' , '#c026d3' , '#86198f' , '#fce7f3' , '#f9a8d4' , '#ec4899' , '#be185d'
2026-06-04 15:08:25 +09:00
];
2026-06-04 16:40:45 +09:00
$palette = array_combine ( $codes , array_slice ( $colors , 0 , count ( $codes ))) ? : [];
2026-06-04 15:08:25 +09:00
$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 );
2026-06-04 16:40:45 +09:00
$cells .= '<i style="left:' . h (( $x / $width ) * 100 ) . '%;top:' . h (( $y / $height ) * 100 ) . '%;width:' . h ( 100 / $width ) . '%;height:' . h ( 100 / $height ) . '%;background:' . h ( $color ) . '"></i>' ;
2026-06-04 15:08:25 +09:00
}
}
return '<div class="thumb" style="--w:' . h ( $width ) . ';--h:' . h ( $height ) . '">' . $cells . '</div>' ;
}
2026-06-04 02:03:59 +09:00
function read_filedb ( string $path ) : array {
if ( ! is_file ( $path )) return [];
$raw = file_get_contents ( $path );
$db = $raw ? json_decode ( $raw , true ) : null ;
return is_array ( $db ) ? $db : [];
}
function write_filedb ( string $path , callable $mutator ) : void {
$dir = dirname ( $path );
if ( ! is_dir ( $dir ) && ! mkdir ( $dir , 0775 , true ) && ! is_dir ( $dir )) throw new RuntimeException ( 'data directory unavailable' );
$fp = fopen ( $path , 'c+' );
if ( ! $fp ) throw new RuntimeException ( 'file db unavailable' );
try {
if ( ! flock ( $fp , LOCK_EX )) throw new RuntimeException ( 'file db lock failed' );
$raw = stream_get_contents ( $fp );
$db = $raw ? json_decode ( $raw , true ) : null ;
if ( ! is_array ( $db )) $db = [ 'schema' => 1 , 'accounts' => [], 'assets' => [], 'objects' => [], 'votes' => [], 'reports' => [], 'events' => [], 'commands' => [], 'nextEventId' => 1 ];
$mutator ( $db );
ftruncate ( $fp , 0 );
rewind ( $fp );
2026-06-04 15:08:25 +09:00
fwrite ( $fp , encode_json_value ( $db ));
2026-06-04 02:03:59 +09:00
fflush ( $fp );
flock ( $fp , LOCK_UN );
} finally {
fclose ( $fp );
}
}
2026-06-04 15:08:25 +09:00
function sqlite_db ( string $path ) : PDO {
2026-06-04 02:03:59 +09:00
$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' );
2026-06-04 15:08:25 +09:00
return $db ;
}
2026-06-04 16:40:45 +09:00
function ensure_sqlite_anonymous_account ( PDO $db ) : void {
$now = now_ms ();
$hash = password_hash ( bin2hex ( random_bytes ( 16 )), PASSWORD_DEFAULT );
$db -> prepare ( 'INSERT OR IGNORE INTO accounts(id, name, password_hash, created_at, updated_at) VALUES(?,?,?,?,?)' )
-> execute ([ 'anonymous' , 'Anonymous' , $hash , $now , $now ]);
}
2026-06-04 15:08:25 +09:00
function list_sqlite_accounts ( string $path ) : array {
$db = sqlite_db ( $path );
if ( ! sqlite_tables_ready ( $db )) return [];
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 );
2026-06-04 02:03:59 +09:00
if ( ! sqlite_tables_ready ( $db )) return [];
2026-06-04 15:08:25 +09:00
$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' ] ? ? '' );
2026-06-04 02:03:59 +09:00
return [
2026-06-04 15:08:25 +09:00
'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 ),
2026-06-04 02:03:59 +09:00
];
2026-06-04 15:08:25 +09:00
}, $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 ();
2026-06-04 16:40:45 +09:00
ensure_sqlite_anonymous_account ( $db );
$assetRows = $db -> prepare ( 'SELECT id, json FROM assets WHERE owner_account_id = ?' );
$assetRows -> execute ([ $accountId ]);
foreach ( $assetRows -> fetchAll () ? : [] as $row ) {
$asset = decode_json_value ( $row [ 'json' ] ? ? '' );
$asset [ 'ownerAccountId' ] = 'anonymous' ;
$asset [ 'author' ] = 'Anonymous' ;
$asset [ 'updatedAt' ] = $now ;
$db -> prepare ( 'UPDATE assets SET owner_account_id = ?, author_name = ?, json = ?, updated_at = ? WHERE id = ?' )
-> execute ([ 'anonymous' , 'Anonymous' , encode_json_value ( $asset ), $now , $row [ 'id' ]]);
}
$objectRows = $db -> prepare ( 'SELECT id, json FROM objects WHERE owner_account_id = ?' );
$objectRows -> execute ([ $accountId ]);
foreach ( $objectRows -> fetchAll () ? : [] as $row ) {
$object = decode_json_value ( $row [ 'json' ] ? ? '' );
$object [ 'ownerAccountId' ] = 'anonymous' ;
$db -> prepare ( 'UPDATE objects SET owner_account_id = ?, json = ?, updated_at = ? WHERE id = ?' )
-> execute ([ 'anonymous' , encode_json_value ( $object ), $now , $row [ 'id' ]]);
}
2026-06-04 15:08:25 +09:00
$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 );
2026-06-04 02:03:59 +09:00
}
function list_filedb_reports ( string $path ) : array {
$db = read_filedb ( $path );
$out = [];
2026-06-04 15:08:25 +09:00
foreach (( $db [ 'reports' ] ? ? []) as $r ) {
2026-06-04 02:03:59 +09:00
if (( $r [ 'status' ] ? ? 'open' ) !== 'open' ) continue ;
2026-06-04 15:08:25 +09:00
$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' ] ? ? []];
2026-06-04 02:03:59 +09:00
}
usort ( $out , fn ( $a , $b ) => (( int ) $b [ 'created_at' ]) <=> (( int ) $a [ 'created_at' ]));
return array_slice ( $out , 0 , 200 );
}
2026-06-04 15:08:25 +09:00
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 ();
2026-06-04 16:40:45 +09:00
$ensureAnonymous = function () use ( & $db , $now ) : void {
$db [ 'accounts' ] = is_array ( $db [ 'accounts' ] ? ? null ) ? $db [ 'accounts' ] : [];
if ( ! isset ( $db [ 'accounts' ][ 'anonymous' ])) {
$db [ 'accounts' ][ 'anonymous' ] = [ 'id' => 'anonymous' , 'name' => 'Anonymous' , 'password_hash' => password_hash ( bin2hex ( random_bytes ( 16 )), PASSWORD_DEFAULT ), 'created_at' => $now , 'updated_at' => $now , 'disabled_at' => null ];
}
};
2026-06-04 15:08:25 +09:00
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 ;
2026-06-04 02:03:59 +09:00
}
2026-06-04 15:08:25 +09:00
} elseif ( $action === 'delete_account' ) {
$accountId = clean_id ( $post [ 'account_id' ] ? ? '' );
if ( $accountId === '' || $accountId === 'island-team' ) return ;
2026-06-04 16:40:45 +09:00
$ensureAnonymous ();
foreach ( $db [ 'assets' ] as & $asset ) if (( $asset [ 'owner_account_id' ] ? ? '' ) === $accountId ) {
$asset [ 'owner_account_id' ] = 'anonymous' ;
$asset [ 'author_name' ] = 'Anonymous' ;
$asset [ 'updated_at' ] = $now ;
$json = decode_json_value ( $asset [ 'json' ] ? ? []);
$json [ 'ownerAccountId' ] = 'anonymous' ;
$json [ 'author' ] = 'Anonymous' ;
$json [ 'updatedAt' ] = $now ;
$asset [ 'json' ] = $json ;
}
2026-06-04 15:08:25 +09:00
unset ( $asset );
2026-06-04 16:40:45 +09:00
foreach ( $db [ 'objects' ] as & $object ) if (( $object [ 'owner_account_id' ] ? ? '' ) === $accountId ) {
$object [ 'owner_account_id' ] = 'anonymous' ;
$object [ 'updated_at' ] = $now ;
$json = decode_json_value ( $object [ 'json' ] ? ? []);
$json [ 'ownerAccountId' ] = 'anonymous' ;
$object [ 'json' ] = $json ;
}
2026-06-04 15:08:25 +09:00
unset ( $object );
foreach ( $db [ 'reports' ] as & $report ) if (( $report [ 'reporter_account_id' ] ? ? '' ) === $accountId ) $report [ 'status' ] = 'closed' ;
2026-06-04 02:03:59 +09:00
unset ( $report );
2026-06-04 15:08:25 +09:00
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 );
}
2026-06-04 02:03:59 +09:00
}
});
}
if ( $token === '' ) {
http_response_code ( 403 );
2026-06-04 15:08:25 +09:00
echo '<!doctype html><meta charset="utf-8"><title>Admin disabled</title><h1>Admin disabled</h1>' ;
2026-06-04 02:03:59 +09:00
exit ;
}
if ( ! hash_equals ( $token , $given )) {
http_response_code ( 403 );
echo '<!doctype html><meta charset="utf-8"><title>Forbidden</title><h1>Forbidden</h1><p>Invalid admin token.</p>' ;
exit ;
}
$store = choose_store ( $config );
$error = '' ;
2026-06-04 15:08:25 +09:00
$accounts = [];
$assets = [];
$reports = [];
2026-06-04 02:03:59 +09:00
try {
if ( $_SERVER [ 'REQUEST_METHOD' ] === 'POST' ) {
2026-06-04 15:08:25 +09:00
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' ]);
2026-06-04 02:03:59 +09:00
}
} catch ( Throwable $e ) {
$error = $e -> getMessage ();
}
$storeLabel = $store [ 'type' ] === 'sqlite' ? 'SQLite' : ( $store [ 'type' ] === 'filedb' ? 'JSON file DB' : 'DB not initialized yet' );
2026-06-04 15:08:25 +09:00
$baseUrl = './?token=' . rawurlencode ( $given );
2026-06-04 02:03:59 +09:00
?>
<! doctype html >
< meta charset = " utf-8 " >
< meta name = " viewport " content = " width=device-width,initial-scale=1 " >
< title > Pixel Island Admin </ title >
2026-06-04 16:40:45 +09:00
< script >
document . addEventListener ( 'submit' , () => {
try { sessionStorage . setItem ( 'pixel-admin-scroll' , String ( window . scrollY || 0 )); } catch ( error ) {}
});
window . addEventListener ( 'load' , () => {
try {
const y = Number ( sessionStorage . getItem ( 'pixel-admin-scroll' ) || 0 );
sessionStorage . removeItem ( 'pixel-admin-scroll' );
if ( y > 0 ) requestAnimationFrame (() => scrollTo ( 0 , y ));
} catch ( error ) {}
});
</ script >
2026-06-04 02:03:59 +09:00
< style >
2026-06-04 16:40:45 +09:00
body { font - family : system - ui , sans - serif ; margin : 24 px ; background : #f7f4ea;color:#2b2b2b}h1{margin-bottom:4px}.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(380px,1fr));gap:12px}.card{background:#fff;border:1px solid #ddd;border-radius:10px;padding:14px;margin:10px 0}.media{display:flex;gap:14px;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:128px;height:128px;background:#f2eadb;border:1px solid #ddd;image-rendering:pixelated;overflow:hidden}.thumb i{position:absolute;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}@media(max-width:520px){.grid{grid-template-columns:1fr}.media{flex-direction:column}.thumb{width:112px;height:112px}}
2026-06-04 02:03:59 +09:00
</ style >
< h1 > Pixel Island Admin </ h1 >
2026-06-04 15:08:25 +09:00
< 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>
2026-06-04 02:03:59 +09:00
< ? 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 ; ?>
2026-06-04 15:08:25 +09:00
< h2 id = " accounts " > Users </ h2 >
< div class = " grid " >
< ? php foreach ( $accounts as $a ) : ?>
2026-06-04 02:03:59 +09:00
< div class = " card " >
2026-06-04 15:08:25 +09:00
< 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 >
2026-06-04 02:03:59 +09:00
< form method = " post " >
< input type = " hidden " name = " token " value = " <?=h( $given )?> " >
2026-06-04 15:08:25 +09:00
< 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; ?>
2026-06-04 16:40:45 +09:00
< ? php if (( $a [ 'id' ] ? ? '' ) !== 'island-team' && ( $a [ 'id' ] ? ? '' ) !== 'anonymous' ) : ?> <button class="danger" name="admin_action" value="delete_account" onclick="return confirm('Delete this user? Their pictures become Anonymous.')">Delete</button><?php endif; ?>
2026-06-04 02:03:59 +09:00
</ form >
</ div >
< ? php endforeach ; ?>
2026-06-04 15:08:25 +09:00
</ 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 ; ?>
2026-06-04 02:03:59 +09:00
< ? php if ( ! $reports ) : ?> <p>No open reports.</p><?php endif; ?>