This commit is contained in:
33333-33333 2026-06-04 14:37:03 +09:00
commit 80aae72feb
10 changed files with 830 additions and 5041 deletions

View file

@ -1,109 +0,0 @@
# Pixel Island shared test build
この ZIP は `public_html` 直下に置く試験公開用です。HTTPS 前提で、同一フォルダ配下に DB を作成します。
## 置き方
`public_html` 直下が次の構成になるようにアップロードしてください。
```text
public_html/
index.html
app.js
styles.css
js/
api/
index.php
config.php
filedb.php
admin/
index.php
_data/
.htaccess
web.config
README.txt
```
## DB の場所
標準は SQLite です。
```text
public_html/_data/pixel_island.sqlite
```
サーバーの PHP に `pdo_sqlite` が無い場合は、自動でロック付き JSON ファイル DB に切り替わります。
```text
public_html/_data/pixel_island_filedb.json
```
`_data` は Web から直接読まれないよう、Apache 用 `.htaccess` と IIS 用 `web.config` を同梱しています。サーバー設定によって効かない場合があるため、公開後に `https://あなたのURL/_data/``https://あなたのURL/_data/pixel_island_filedb.json` が直接見えないことを確認してください。
## 必要なサーバー機能
| 機能 | 必須度 | 用途 |
|---|---:|---|
| HTTPS | 必須 | アカウントID・パスワード送信保護 |
| PHP 8.x | 必須 | API 実行 |
| 書き込み権限 | 必須 | `_data` に DB を作成・更新 |
| `pdo_sqlite` | 推奨 | SQLite DB 使用 |
| `.htaccess` または `web.config` の deny | 推奨 | `_data` 直アクセス防止 |
## 動作確認 URL
```text
https://あなたのURL/api/index.php?action=health
```
正常例:
```json
{"ok":true,"service":"pixel-island-api","sqlite":true}
```
`sqlite:false, fileDb:true` の場合、SQLite が使えず JSON ファイル DB で動いています。少人数テストなら可、本公開では SQLite 以上を推奨します。
## 今回実装した段階
### Stage 1: 共有バックエンド
- `api/index.php` を追加
- `_data` 配下に DB を作成
- 作品・配置・投票・通報をサーバー保存
- フロントエンドは起動時と約5秒ごとに共有データを同期
- API が使えない場合はローカル専用にフォールバック
### Stage 2: アカウント・所有者検証
- ブラウザ側のローカルアカウントをサーバー登録
- パスワードはサーバー側でハッシュ保存
- 作品作成・削除・配置・移動は所有者のみ許可
- 1時間あたりの投稿数をサーバー側で制限
### Stage 3: 通報・管理画面
- 通報を DB に保存
- `admin/index.php` を追加
- SQLite / JSON fallback の両方で管理画面が動作
- 管理画面からオブジェクトを非表示 / 復元 / 通報クローズ可能
管理画面を使う場合は `api/config.php``admin_token` に長いランダム文字列を設定し、次の形式で開きます。
```text
https://あなたのURL/admin/?token=設定したトークン
```
## 未実装・今後の段階
| 段階 | 内容 |
|---|---|
| Stage 4 | 管理者ログイン、管理者ロール、CSRF 対策強化、操作監査ログ |
| Stage 5 | 画像/投稿のより厳密なサーバー側バリデーション |
| Stage 6 | 差分同期、ページング、重い島での高速化 |
| Stage 7 | 自動モデレーション、NGワード、IP/端末単位レート制限 |
| Stage 8 | バックアップ、エクスポート、DB復旧手順 |
## 注意
このビルドは「試験公開」用です。本公開する場合は少なくとも SQLite が使える状態にし、`_data` の直アクセス不可確認、定期バックアップ、管理トークン設定を行ってください。SQLite が無い環境でも JSON fallback で少人数テストは可能ですが、同時アクセスやデータ破損リスクを考えると長期運用には向きません。

View file

@ -1,4 +1,4 @@
# Pixel Island Summoner
# Pixel Island
Browser-only prototype for a shared pixel-art island.

File diff suppressed because one or more lines are too long

View file

@ -2,7 +2,7 @@
// Pixel Island shared backend configuration.
// Place this public_html folder as-is. SQLite DB is created under ../_data/ on first API access.
return [
'db_path' => dirname(__DIR__) . DIRECTORY_SEPARATOR . '_data' . DIRECTORY_SEPARATOR . 'pixel_island.sqlite',
'db_path' => getenv('PIXEL_ISLAND_DB_PATH') ?: dirname(__DIR__) . DIRECTORY_SEPARATOR . '_data' . DIRECTORY_SEPARATOR . 'pixel_island.sqlite',
'world_width' => 144,
'world_height' => 112,
'max_assets_per_account' => 220,

View file

@ -8,7 +8,10 @@ function fb_respond(array $payload, int $status = 200): void {
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_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);
}
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 ?? ''));
@ -33,6 +36,37 @@ function fb_request_json(int $maxBytes): array {
function fb_default_db(): array {
return ['schema'=>1,'accounts'=>[],'assets'=>[],'objects'=>[],'votes'=>[],'reports'=>[],'events'=>[],'commands'=>[],'nextEventId'=>1];
}
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');
}
function fb_db_path(array $config): string {
return dirname($config['db_path']) . DIRECTORY_SEPARATOR . 'pixel_island_filedb.json';
}
@ -121,10 +155,17 @@ function fb_quota(array $db, array $actor, array $config): bool {
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_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];
}
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];
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;
}
$now=fb_now();
if ($type==='asset.create') {
$asset=fb_normalize_asset(is_array($command['asset'] ?? null)?$command['asset']:[], $actor);
@ -136,11 +177,30 @@ function fb_process(array &$db, array $command, array $actor, array $config): ar
$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==='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;
} 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);
if ($type==='object.publish') $object['publishedAt']=$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'=>(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') {
@ -151,8 +211,9 @@ function fb_process(array &$db, array $command, array $actor, array $config): ar
} 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];
$result=['ok'=>true,'id'=>$cmdId,'type'=>$type];
fb_command_result($db,$cmdId,$actor['id'],(int)($command['createdAt'] ?? $now),$result);
return $result;
}
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 {
@ -169,8 +230,9 @@ function fb_snapshot(array $db): array {
$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) {
fb_seed_defaults($db, $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)]; }
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)]; }
return ['ok'=>false,'error'=>'unknown_action'];
});

View file

@ -19,6 +19,9 @@ function respond(array $payload, int $status = 200): void {
}
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);
}
@ -134,9 +137,13 @@ 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
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 {
@ -178,6 +185,69 @@ function decode_json_row(?string $json): array {
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');
@ -237,6 +307,12 @@ function ensure_asset_owner(PDO $db, string $assetId, string $actorId): array {
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);
@ -252,9 +328,15 @@ function process_command(PDO $db, array $command, array $actor, array $config):
$type = (string) ($command['type'] ?? '');
if ($cmdId === '' || $type === '') return ['ok' => false, 'id' => $cmdId, 'error' => 'invalid_command'];
$stmt = $db->prepare('SELECT id FROM commands WHERE id = ?');
$stmt = $db->prepare('SELECT result_json FROM commands WHERE id = ?');
$stmt->execute([$cmdId]);
if ($stmt->fetch()) return ['ok' => true, 'id' => $cmdId, 'duplicate' => true];
$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') {
@ -277,6 +359,39 @@ function process_command(PDO $db, array $command, array $actor, array $config):
$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);
@ -284,6 +399,7 @@ function process_command(PDO $db, array $command, array $actor, array $config):
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)
@ -329,9 +445,9 @@ function process_command(PDO $db, array $command, array $actor, array $config):
return ['ok' => false, 'id' => $cmdId, 'error' => 'unsupported_command'];
}
$db->prepare('INSERT INTO commands(id, actor_account_id, created_at, applied_at) VALUES(?,?,?,?)')
->execute([$cmdId, $actor['id'], (int) ($command['createdAt'] ?? $now), $now]);
return ['ok' => true, 'id' => $cmdId];
$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 {
@ -350,6 +466,7 @@ function vote_snapshot(PDO $db, string $targetType): array {
}
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']);
@ -433,7 +550,9 @@ try {
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'];
@ -442,8 +561,15 @@ try {
$rejected[] = $result;
}
} catch (Throwable $e) {
$GLOBALS['PIXEL_ISLAND_COMMAND_CONTEXT'] = false;
if ($database->inTransaction()) $database->rollBack();
$rejected[] = ['ok' => false, 'id' => clean_id($command['id'] ?? ''), 'error' => $e->getMessage()];
$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)]);

5323
app.js

File diff suppressed because it is too large Load diff

View file

@ -3,38 +3,23 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Pixel Island Summoner - Shared Test</title>
<title>Pixel Island</title>
<link rel="stylesheet" href="./styles.css" />
</head>
<body>
<div class="app">
<canvas id="worldCanvas" aria-label="Island map"></canvas>
<a class="homeLink" href="https://host.nishi.boats/~333/" aria-label="Open 333 home" title="Open 333 home">
<span class="homeLinkIcon" aria-hidden="true"></span>
<span class="homeLinkText">333</span>
</a>
<button id="openEditor" class="drawDockButton" type="button" title="Open Pixel Studio"><span>✎ DRAW</span><span id="drawQuotaBadge" class="quotaBadge drawQuotaBadge" aria-live="polite"></span></button>
<div id="placementPreviewBar" class="placementPreviewBar" hidden>
<button id="confirmPreviewPlace" class="primary" type="button">Place here</button>
<button id="backToCanvas" class="secondary" type="button">Back to canvas</button>
<span>Check how this work looks on the island. Works are permanent in Collection; island placement is temporary exhibition.</span>
</div>
<button id="placeHereButton" class="placeHereButton" type="button" hidden>PLACE HERE</button>
<header class="hud topHud islandTopBar">
<div class="brandBlock">
<div class="logo">Pixel Island</div>
<div class="subline">A shared rotating island exhibition for tiny pixel works.</div>
</div>
<div class="authorCard accountCard utilityControl" aria-label="Local account">
<div class="accountFields">
<label class="accountField">ID
<input id="accountId" type="text" readonly placeholder="auto" />
</label>
<label class="accountField">Name
<input id="authorName" type="text" maxlength="24" value="Local Artist" />
</label>
<label class="accountField">Pass
<input id="accountPass" type="text" maxlength="32" placeholder="auto" />
</label>
</div>
<button id="createAccount" class="miniButton" type="button">Generate account</button>
<small id="accountNote">Publish creates an account for shared island publishing.</small>
<div class="subline">A rotating island exhibition for tiny pixel works.</div>
</div>
</header>
@ -70,8 +55,8 @@
<span class="dimensionSeparator" aria-hidden="true">×</span>
<input id="assetWidth" class="metaInput metaDimension" type="number" min="1" max="64" step="1" value="8" placeholder="W" aria-label="Width" />
<select id="assetCategory" class="metaInput metaRole" aria-label="Role" required>
<option value="" disabled selected>Role</option>
<option value="human">Human</option>
<option value="" disabled>Role</option>
<option value="human" selected>Human</option>
<option value="animal">Animal</option>
<option value="bird">Bird</option>
<option value="fish">Fish</option>
@ -177,8 +162,27 @@
</section>
<section id="tab-settings" class="tabPanel">
<div class="card stack accountSettingsCard">
<div class="cardTitle">Account</div>
<p class="hint">Local publishing identity for saving and placing your works.</p>
<div class="authorCard accountCard utilityControl" aria-label="Local account">
<div class="accountFields">
<label class="accountField">ID
<input id="accountId" type="text" readonly placeholder="auto" />
</label>
<label class="accountField">Name
<input id="authorName" type="text" maxlength="24" value="Local Artist" />
</label>
<label class="accountField">Pass
<input id="accountPass" type="text" maxlength="32" placeholder="auto" />
</label>
</div>
<button id="createAccount" class="miniButton" type="button">Generate account</button>
<small id="accountNote">Publish creates a local account for island publishing.</small>
</div>
</div>
<div class="card stack">
<div class="cardTitle">Settings</div>
<div class="cardTitle">Display</div>
<p class="hint">Turn major visual systems on or off. Server decides the public 250 exhibition slots; this local cap only filters your view.</p>
<div class="toggleList">
<label class="checkRow"><input id="settingLights" type="checkbox" checked /> <span>Lights & glow</span></label>

132
server/test_publish_api.py Normal file
View file

@ -0,0 +1,132 @@
import json
import os
import shutil
import socket
import subprocess
import tempfile
import time
import unittest
import urllib.error
import urllib.request
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
def free_port():
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.bind(("127.0.0.1", 0))
return sock.getsockname()[1]
def asset(asset_id, owner="acct-a"):
return {
"id": asset_id,
"name": "Test Block",
"category": "static",
"subtype": "other",
"width": 1,
"height": 1,
"size": 1,
"pixels": "a",
"ownerAccountId": owner,
}
def publish_command(command_id, asset_id="asset-a", object_id="object-a"):
return {
"id": command_id,
"type": "publish.asset_object",
"kind": "static",
"asset": asset(asset_id),
"object": {
"id": object_id,
"assetId": asset_id,
"x": 3,
"y": 4,
"publishedAt": 1,
"version": 1,
},
}
@unittest.skipIf(shutil.which("php") is None, "PHP CLI is not installed")
class PublishApiTest(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.port = free_port()
env = os.environ.copy()
env["PIXEL_ISLAND_DB_PATH"] = str(Path(self.temp.name) / "pixel.sqlite")
self.proc = subprocess.Popen(
["php", "-S", f"127.0.0.1:{self.port}", "-t", str(ROOT)],
cwd=str(ROOT),
env=env,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
self.base = f"http://127.0.0.1:{self.port}/api/index.php"
deadline = time.time() + 5
while time.time() < deadline:
try:
self.get("health")
return
except Exception:
time.sleep(0.05)
self.fail("PHP test server did not start")
def tearDown(self):
self.proc.terminate()
self.proc.wait(timeout=5)
self.temp.cleanup()
def get(self, action):
with urllib.request.urlopen(f"{self.base}?action={action}", timeout=5) as response:
return json.loads(response.read().decode("utf-8"))
def post_commands(self, commands, account_id="acct-a", password="pw"):
payload = {
"account": {"id": account_id, "name": account_id, "password": password},
"commands": commands,
}
request = urllib.request.Request(
f"{self.base}?action=commands",
data=json.dumps(payload).encode("utf-8"),
headers={"Content-Type": "application/json"},
method="POST",
)
with urllib.request.urlopen(request, timeout=5) as response:
return json.loads(response.read().decode("utf-8"))
def test_publish_asset_object_is_atomic_and_server_timestamped(self):
data = self.post_commands([publish_command("cmd-one")])
self.assertEqual(data["appliedCommandIds"], ["cmd-one"])
self.assertEqual(data["rejectedCommands"], [])
placed = data["snapshot"]["placed"]
self.assertEqual(len(placed), 1)
self.assertEqual(placed[0]["assetId"], "asset-a")
self.assertNotEqual(placed[0]["publishedAt"], 1)
self.assertEqual(data["snapshot"]["assets"][0]["ownerAccountId"], "acct-a")
def test_duplicate_publish_command_replays_without_new_object(self):
self.post_commands([publish_command("cmd-dupe")])
data = self.post_commands([publish_command("cmd-dupe")])
self.assertEqual(data["appliedCommandIds"], ["cmd-dupe"])
self.assertEqual(len(data["snapshot"]["placed"]), 1)
def test_owner_rejection_is_reported_in_batch(self):
self.post_commands([publish_command("cmd-owner", "shared-asset", "owner-object")])
stolen = publish_command("cmd-stolen", "shared-asset", "stolen-object")
data = self.post_commands([stolen], account_id="acct-b", password="pw")
self.assertEqual(data["appliedCommandIds"], [])
self.assertEqual(data["rejectedCommands"][0]["error"], "asset_id_already_owned")
def test_quota_rejection_does_not_abort_response(self):
commands = [publish_command(f"cmd-{i}", f"asset-{i}", f"object-{i}") for i in range(6)]
data = self.post_commands(commands)
self.assertEqual(len(data["appliedCommandIds"]), 5)
self.assertEqual(data["rejectedCommands"][0]["error"], "publish_limit_reached")
if __name__ == "__main__":
unittest.main()

View file

@ -995,8 +995,8 @@ body, button, input, select, textarea {
text-transform: uppercase;
}
.reportDialog {
position: absolute;
z-index: 20;
position: fixed;
z-index: 9998;
inset: 0;
display: grid;
place-items: center;
@ -1052,8 +1052,9 @@ body, button, input, select, textarea { font-size: 15px; }
.authorCard { min-width: 210px; }
.authorCard small { display:block; margin-top:6px; font-size:12px; line-height:1.35; opacity:.82; }
.miniButton { margin-top:6px; padding:6px 8px; border:2px solid #243044; background:#fff3d9; box-shadow:2px 2px 0 rgba(36,48,68,.22); cursor:pointer; font-size:12px; }
.placementPreviewBar { position:fixed; left:50%; bottom:22px; transform:translateX(-50%); z-index:35; display:flex; align-items:center; gap:10px; max-width:min(760px, calc(100vw - 24px)); padding:12px 14px; border:3px solid #243044; background:#fff7de; box-shadow:5px 5px 0 rgba(36,48,68,.25); font-size:15px; }
.placementPreviewBar[hidden] { display:none; }
.homeLink { position:fixed; left:12px; top:12px; z-index:6; display:inline-flex; align-items:center; gap:6px; padding:7px 9px; border:2px solid #243044; background:rgba(255,247,222,.92); box-shadow:3px 3px 0 rgba(36,48,68,.22); color:#243044; text-decoration:none; font-weight:800; letter-spacing:.02em; }
.homeLinkIcon { display:grid; place-items:center; width:22px; height:22px; border:2px solid currentColor; background:#fffdf5; line-height:1; }
.homeLinkText { font-size:13px; }
.likedCodex { margin:8px 0 14px; padding:10px; border:2px dashed rgba(36,48,68,.35); background:rgba(255,255,255,.45); font-size:14px; }
.likedCodexTitle { font-weight:700; margin-bottom:6px; }
.likedCodexList { display:flex; gap:8px; flex-wrap:wrap; }
@ -1213,6 +1214,25 @@ body, button, input, select, textarea { font-size: 15px; }
box-shadow: none !important;
}
.clockCard.utilityControl { display: none !important; }
.accountSettingsCard .accountCard.utilityControl {
max-width: none !important;
width: 100% !important;
justify-content: space-between;
flex-wrap: wrap;
background: #fffdf5 !important;
}
.accountSettingsCard .accountFields {
grid-template-columns: minmax(90px, 1fr) minmax(120px, 1.2fr) minmax(120px, 1.2fr);
flex: 1 1 360px;
}
.accountSettingsCard .accountCard small {
max-width: none !important;
flex-basis: 100%;
}
@media (max-width: 560px) {
.accountSettingsCard .accountFields { grid-template-columns: 1fr; }
}
.editorToolRow { grid-template-columns: repeat(4, minmax(0, 1fr)) !important; }
.editorHistoryRow { grid-template-columns: repeat(2, minmax(0, 1fr)) !important; }
.studioDrawer.advancedTools .editorToolRow { grid-template-columns: repeat(4, minmax(0, 1fr)) !important; }
@ -1266,7 +1286,6 @@ body, button, input, select, textarea { font-size: 15px; }
font-size: 11px;
}
.finishActions { grid-template-columns: 1.4fr 1fr !important; }
.placementPreviewBar span::after { content: " Left-click chooses a tile; right-click cancels selection."; }
@media (max-width: 980px) {
.islandTopBar { width: calc(100vw - 16px) !important; flex-wrap: wrap; justify-content: center; }
.accountCard.utilityControl { order: 3; max-width: 100% !important; }
@ -1462,7 +1481,7 @@ body, button, input, select, textarea { font-size: 15px; }
width: 4px;
height: 28px;
transform-origin: 50% 100%;
transform: translate(-50%, -100%) rotate(var(--clock-rotate));
transform: translate(-50%, -100%) rotate(var(--clock-rotate, 0deg));
background: #243044;
border-radius: 6px 6px 2px 2px;
box-shadow: 1px 0 0 rgba(255,255,255,.45);
@ -2895,3 +2914,26 @@ body, button, input, select, textarea { font-size: 15px; }
}
}
/* v3 placement confirmation: explicit button, never the old bottom preview bar. */
.placeHereButton {
position: fixed;
z-index: 92;
transform: translate(-50%, -100%);
padding: 9px 12px !important;
border: 3px solid var(--line) !important;
border-radius: 13px !important;
background: #fff2a8 !important;
color: #243044 !important;
box-shadow: 0 6px 0 rgba(36,48,68,.22), 0 10px 18px rgba(22,31,46,.18) !important;
font-size: 13px !important;
font-weight: 900 !important;
letter-spacing: .04em;
cursor: pointer;
}
.placeHereButton[hidden] { display: none !important; }
#confirmDialog.reportDialog { position: fixed !important; z-index: 10000 !important; }
.reportDialog { z-index: 9998; }
.assetStatus.validating { background: #fff2a8; }
.assetStatus.failed { background: #ffe2e2; color: #8b2424; }