publish
This commit is contained in:
parent
92abfa3c89
commit
71009c551f
11 changed files with 2771 additions and 204 deletions
109
DEPLOY_SHARED_JA.md
Normal file
109
DEPLOY_SHARED_JA.md
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
# 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 で少人数テストは可能ですが、同時アクセスやデータ破損リスクを考えると長期運用には向きません。
|
||||
1
_data/.htaccess
Normal file
1
_data/.htaccess
Normal file
|
|
@ -0,0 +1 @@
|
|||
Require all denied
|
||||
2
_data/README.txt
Normal file
2
_data/README.txt
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
SQLite database files are created here on first API access.
|
||||
Do not delete .htaccess or web.config. They block direct web access to this directory.
|
||||
11
_data/web.config
Normal file
11
_data/web.config
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<configuration>
|
||||
<system.webServer>
|
||||
<security>
|
||||
<authorization>
|
||||
<remove users="*" roles="" verbs="" />
|
||||
<add accessType="Deny" users="*" />
|
||||
</authorization>
|
||||
</security>
|
||||
</system.webServer>
|
||||
</configuration>
|
||||
190
admin/index.php
Normal file
190
admin/index.php
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
$config = require dirname(__DIR__) . '/api/config.php';
|
||||
$token = (string)($config['admin_token'] ?? '');
|
||||
$given = (string)($_GET['token'] ?? $_POST['token'] ?? '');
|
||||
header('X-Content-Type-Options: nosniff');
|
||||
|
||||
function h($s): string { return htmlspecialchars((string)$s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); }
|
||||
function clean_object_id($value): string { return substr(preg_replace('/[^A-Za-z0-9_:\-.]/', '', (string)$value) ?? '', 0, 96); }
|
||||
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 {
|
||||
$stmt = $db->query("SELECT name FROM sqlite_master WHERE type='table' AND name IN ('reports','objects','assets')");
|
||||
$names = $stmt ? array_column($stmt->fetchAll(PDO::FETCH_ASSOC), 'name') : [];
|
||||
return in_array('reports', $names, true) && in_array('objects', $names, true) && in_array('assets', $names, true);
|
||||
}
|
||||
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];
|
||||
}
|
||||
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);
|
||||
fwrite($fp, json_encode($db, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
|
||||
fflush($fp);
|
||||
flock($fp, LOCK_UN);
|
||||
} finally {
|
||||
fclose($fp);
|
||||
}
|
||||
}
|
||||
function asset_name_from_json($json, string $fallback = 'Untitled'): string {
|
||||
if (is_array($json)) return (string)($json['name'] ?? $fallback);
|
||||
$d = json_decode((string)$json, true);
|
||||
return is_array($d) ? (string)($d['name'] ?? $fallback) : $fallback;
|
||||
}
|
||||
function list_sqlite_reports(string $path): array {
|
||||
$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');
|
||||
if (!sqlite_tables_ready($db)) return [];
|
||||
$rows = $db->query("SELECT r.*, o.moderation_status, o.json AS object_json, 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(function($r) {
|
||||
return [
|
||||
'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'),
|
||||
];
|
||||
}, $rows ?: []);
|
||||
}
|
||||
function list_filedb_reports(string $path): array {
|
||||
$db = read_filedb($path);
|
||||
$reports = is_array($db['reports'] ?? null) ? $db['reports'] : [];
|
||||
$objects = is_array($db['objects'] ?? null) ? $db['objects'] : [];
|
||||
$assets = is_array($db['assets'] ?? null) ? $db['assets'] : [];
|
||||
$out = [];
|
||||
foreach ($reports as $r) {
|
||||
if (($r['status'] ?? 'open') !== 'open') continue;
|
||||
$objectId = (string)($r['object_id'] ?? '');
|
||||
$assetId = (string)($r['asset_id'] ?? '');
|
||||
$object = $objects[$objectId] ?? [];
|
||||
$asset = $assets[$assetId] ?? [];
|
||||
$out[] = [
|
||||
'id' => $r['id'] ?? '',
|
||||
'object_id' => $objectId,
|
||||
'asset_id' => $assetId,
|
||||
'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'),
|
||||
];
|
||||
}
|
||||
usort($out, fn($a, $b) => ((int)$b['created_at']) <=> ((int)$a['created_at']));
|
||||
return array_slice($out, 0, 200);
|
||||
}
|
||||
function apply_sqlite_action(string $path, string $objectId, string $action): void {
|
||||
$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');
|
||||
if (!sqlite_tables_ready($db)) return;
|
||||
$now = (int)floor(microtime(true) * 1000);
|
||||
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]);
|
||||
} elseif ($action === 'close_reports') {
|
||||
$db->prepare("UPDATE reports SET status = 'closed' WHERE object_id = ?")->execute([$objectId]);
|
||||
}
|
||||
}
|
||||
function apply_filedb_action(string $path, string $objectId, string $action): void {
|
||||
write_filedb($path, function(array &$db) use ($objectId, $action) {
|
||||
$now = (int)floor(microtime(true) * 1000);
|
||||
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' && is_array($db['reports'] ?? null)) {
|
||||
foreach ($db['reports'] as &$report) {
|
||||
if (($report['object_id'] ?? '') === $objectId) $report['status'] = 'closed';
|
||||
}
|
||||
unset($report);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if ($token === '') {
|
||||
http_response_code(403);
|
||||
echo '<!doctype html><meta charset="utf-8"><title>Admin disabled</title><h1>Admin disabled</h1><p>Set <code>admin_token</code> in <code>api/config.php</code> to enable this page.</p>';
|
||||
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 = '';
|
||||
try {
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$objectId = clean_object_id($_POST['object_id'] ?? '');
|
||||
$action = (string)($_POST['moderation_action'] ?? '');
|
||||
if ($objectId !== '') {
|
||||
if ($store['type'] === 'sqlite') apply_sqlite_action($store['path'], $objectId, $action);
|
||||
elseif ($store['type'] === 'filedb') apply_filedb_action($store['path'], $objectId, $action);
|
||||
}
|
||||
header('Location: ./?token=' . rawurlencode($given));
|
||||
exit;
|
||||
}
|
||||
if ($store['type'] === 'sqlite') $reports = list_sqlite_reports($store['path']);
|
||||
elseif ($store['type'] === 'filedb') $reports = list_filedb_reports($store['path']);
|
||||
else $reports = [];
|
||||
} catch (Throwable $e) {
|
||||
$reports = [];
|
||||
$error = $e->getMessage();
|
||||
}
|
||||
$storeLabel = $store['type'] === 'sqlite' ? 'SQLite' : ($store['type'] === 'filedb' ? 'JSON file DB' : 'DB not initialized yet');
|
||||
?>
|
||||
<!doctype html>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Pixel Island Admin</title>
|
||||
<style>
|
||||
body{font-family:system-ui,sans-serif;margin:24px;background:#f7f4ea;color:#2b2b2b}.card{background:#fff;border:1px solid #ddd;border-radius:12px;padding:16px;margin:12px 0}button{margin-right:8px;padding:6px 10px}.danger{background:#b92323;color:white;border:0;border-radius:6px}.ok{background:#267a3e;color:white;border:0;border-radius:6px}.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}
|
||||
</style>
|
||||
<h1>Pixel Island Admin</h1>
|
||||
<p class="muted">Store: <?=h($storeLabel)?> / Open reports: <?=count($reports)?>. Hiding an object removes it from public snapshots; restore makes it visible again.</p>
|
||||
<?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; ?>
|
||||
<?php foreach ($reports as $r): ?>
|
||||
<div class="card">
|
||||
<h2><?=h($r['asset_name'] ?? 'Untitled')?></h2>
|
||||
<p><strong>Reason:</strong> <?=h($r['reason'] ?? '')?> / <strong>Reporter:</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="object_id" value="<?=h($r['object_id'] ?? '')?>">
|
||||
<button class="danger" name="moderation_action" value="hide">Hide object</button>
|
||||
<button class="ok" name="moderation_action" value="restore">Restore object</button>
|
||||
<button name="moderation_action" value="close_reports">Close reports</button>
|
||||
</form>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
<?php if (!$reports): ?><p>No open reports.</p><?php endif; ?>
|
||||
17
api/config.php
Normal file
17
api/config.php
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
<?php
|
||||
// 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',
|
||||
'world_width' => 144,
|
||||
'world_height' => 112,
|
||||
'max_assets_per_account' => 220,
|
||||
'max_world_objects' => 1000,
|
||||
'publish_limit_first_day' => 5,
|
||||
'publish_limit_trusted' => 10,
|
||||
'trusted_after_ms' => 24 * 60 * 60 * 1000,
|
||||
'max_json_bytes' => 5 * 1024 * 1024,
|
||||
// Set an arbitrary long token here if you use admin/index.php.
|
||||
// Example: 'admin_token' => 'change-this-long-random-string',
|
||||
'admin_token' => '',
|
||||
];
|
||||
176
api/filedb.php
Normal file
176
api/filedb.php
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
<?php
|
||||
declare(strict_types=1);
|
||||
// Fallback backend for hosts without pdo_sqlite. Uses a locked JSON file under _data/.
|
||||
// Prefer SQLite for real traffic; this fallback is intended for small HTTPS test publication.
|
||||
|
||||
function fb_respond(array $payload, int $status = 200): void {
|
||||
http_response_code($status);
|
||||
echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
exit;
|
||||
}
|
||||
function fb_fail(string $message, int $status = 400, array $extra = []): void { fb_respond(['ok' => false, 'error' => $message] + $extra, $status); }
|
||||
function fb_now(): int { return (int) floor(microtime(true) * 1000); }
|
||||
function fb_clean_text($value, string $fallback = '', int $max = 80): string {
|
||||
$text = trim((string)($value ?? ''));
|
||||
$text = preg_replace('/[\x00-\x1F\x7F]/u', '', $text) ?? '';
|
||||
$text = preg_replace('/\s+/u', ' ', $text) ?? '';
|
||||
if ($text === '') $text = $fallback;
|
||||
return function_exists('mb_substr') ? mb_substr($text, 0, $max, 'UTF-8') : substr($text, 0, $max);
|
||||
}
|
||||
function fb_clean_id($value, string $fallback = ''): string {
|
||||
$id = trim((string)($value ?? ''));
|
||||
if ($id === '') return $fallback;
|
||||
return substr(preg_replace('/[^A-Za-z0-9_:\-.]/', '', $id) ?? '', 0, 96);
|
||||
}
|
||||
function fb_request_json(int $maxBytes): array {
|
||||
$raw = file_get_contents('php://input') ?: '';
|
||||
if ($raw === '') return [];
|
||||
if (strlen($raw) > $maxBytes) fb_fail('request_too_large', 413);
|
||||
$data = json_decode($raw, true);
|
||||
if (!is_array($data)) fb_fail('invalid_json', 400);
|
||||
return $data;
|
||||
}
|
||||
function fb_default_db(): array {
|
||||
return ['schema'=>1,'accounts'=>[],'assets'=>[],'objects'=>[],'votes'=>[],'reports'=>[],'events'=>[],'commands'=>[],'nextEventId'=>1];
|
||||
}
|
||||
function fb_db_path(array $config): string {
|
||||
return dirname($config['db_path']) . DIRECTORY_SEPARATOR . 'pixel_island_filedb.json';
|
||||
}
|
||||
function fb_with_db(array $config, callable $callback): void {
|
||||
$path = fb_db_path($config);
|
||||
$dir = dirname($path);
|
||||
if (!is_dir($dir) && !mkdir($dir, 0775, true) && !is_dir($dir)) fb_fail('db_directory_unavailable', 500);
|
||||
$fp = fopen($path, 'c+');
|
||||
if (!$fp) fb_fail('filedb_unavailable', 500);
|
||||
try {
|
||||
if (!flock($fp, LOCK_EX)) fb_fail('filedb_lock_failed', 503);
|
||||
$raw = stream_get_contents($fp);
|
||||
$db = $raw ? json_decode($raw, true) : null;
|
||||
if (!is_array($db)) $db = fb_default_db();
|
||||
$result = $callback($db);
|
||||
ftruncate($fp, 0);
|
||||
rewind($fp);
|
||||
fwrite($fp, json_encode($db, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
|
||||
fflush($fp);
|
||||
flock($fp, LOCK_UN);
|
||||
fclose($fp);
|
||||
fb_respond($result);
|
||||
} catch (Throwable $e) {
|
||||
flock($fp, LOCK_UN);
|
||||
fclose($fp);
|
||||
fb_fail($e->getMessage(), 500);
|
||||
}
|
||||
}
|
||||
function fb_auth(array &$db, array $account, bool $create = true): array {
|
||||
$id = fb_clean_id($account['id'] ?? '');
|
||||
$password = (string)($account['password'] ?? $account['pass'] ?? '');
|
||||
$name = fb_clean_text($account['name'] ?? $id, $id, 32);
|
||||
if ($id === '' || $password === '') fb_fail('account_required', 401);
|
||||
$now = fb_now();
|
||||
if (!isset($db['accounts'][$id])) {
|
||||
if (!$create) fb_fail('account_not_found', 401);
|
||||
$db['accounts'][$id] = ['id'=>$id,'name'=>$name,'password_hash'=>password_hash($password, PASSWORD_DEFAULT),'created_at'=>$now,'updated_at'=>$now,'disabled_at'=>null];
|
||||
} else {
|
||||
$row = $db['accounts'][$id];
|
||||
if (!empty($row['disabled_at'])) fb_fail('account_disabled', 403);
|
||||
if (!password_verify($password, $row['password_hash'] ?? '')) fb_fail('invalid_account_password', 401);
|
||||
if ($name !== '' && $name !== ($row['name'] ?? '')) {
|
||||
$db['accounts'][$id]['name'] = $name;
|
||||
$db['accounts'][$id]['updated_at'] = $now;
|
||||
}
|
||||
}
|
||||
return $db['accounts'][$id];
|
||||
}
|
||||
function fb_event(array &$db, string $type, array $event): int {
|
||||
$id = (int)($db['nextEventId'] ?? 1);
|
||||
$db['nextEventId'] = $id + 1;
|
||||
$db['events'][] = ['id'=>$id,'type'=>$type,'json'=>$event,'created_at'=>fb_now()];
|
||||
if (count($db['events']) > 5000) $db['events'] = array_slice($db['events'], -5000);
|
||||
return $id;
|
||||
}
|
||||
function fb_normalize_asset(array $asset, array $actor): array {
|
||||
$id = fb_clean_id($asset['id'] ?? '');
|
||||
if ($id === '') fb_fail('asset_id_required');
|
||||
$category = (($asset['category'] ?? '') === 'dynamic') ? 'dynamic' : 'static';
|
||||
$w = max(1, min(64, (int)($asset['width'] ?? $asset['w'] ?? $asset['size'] ?? 16)));
|
||||
$h = max(1, min(64, (int)($asset['height'] ?? $asset['ht'] ?? $asset['size'] ?? $w)));
|
||||
$asset['id']=$id; $asset['name']=fb_clean_text($asset['name'] ?? 'Untitled','Untitled',32);
|
||||
$asset['category']=$category; $asset['subtype']=fb_clean_text($asset['subtype'] ?? ($category==='dynamic'?'human':'other'),'other',24);
|
||||
$asset['size']=max($w,$h); $asset['width']=$w; $asset['height']=$h;
|
||||
$asset['author']=fb_clean_text($actor['name'] ?? $actor['id'],$actor['id'],32); $asset['ownerAccountId']=$actor['id'];
|
||||
$asset['version']=max(1,(int)($asset['version'] ?? 1)); $asset['createdAt']=(int)($asset['createdAt'] ?? fb_now()); $asset['updatedAt']=fb_now();
|
||||
if (!isset($asset['pixels']) && !isset($asset['faces'])) fb_fail('asset_pixels_required');
|
||||
if (strlen(json_encode($asset, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)) > 250000) fb_fail('asset_too_large', 413);
|
||||
return $asset;
|
||||
}
|
||||
function fb_normalize_object(array $object, string $kind, array $actor, array $config): array {
|
||||
$id=fb_clean_id($object['id'] ?? ''); $assetId=fb_clean_id($object['assetId'] ?? '');
|
||||
if ($id==='' || $assetId==='') fb_fail('object_id_or_asset_required');
|
||||
$object['id']=$id; $object['assetId']=$assetId; $object['ownerAccountId']=$actor['id']; $object['version']=max(1,(int)($object['version'] ?? 1)); $object['status']='active'; $object['publishedAt']=(int)($object['publishedAt'] ?? fb_now());
|
||||
if ($kind==='dynamic') { $object['homeX']=max(0,min((int)$config['world_width']-1,(int)round((float)($object['homeX'] ?? $object['x'] ?? 0)))); $object['homeY']=max(0,min((int)$config['world_height']-1,(int)round((float)($object['homeY'] ?? $object['y'] ?? 0)))); $object['createdAt']=(int)($object['createdAt'] ?? fb_now()); }
|
||||
else { $object['x']=max(0,min((int)$config['world_width']-1,(int)round((float)($object['x'] ?? 0)))); $object['y']=max(0,min((int)$config['world_height']-1,(int)round((float)($object['y'] ?? 0)))); $object['placedAt']=(int)($object['placedAt'] ?? fb_now()); }
|
||||
return $object;
|
||||
}
|
||||
function fb_asset_owner(array $db, string $assetId, string $actorId): void {
|
||||
$row = $db['assets'][$assetId] ?? null;
|
||||
if (!$row || !empty($row['deleted_at'])) fb_fail('asset_not_found', 404);
|
||||
if (($row['owner_account_id'] ?? '') !== $actorId) fb_fail('asset_owner_required', 403);
|
||||
}
|
||||
function fb_quota(array $db, array $actor, array $config): bool {
|
||||
$now=fb_now(); $created=(int)($actor['created_at'] ?? $now); $limit=($now-$created)<(int)$config['trusted_after_ms'] ? (int)$config['publish_limit_first_day'] : (int)$config['publish_limit_trusted']; $since=$now-3600000; $used=0;
|
||||
foreach ($db['objects'] as $o) if (($o['owner_account_id'] ?? '')===$actor['id'] && empty($o['deleted_at']) && (int)($o['published_at'] ?? 0) >= $since) $used++;
|
||||
return $used < $limit;
|
||||
}
|
||||
function fb_process(array &$db, array $command, array $actor, array $config): array {
|
||||
$cmdId=fb_clean_id($command['id'] ?? ''); $type=(string)($command['type'] ?? '');
|
||||
if ($cmdId==='' || $type==='') return ['ok'=>false,'id'=>$cmdId,'error'=>'invalid_command'];
|
||||
if (isset($db['commands'][$cmdId])) return ['ok'=>true,'id'=>$cmdId,'duplicate'=>true];
|
||||
$now=fb_now();
|
||||
if ($type==='asset.create') {
|
||||
$asset=fb_normalize_asset(is_array($command['asset'] ?? null)?$command['asset']:[], $actor);
|
||||
if (isset($db['assets'][$asset['id']]) && ($db['assets'][$asset['id']]['owner_account_id'] ?? '') !== $actor['id']) fb_fail('asset_id_already_owned',403);
|
||||
$db['assets'][$asset['id']]=['id'=>$asset['id'],'owner_account_id'=>$actor['id'],'author_name'=>$asset['author'],'json'=>$asset,'version'=>$asset['version'],'created_at'=>$asset['createdAt'],'updated_at'=>$asset['updatedAt'],'deleted_at'=>null,'deleted_by'=>null,'content_hash'=>$asset['contentHash'] ?? null];
|
||||
fb_event($db,'asset.upsert',['type'=>'asset.upsert','assetId'=>$asset['id'],'actorAccountId'=>$actor['id']]);
|
||||
} elseif ($type==='asset.delete') {
|
||||
$assetId=fb_clean_id($command['assetId'] ?? ''); fb_asset_owner($db,$assetId,$actor['id']);
|
||||
$db['assets'][$assetId]['deleted_at']=$now; $db['assets'][$assetId]['deleted_by']=$actor['id']; $db['assets'][$assetId]['updated_at']=$now;
|
||||
foreach ($db['objects'] as &$o) if (($o['asset_id'] ?? '')===$assetId && empty($o['deleted_at'])) { $o['deleted_at']=$now; $o['deleted_by']=$actor['id']; $o['updated_at']=$now; } unset($o);
|
||||
fb_event($db,'asset.delete',['type'=>'asset.delete','assetId'=>$assetId,'actorAccountId'=>$actor['id']]);
|
||||
} elseif ($type==='object.publish' || $type==='object.move') {
|
||||
$kind=(($command['kind'] ?? '')==='dynamic')?'dynamic':'static'; $object=fb_normalize_object(is_array($command['object'] ?? null)?$command['object']:[], $kind, $actor, $config); fb_asset_owner($db,$object['assetId'],$actor['id']);
|
||||
if ($type==='object.publish' && !fb_quota($db,$actor,$config)) fb_fail('publish_limit_reached',429);
|
||||
$active=0; foreach($db['objects'] as $o) if (empty($o['deleted_at']) && ($o['moderation_status'] ?? 'active')==='active') $active++;
|
||||
if ($type==='object.publish' && $active >= (int)$config['max_world_objects']) fb_fail('world_object_limit_reached',409);
|
||||
$db['objects'][$object['id']]=['id'=>$object['id'],'kind'=>$kind,'asset_id'=>$object['assetId'],'owner_account_id'=>$actor['id'],'json'=>$object,'version'=>$object['version'],'published_at'=>(int)$object['publishedAt'],'updated_at'=>$now,'deleted_at'=>null,'deleted_by'=>null,'moderation_status'=>'active'];
|
||||
fb_event($db,'object.upsert',['type'=>'object.upsert','kind'=>$kind,'objectId'=>$object['id'],'actorAccountId'=>$actor['id']]);
|
||||
} elseif ($type==='object.delete') {
|
||||
$kind=(($command['kind'] ?? '')==='dynamic')?'dynamic':'static'; $objectId=fb_clean_id($command['objectId'] ?? ''); $o=$db['objects'][$objectId] ?? null; if (!$o || ($o['kind'] ?? '')!==$kind || !empty($o['deleted_at'])) fb_fail('object_not_found',404); if (($o['owner_account_id'] ?? '')!==$actor['id']) fb_fail('object_owner_required',403);
|
||||
$db['objects'][$objectId]['deleted_at']=$now; $db['objects'][$objectId]['deleted_by']=$actor['id']; $db['objects'][$objectId]['updated_at']=$now; fb_event($db,'object.delete',['type'=>'object.delete','kind'=>$kind,'objectId'=>$objectId,'actorAccountId'=>$actor['id']]);
|
||||
} elseif ($type==='vote.asset' || $type==='vote.object') {
|
||||
$targetType=$type==='vote.asset'?'asset':'object'; $targetId=fb_clean_id($command[$targetType.'Id'] ?? $command['targetId'] ?? ''); $value=max(-1,min(1,(int)($command['value'] ?? $command['delta'] ?? 0))); if($targetId==='') fb_fail('vote_target_required'); $key=$targetType.'|'.$targetId.'|'.$actor['id']; if($value===0) unset($db['votes'][$key]); else $db['votes'][$key]=['target_type'=>$targetType,'target_id'=>$targetId,'voter_account_id'=>$actor['id'],'value'=>$value,'updated_at'=>$now]; fb_event($db,'vote.'.$targetType,['type'=>'vote.'.$targetType,'targetId'=>$targetId,'actorAccountId'=>$actor['id'],'value'=>$value]);
|
||||
} elseif ($type==='report.object') {
|
||||
$objectId=fb_clean_id($command['objectId'] ?? ''); $assetId=fb_clean_id($command['assetId'] ?? ''); $kind=(($command['objectKind'] ?? $command['kind'] ?? '')==='dynamic')?'dynamic':'static'; $reason=fb_clean_text($command['reason'] ?? 'other','other',48); if($objectId==='' || $assetId==='') fb_fail('report_target_required'); $reportId=fb_clean_id($command['reportId'] ?? $command['id'] ?? ('report_'.bin2hex(random_bytes(6)))); $db['reports'][$reportId]=['id'=>$reportId,'object_id'=>$objectId,'asset_id'=>$assetId,'object_kind'=>$kind,'reporter_account_id'=>$actor['id'],'reason'=>$reason,'created_at'=>$now,'status'=>'open']; fb_event($db,'report.object',['type'=>'report.object','objectId'=>$objectId,'assetId'=>$assetId,'actorAccountId'=>$actor['id'],'reason'=>$reason]);
|
||||
} else return ['ok'=>false,'id'=>$cmdId,'error'=>'unsupported_command'];
|
||||
$db['commands'][$cmdId]=['id'=>$cmdId,'actor_account_id'=>$actor['id'],'created_at'=>(int)($command['createdAt'] ?? $now),'applied_at'=>$now];
|
||||
return ['ok'=>true,'id'=>$cmdId];
|
||||
}
|
||||
function fb_votes(array $db, string $targetType): array { $out=[]; foreach($db['votes'] as $v){ if(($v['target_type'] ?? '')!==$targetType || (int)($v['value'] ?? 0)===0) continue; $id=$v['target_id']; $val=(int)$v['value']; if(!isset($out[$id])) $out[$id]=['up'=>0,'down'=>0,'voters'=>[]]; if($val>0)$out[$id]['up']++; if($val<0)$out[$id]['down']++; $out[$id]['voters'][$v['voter_account_id']]=$val; } return $out; }
|
||||
function fb_snapshot(array $db): array {
|
||||
$assets=[]; foreach($db['assets'] as $a) if(empty($a['deleted_at'])) $assets[]=$a['json'];
|
||||
usort($assets, fn($a,$b)=>(int)($b['updatedAt'] ?? 0) <=> (int)($a['updatedAt'] ?? 0));
|
||||
$placed=[]; $dynamic=[]; foreach($db['objects'] as $o){ if(!empty($o['deleted_at']) || ($o['moderation_status'] ?? 'active')!=='active') continue; if(($o['kind'] ?? '')==='dynamic') $dynamic[]=$o['json']; else $placed[]=$o['json']; }
|
||||
$assetT=[]; foreach($db['assets'] as $a) if(!empty($a['deleted_at'])) $assetT[$a['id']]=['id'=>$a['id'],'deletedAt'=>(int)$a['deleted_at'],'deletedBy'=>$a['deleted_by'] ?? '', 'version'=>(int)($a['version'] ?? 1)];
|
||||
$objectT=[]; foreach($db['objects'] as $o) if(!empty($o['deleted_at'])) $objectT[$o['id']]=['id'=>$o['id'],'assetId'=>$o['asset_id'],'deletedAt'=>(int)$o['deleted_at'],'deletedBy'=>$o['deleted_by'] ?? '', 'version'=>(int)($o['version'] ?? 1)];
|
||||
$reports=[]; foreach($db['reports'] as $r) if(($r['status'] ?? 'open')==='open') $reports[]=['id'=>$r['id'],'objectId'=>$r['object_id'],'assetId'=>$r['asset_id'],'objectKind'=>$r['object_kind'],'reporter'=>$r['reporter_account_id'],'reason'=>$r['reason'],'createdAt'=>(int)$r['created_at']];
|
||||
$last=0; foreach($db['events'] as $e) $last=max($last,(int)($e['id'] ?? 0));
|
||||
return ['ok'=>true,'schema'=>1,'fileDb'=>true,'serverNow'=>fb_now(),'lastEventId'=>$last,'authority'=>['publish'=>'server','objectMove'=>'server','dayNight'=>'local','dynamicMotion'=>'local'],'assets'=>array_values($assets),'placed'=>array_values($placed),'dynamicSummons'=>array_values($dynamic),'assetVotes'=>fb_votes($db,'asset'),'objectVotes'=>fb_votes($db,'object'),'moderationReports'=>$reports,'tombstones'=>['assets'=>$assetT,'objects'=>$objectT]];
|
||||
}
|
||||
|
||||
$action = $_GET['action'] ?? '';
|
||||
if ($action === 'health' || $action === '') fb_respond(['ok'=>true,'service'=>'pixel-island-api','serverNow'=>fb_now(),'sqlite'=>false,'fileDb'=>true,'note'=>'pdo_sqlite is unavailable; using locked JSON fallback']);
|
||||
fb_with_db($config, function(array &$db) use ($action, $config) {
|
||||
if ($action === 'snapshot') return fb_snapshot($db);
|
||||
if ($action === 'account') { $data=fb_request_json((int)$config['max_json_bytes']); $account=fb_auth($db, is_array($data['account'] ?? null)?$data['account']:$data, true); return ['ok'=>true,'account'=>['id'=>$account['id'],'name'=>$account['name'],'createdAt'=>(int)$account['created_at']]]; }
|
||||
if ($action === 'commands') { $data=fb_request_json((int)$config['max_json_bytes']); $actor=fb_auth($db, is_array($data['account'] ?? null)?$data['account']:[], true); $commands=is_array($data['commands'] ?? null)?$data['commands']:[]; if(count($commands)>100) fb_fail('too_many_commands',413); $applied=[]; $rejected=[]; foreach($commands as $command){ if(!is_array($command)) continue; try{ $result=fb_process($db,$command,$actor,$config); if($result['ok'] ?? false) $applied[]=$result['id']; else $rejected[]=$result; } catch(Throwable $e){ $rejected[]=['ok'=>false,'id'=>fb_clean_id($command['id'] ?? ''),'error'=>$e->getMessage()]; } } return ['ok'=>true,'appliedCommandIds'=>$applied,'rejectedCommands'=>$rejected,'snapshot'=>fb_snapshot($db)]; }
|
||||
return ['ok'=>false,'error'=>'unknown_action'];
|
||||
});
|
||||
455
api/index.php
Normal file
455
api/index.php
Normal file
|
|
@ -0,0 +1,455 @@
|
|||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
$config = require __DIR__ . '/config.php';
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
header('X-Content-Type-Options: nosniff');
|
||||
|
||||
$drivers = class_exists('PDO') ? PDO::getAvailableDrivers() : [];
|
||||
if (!in_array('sqlite', $drivers, true)) {
|
||||
require __DIR__ . '/filedb.php';
|
||||
exit;
|
||||
}
|
||||
|
||||
function respond(array $payload, int $status = 200): void {
|
||||
http_response_code($status);
|
||||
echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
exit;
|
||||
}
|
||||
|
||||
function fail(string $message, int $status = 400, array $extra = []): void {
|
||||
respond(['ok' => false, 'error' => $message] + $extra, $status);
|
||||
}
|
||||
|
||||
function request_json(int $maxBytes): array {
|
||||
$raw = file_get_contents('php://input') ?: '';
|
||||
if ($raw === '') return [];
|
||||
if (strlen($raw) > $maxBytes) fail('request_too_large', 413);
|
||||
$data = json_decode($raw, true);
|
||||
if (!is_array($data)) fail('invalid_json', 400);
|
||||
return $data;
|
||||
}
|
||||
|
||||
function now_ms(): int {
|
||||
return (int) floor(microtime(true) * 1000);
|
||||
}
|
||||
|
||||
function clean_text($value, string $fallback = '', int $max = 80): string {
|
||||
$text = trim((string) ($value ?? ''));
|
||||
$text = preg_replace('/[\x00-\x1F\x7F]/u', '', $text) ?? '';
|
||||
$text = preg_replace('/\s+/u', ' ', $text) ?? '';
|
||||
if ($text === '') $text = $fallback;
|
||||
if (function_exists('mb_substr')) return mb_substr($text, 0, $max, 'UTF-8');
|
||||
return substr($text, 0, $max);
|
||||
}
|
||||
|
||||
function clean_id($value, string $fallback = ''): string {
|
||||
$id = trim((string) ($value ?? ''));
|
||||
if ($id === '') return $fallback;
|
||||
$id = preg_replace('/[^A-Za-z0-9_:\-.]/', '', $id) ?? '';
|
||||
return substr($id, 0, 96);
|
||||
}
|
||||
|
||||
function db(array $config): PDO {
|
||||
$path = $config['db_path'];
|
||||
$dir = dirname($path);
|
||||
if (!is_dir($dir) && !mkdir($dir, 0775, true) && !is_dir($dir)) fail('db_directory_unavailable', 500);
|
||||
$pdo = new PDO('sqlite:' . $path, null, null, [
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||
]);
|
||||
$pdo->exec('PRAGMA foreign_keys = ON');
|
||||
$pdo->exec('PRAGMA busy_timeout = 5000');
|
||||
$pdo->exec('PRAGMA journal_mode = WAL');
|
||||
migrate($pdo);
|
||||
return $pdo;
|
||||
}
|
||||
|
||||
function migrate(PDO $db): void {
|
||||
$db->exec(<<<SQL
|
||||
CREATE TABLE IF NOT EXISTS accounts (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
disabled_at INTEGER
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS assets (
|
||||
id TEXT PRIMARY KEY,
|
||||
owner_account_id TEXT NOT NULL,
|
||||
author_name TEXT NOT NULL,
|
||||
json TEXT NOT NULL,
|
||||
version INTEGER NOT NULL DEFAULT 1,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
deleted_at INTEGER,
|
||||
deleted_by TEXT,
|
||||
content_hash TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_assets_owner ON assets(owner_account_id);
|
||||
CREATE TABLE IF NOT EXISTS objects (
|
||||
id TEXT PRIMARY KEY,
|
||||
kind TEXT NOT NULL CHECK(kind IN ('static','dynamic')),
|
||||
asset_id TEXT NOT NULL,
|
||||
owner_account_id TEXT NOT NULL,
|
||||
json TEXT NOT NULL,
|
||||
version INTEGER NOT NULL DEFAULT 1,
|
||||
published_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
deleted_at INTEGER,
|
||||
deleted_by TEXT,
|
||||
moderation_status TEXT NOT NULL DEFAULT 'active'
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_objects_asset ON objects(asset_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_objects_owner ON objects(owner_account_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_objects_active ON objects(kind, deleted_at, moderation_status);
|
||||
CREATE TABLE IF NOT EXISTS votes (
|
||||
target_type TEXT NOT NULL CHECK(target_type IN ('asset','object')),
|
||||
target_id TEXT NOT NULL,
|
||||
voter_account_id TEXT NOT NULL,
|
||||
value INTEGER NOT NULL CHECK(value IN (-1,0,1)),
|
||||
updated_at INTEGER NOT NULL,
|
||||
PRIMARY KEY(target_type, target_id, voter_account_id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS reports (
|
||||
id TEXT PRIMARY KEY,
|
||||
object_id TEXT NOT NULL,
|
||||
asset_id TEXT NOT NULL,
|
||||
object_kind TEXT NOT NULL CHECK(object_kind IN ('static','dynamic')),
|
||||
reporter_account_id TEXT NOT NULL,
|
||||
reason TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'open'
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_reports_object ON reports(object_id);
|
||||
CREATE TABLE IF NOT EXISTS events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
type TEXT NOT NULL,
|
||||
json TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS commands (
|
||||
id TEXT PRIMARY KEY,
|
||||
actor_account_id TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
applied_at INTEGER NOT NULL
|
||||
);
|
||||
SQL);
|
||||
}
|
||||
|
||||
function authenticate(PDO $db, array $account, bool $createIfMissing = true): array {
|
||||
$id = clean_id($account['id'] ?? '');
|
||||
$password = (string) ($account['password'] ?? $account['pass'] ?? '');
|
||||
$name = clean_text($account['name'] ?? $id, $id, 32);
|
||||
if ($id === '' || $password === '') fail('account_required', 401);
|
||||
|
||||
$stmt = $db->prepare('SELECT * FROM accounts WHERE id = ?');
|
||||
$stmt->execute([$id]);
|
||||
$row = $stmt->fetch();
|
||||
$now = now_ms();
|
||||
if (!$row) {
|
||||
if (!$createIfMissing) fail('account_not_found', 401);
|
||||
$hash = password_hash($password, PASSWORD_DEFAULT);
|
||||
$db->prepare('INSERT INTO accounts(id, name, password_hash, created_at, updated_at) VALUES(?,?,?,?,?)')
|
||||
->execute([$id, $name, $hash, $now, $now]);
|
||||
return ['id' => $id, 'name' => $name, 'created_at' => $now, 'updated_at' => $now];
|
||||
}
|
||||
if (!empty($row['disabled_at'])) fail('account_disabled', 403);
|
||||
if (!password_verify($password, $row['password_hash'])) fail('invalid_account_password', 401);
|
||||
if ($name !== '' && $name !== $row['name']) {
|
||||
$db->prepare('UPDATE accounts SET name = ?, updated_at = ? WHERE id = ?')->execute([$name, $now, $id]);
|
||||
$row['name'] = $name;
|
||||
$row['updated_at'] = $now;
|
||||
}
|
||||
return $row;
|
||||
}
|
||||
|
||||
function record_event(PDO $db, string $type, array $event): int {
|
||||
$now = now_ms();
|
||||
$db->prepare('INSERT INTO events(type, json, created_at) VALUES(?,?,?)')
|
||||
->execute([$type, json_encode($event, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), $now]);
|
||||
return (int) $db->lastInsertId();
|
||||
}
|
||||
|
||||
function decode_json_row(?string $json): array {
|
||||
$decoded = json_decode((string) $json, true);
|
||||
return is_array($decoded) ? $decoded : [];
|
||||
}
|
||||
|
||||
function normalize_asset(array $asset, array $actor, array $config): array {
|
||||
$id = clean_id($asset['id'] ?? '');
|
||||
if ($id === '') fail('asset_id_required');
|
||||
$category = (($asset['category'] ?? '') === 'dynamic') ? 'dynamic' : 'static';
|
||||
$width = max(1, min(64, (int) ($asset['width'] ?? $asset['w'] ?? $asset['size'] ?? 16)));
|
||||
$height = max(1, min(64, (int) ($asset['height'] ?? $asset['ht'] ?? $asset['size'] ?? $width)));
|
||||
$asset['id'] = $id;
|
||||
$asset['name'] = clean_text($asset['name'] ?? 'Untitled', 'Untitled', 32);
|
||||
$asset['category'] = $category;
|
||||
$asset['subtype'] = clean_text($asset['subtype'] ?? ($category === 'dynamic' ? 'human' : 'other'), 'other', 24);
|
||||
$asset['size'] = max($width, $height);
|
||||
$asset['width'] = $width;
|
||||
$asset['height'] = $height;
|
||||
$asset['author'] = clean_text($actor['name'] ?? $actor['id'], $actor['id'], 32);
|
||||
$asset['ownerAccountId'] = $actor['id'];
|
||||
$asset['version'] = max(1, (int) ($asset['version'] ?? 1));
|
||||
$asset['createdAt'] = (int) ($asset['createdAt'] ?? now_ms());
|
||||
$asset['updatedAt'] = now_ms();
|
||||
$asset['parentAssetId'] = isset($asset['parentAssetId']) ? clean_id($asset['parentAssetId'], '') : null;
|
||||
$asset['originalAssetId'] = isset($asset['originalAssetId']) ? clean_id($asset['originalAssetId'], '') : null;
|
||||
if (!isset($asset['pixels']) && !isset($asset['faces'])) fail('asset_pixels_required');
|
||||
$json = json_encode($asset, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
if ($json === false || strlen($json) > 250000) fail('asset_too_large', 413);
|
||||
return $asset;
|
||||
}
|
||||
|
||||
function normalize_object(array $object, string $kind, array $actor, array $config): array {
|
||||
$id = clean_id($object['id'] ?? '');
|
||||
$assetId = clean_id($object['assetId'] ?? '');
|
||||
if ($id === '' || $assetId === '') fail('object_id_or_asset_required');
|
||||
$object['id'] = $id;
|
||||
$object['assetId'] = $assetId;
|
||||
$object['ownerAccountId'] = $actor['id'];
|
||||
$object['version'] = max(1, (int) ($object['version'] ?? 1));
|
||||
$object['status'] = 'active';
|
||||
$object['publishedAt'] = (int) ($object['publishedAt'] ?? now_ms());
|
||||
if ($kind === 'dynamic') {
|
||||
$object['homeX'] = max(0, min((int) $config['world_width'] - 1, (int) round((float) ($object['homeX'] ?? $object['x'] ?? 0))));
|
||||
$object['homeY'] = max(0, min((int) $config['world_height'] - 1, (int) round((float) ($object['homeY'] ?? $object['y'] ?? 0))));
|
||||
$object['createdAt'] = (int) ($object['createdAt'] ?? now_ms());
|
||||
} else {
|
||||
$object['x'] = max(0, min((int) $config['world_width'] - 1, (int) round((float) ($object['x'] ?? 0))));
|
||||
$object['y'] = max(0, min((int) $config['world_height'] - 1, (int) round((float) ($object['y'] ?? 0))));
|
||||
$object['placedAt'] = (int) ($object['placedAt'] ?? now_ms());
|
||||
}
|
||||
$json = json_encode($object, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
if ($json === false || strlen($json) > 50000) fail('object_too_large', 413);
|
||||
return $object;
|
||||
}
|
||||
|
||||
function ensure_asset_owner(PDO $db, string $assetId, string $actorId): array {
|
||||
$stmt = $db->prepare('SELECT * FROM assets WHERE id = ? AND deleted_at IS NULL');
|
||||
$stmt->execute([$assetId]);
|
||||
$asset = $stmt->fetch();
|
||||
if (!$asset) fail('asset_not_found', 404);
|
||||
if ($asset['owner_account_id'] !== $actorId) fail('asset_owner_required', 403);
|
||||
return $asset;
|
||||
}
|
||||
|
||||
function publish_quota_available(PDO $db, array $actor, array $config): bool {
|
||||
$now = now_ms();
|
||||
$created = (int) ($actor['created_at'] ?? $now);
|
||||
$limit = ($now - $created) < (int) $config['trusted_after_ms'] ? (int) $config['publish_limit_first_day'] : (int) $config['publish_limit_trusted'];
|
||||
$since = $now - 60 * 60 * 1000;
|
||||
$stmt = $db->prepare('SELECT COUNT(*) AS c FROM objects WHERE owner_account_id = ? AND published_at >= ? AND deleted_at IS NULL');
|
||||
$stmt->execute([$actor['id'], $since]);
|
||||
return ((int) ($stmt->fetch()['c'] ?? 0)) < $limit;
|
||||
}
|
||||
|
||||
function process_command(PDO $db, array $command, array $actor, array $config): array {
|
||||
$cmdId = clean_id($command['id'] ?? '');
|
||||
$type = (string) ($command['type'] ?? '');
|
||||
if ($cmdId === '' || $type === '') return ['ok' => false, 'id' => $cmdId, 'error' => 'invalid_command'];
|
||||
|
||||
$stmt = $db->prepare('SELECT id FROM commands WHERE id = ?');
|
||||
$stmt->execute([$cmdId]);
|
||||
if ($stmt->fetch()) return ['ok' => true, 'id' => $cmdId, 'duplicate' => true];
|
||||
|
||||
$now = now_ms();
|
||||
if ($type === 'asset.create') {
|
||||
$asset = normalize_asset(is_array($command['asset'] ?? null) ? $command['asset'] : [], $actor, $config);
|
||||
$existing = $db->prepare('SELECT owner_account_id, deleted_at FROM assets WHERE id = ?');
|
||||
$existing->execute([$asset['id']]);
|
||||
$row = $existing->fetch();
|
||||
if ($row && $row['owner_account_id'] !== $actor['id']) fail('asset_id_already_owned', 403);
|
||||
$json = json_encode($asset, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
$db->prepare('INSERT INTO assets(id, owner_account_id, author_name, json, version, created_at, updated_at, deleted_at, deleted_by, content_hash)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?)
|
||||
ON CONFLICT(id) DO UPDATE SET author_name=excluded.author_name, json=excluded.json, version=excluded.version, updated_at=excluded.updated_at, deleted_at=NULL, deleted_by=NULL, content_hash=excluded.content_hash')
|
||||
->execute([$asset['id'], $actor['id'], $asset['author'], $json, $asset['version'], $asset['createdAt'], $asset['updatedAt'], null, null, $asset['contentHash'] ?? null]);
|
||||
record_event($db, 'asset.upsert', ['type' => 'asset.upsert', 'assetId' => $asset['id'], 'actorAccountId' => $actor['id']]);
|
||||
} elseif ($type === 'asset.delete') {
|
||||
$assetId = clean_id($command['assetId'] ?? '');
|
||||
ensure_asset_owner($db, $assetId, $actor['id']);
|
||||
$db->prepare('UPDATE assets SET deleted_at = ?, deleted_by = ?, updated_at = ? WHERE id = ?')
|
||||
->execute([$now, $actor['id'], $now, $assetId]);
|
||||
$db->prepare('UPDATE objects SET deleted_at = ?, deleted_by = ?, updated_at = ? WHERE asset_id = ? AND deleted_at IS NULL')
|
||||
->execute([$now, $actor['id'], $now, $assetId]);
|
||||
record_event($db, 'asset.delete', ['type' => 'asset.delete', 'assetId' => $assetId, 'actorAccountId' => $actor['id']]);
|
||||
} elseif ($type === 'object.publish' || $type === 'object.move') {
|
||||
$kind = (($command['kind'] ?? '') === 'dynamic') ? 'dynamic' : 'static';
|
||||
$object = normalize_object(is_array($command['object'] ?? null) ? $command['object'] : [], $kind, $actor, $config);
|
||||
ensure_asset_owner($db, $object['assetId'], $actor['id']);
|
||||
if ($type === 'object.publish' && !publish_quota_available($db, $actor, $config)) fail('publish_limit_reached', 429);
|
||||
$activeCount = (int) $db->query("SELECT COUNT(*) AS c FROM objects WHERE deleted_at IS NULL AND moderation_status = 'active'")->fetch()['c'];
|
||||
if ($type === 'object.publish' && $activeCount >= (int) $config['max_world_objects']) fail('world_object_limit_reached', 409);
|
||||
$json = json_encode($object, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
$publishedAt = (int) ($object['publishedAt'] ?? $now);
|
||||
$db->prepare('INSERT INTO objects(id, kind, asset_id, owner_account_id, json, version, published_at, updated_at, deleted_at, deleted_by, moderation_status)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?)
|
||||
ON CONFLICT(id) DO UPDATE SET json=excluded.json, version=excluded.version, published_at=excluded.published_at, updated_at=excluded.updated_at, deleted_at=NULL, deleted_by=NULL, moderation_status=\'active\'')
|
||||
->execute([$object['id'], $kind, $object['assetId'], $actor['id'], $json, $object['version'], $publishedAt, $now, null, null, 'active']);
|
||||
record_event($db, 'object.upsert', ['type' => 'object.upsert', 'kind' => $kind, 'objectId' => $object['id'], 'actorAccountId' => $actor['id']]);
|
||||
} elseif ($type === 'object.delete') {
|
||||
$kind = (($command['kind'] ?? '') === 'dynamic') ? 'dynamic' : 'static';
|
||||
$objectId = clean_id($command['objectId'] ?? '');
|
||||
$stmt = $db->prepare('SELECT * FROM objects WHERE id = ? AND kind = ? AND deleted_at IS NULL');
|
||||
$stmt->execute([$objectId, $kind]);
|
||||
$obj = $stmt->fetch();
|
||||
if (!$obj) fail('object_not_found', 404);
|
||||
if ($obj['owner_account_id'] !== $actor['id']) fail('object_owner_required', 403);
|
||||
$db->prepare('UPDATE objects SET deleted_at = ?, deleted_by = ?, updated_at = ? WHERE id = ?')
|
||||
->execute([$now, $actor['id'], $now, $objectId]);
|
||||
record_event($db, 'object.delete', ['type' => 'object.delete', 'kind' => $kind, 'objectId' => $objectId, 'actorAccountId' => $actor['id']]);
|
||||
} elseif ($type === 'vote.asset' || $type === 'vote.object') {
|
||||
$targetType = $type === 'vote.asset' ? 'asset' : 'object';
|
||||
$targetId = clean_id($command[$targetType . 'Id'] ?? $command['targetId'] ?? '');
|
||||
$value = max(-1, min(1, (int) ($command['value'] ?? $command['delta'] ?? 0)));
|
||||
if ($targetId === '') fail('vote_target_required');
|
||||
if ($value === 0) {
|
||||
$db->prepare('DELETE FROM votes WHERE target_type = ? AND target_id = ? AND voter_account_id = ?')->execute([$targetType, $targetId, $actor['id']]);
|
||||
} else {
|
||||
$db->prepare('INSERT INTO votes(target_type, target_id, voter_account_id, value, updated_at) VALUES(?,?,?,?,?)
|
||||
ON CONFLICT(target_type, target_id, voter_account_id) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at')
|
||||
->execute([$targetType, $targetId, $actor['id'], $value, $now]);
|
||||
}
|
||||
record_event($db, 'vote.' . $targetType, ['type' => 'vote.' . $targetType, 'targetId' => $targetId, 'actorAccountId' => $actor['id'], 'value' => $value]);
|
||||
} elseif ($type === 'report.object') {
|
||||
$objectId = clean_id($command['objectId'] ?? '');
|
||||
$assetId = clean_id($command['assetId'] ?? '');
|
||||
$kind = (($command['objectKind'] ?? $command['kind'] ?? '') === 'dynamic') ? 'dynamic' : 'static';
|
||||
$reason = clean_text($command['reason'] ?? 'other', 'other', 48);
|
||||
if ($objectId === '' || $assetId === '') fail('report_target_required');
|
||||
$reportId = clean_id($command['reportId'] ?? $command['id'] ?? ('report_' . bin2hex(random_bytes(6))));
|
||||
$db->prepare('INSERT OR IGNORE INTO reports(id, object_id, asset_id, object_kind, reporter_account_id, reason, created_at, status) VALUES(?,?,?,?,?,?,?,?)')
|
||||
->execute([$reportId, $objectId, $assetId, $kind, $actor['id'], $reason, $now, 'open']);
|
||||
record_event($db, 'report.object', ['type' => 'report.object', 'objectId' => $objectId, 'assetId' => $assetId, 'actorAccountId' => $actor['id'], 'reason' => $reason]);
|
||||
} else {
|
||||
return ['ok' => false, 'id' => $cmdId, 'error' => 'unsupported_command'];
|
||||
}
|
||||
|
||||
$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];
|
||||
}
|
||||
|
||||
function vote_snapshot(PDO $db, string $targetType): array {
|
||||
$stmt = $db->prepare('SELECT target_id, voter_account_id, value FROM votes WHERE target_type = ? AND value != 0');
|
||||
$stmt->execute([$targetType]);
|
||||
$out = [];
|
||||
while ($row = $stmt->fetch()) {
|
||||
$id = $row['target_id'];
|
||||
$value = (int) $row['value'];
|
||||
if (!isset($out[$id])) $out[$id] = ['up' => 0, 'down' => 0, 'voters' => []];
|
||||
if ($value > 0) $out[$id]['up']++;
|
||||
if ($value < 0) $out[$id]['down']++;
|
||||
$out[$id]['voters'][$row['voter_account_id']] = $value;
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
function build_snapshot(PDO $db): array {
|
||||
$assets = [];
|
||||
foreach ($db->query('SELECT json FROM assets WHERE deleted_at IS NULL ORDER BY updated_at DESC LIMIT 2000') as $row) {
|
||||
$asset = decode_json_row($row['json']);
|
||||
if ($asset) $assets[] = $asset;
|
||||
}
|
||||
$placed = [];
|
||||
$dynamic = [];
|
||||
foreach ($db->query("SELECT kind, json FROM objects WHERE deleted_at IS NULL AND moderation_status = 'active' ORDER BY published_at DESC LIMIT 1000") as $row) {
|
||||
$obj = decode_json_row($row['json']);
|
||||
if (!$obj) continue;
|
||||
if ($row['kind'] === 'dynamic') $dynamic[] = $obj;
|
||||
else $placed[] = $obj;
|
||||
}
|
||||
$assetTombstones = [];
|
||||
foreach ($db->query('SELECT id, deleted_at, deleted_by, version FROM assets WHERE deleted_at IS NOT NULL') as $row) {
|
||||
$assetTombstones[$row['id']] = ['id' => $row['id'], 'deletedAt' => (int) $row['deleted_at'], 'deletedBy' => $row['deleted_by'], 'version' => (int) $row['version']];
|
||||
}
|
||||
$objectTombstones = [];
|
||||
foreach ($db->query('SELECT id, asset_id, deleted_at, deleted_by, version FROM objects WHERE deleted_at IS NOT NULL') as $row) {
|
||||
$objectTombstones[$row['id']] = ['id' => $row['id'], 'assetId' => $row['asset_id'], 'deletedAt' => (int) $row['deleted_at'], 'deletedBy' => $row['deleted_by'], 'version' => (int) $row['version']];
|
||||
}
|
||||
$reports = [];
|
||||
foreach ($db->query("SELECT * FROM reports WHERE status = 'open' ORDER BY created_at DESC LIMIT 500") as $row) {
|
||||
$reports[] = [
|
||||
'id' => $row['id'],
|
||||
'objectId' => $row['object_id'],
|
||||
'assetId' => $row['asset_id'],
|
||||
'objectKind' => $row['object_kind'],
|
||||
'reporter' => $row['reporter_account_id'],
|
||||
'reason' => $row['reason'],
|
||||
'createdAt' => (int) $row['created_at'],
|
||||
];
|
||||
}
|
||||
$last = $db->query('SELECT COALESCE(MAX(id), 0) AS id FROM events')->fetch();
|
||||
return [
|
||||
'ok' => true,
|
||||
'schema' => 1,
|
||||
'serverNow' => now_ms(),
|
||||
'lastEventId' => (int) ($last['id'] ?? 0),
|
||||
'authority' => ['publish' => 'server', 'objectMove' => 'server', 'dayNight' => 'local', 'dynamicMotion' => 'local'],
|
||||
'assets' => $assets,
|
||||
'placed' => $placed,
|
||||
'dynamicSummons' => $dynamic,
|
||||
'assetVotes' => vote_snapshot($db, 'asset'),
|
||||
'objectVotes' => vote_snapshot($db, 'object'),
|
||||
'moderationReports' => $reports,
|
||||
'tombstones' => ['assets' => $assetTombstones, 'objects' => $objectTombstones],
|
||||
];
|
||||
}
|
||||
|
||||
try {
|
||||
$action = $_GET['action'] ?? '';
|
||||
if ($action === '') {
|
||||
$path = trim((string) parse_url($_SERVER['REQUEST_URI'] ?? '', PHP_URL_PATH), '/');
|
||||
$action = basename($path) === 'index.php' ? '' : basename($path);
|
||||
}
|
||||
$database = db($config);
|
||||
|
||||
if ($action === 'health' || $action === '') {
|
||||
respond(['ok' => true, 'service' => 'pixel-island-api', 'serverNow' => now_ms(), 'sqlite' => true]);
|
||||
}
|
||||
|
||||
if ($action === 'snapshot') {
|
||||
respond(build_snapshot($database));
|
||||
}
|
||||
|
||||
if ($action === 'account') {
|
||||
$data = request_json((int) $config['max_json_bytes']);
|
||||
$account = authenticate($database, is_array($data['account'] ?? null) ? $data['account'] : $data, true);
|
||||
respond(['ok' => true, 'account' => ['id' => $account['id'], 'name' => $account['name'], 'createdAt' => (int) $account['created_at']]]);
|
||||
}
|
||||
|
||||
if ($action === 'commands') {
|
||||
$data = request_json((int) $config['max_json_bytes']);
|
||||
$actor = authenticate($database, is_array($data['account'] ?? null) ? $data['account'] : [], true);
|
||||
$commands = is_array($data['commands'] ?? null) ? $data['commands'] : [];
|
||||
if (count($commands) > 100) fail('too_many_commands', 413);
|
||||
$applied = [];
|
||||
$rejected = [];
|
||||
foreach ($commands as $command) {
|
||||
if (!is_array($command)) continue;
|
||||
try {
|
||||
$database->beginTransaction();
|
||||
$result = process_command($database, $command, $actor, $config);
|
||||
if ($result['ok'] ?? false) {
|
||||
$database->commit();
|
||||
$applied[] = $result['id'];
|
||||
} else {
|
||||
$database->rollBack();
|
||||
$rejected[] = $result;
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
if ($database->inTransaction()) $database->rollBack();
|
||||
$rejected[] = ['ok' => false, 'id' => clean_id($command['id'] ?? ''), 'error' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
respond(['ok' => true, 'appliedCommandIds' => $applied, 'rejectedCommands' => $rejected, 'snapshot' => build_snapshot($database)]);
|
||||
}
|
||||
|
||||
fail('unknown_action', 404);
|
||||
} catch (Throwable $e) {
|
||||
fail($e->getMessage(), 500);
|
||||
}
|
||||
|
|
@ -3,7 +3,7 @@
|
|||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Pixel Island Summoner - Local Prototype</title>
|
||||
<title>Pixel Island Summoner - Shared Test</title>
|
||||
<link rel="stylesheet" href="./styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
|
|
@ -19,7 +19,7 @@
|
|||
<header class="hud topHud islandTopBar">
|
||||
<div class="brandBlock">
|
||||
<div class="logo">Pixel Island</div>
|
||||
<div class="subline">A rotating island exhibition for tiny pixel works.</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">
|
||||
|
|
@ -34,7 +34,7 @@
|
|||
</label>
|
||||
</div>
|
||||
<button id="createAccount" class="miniButton" type="button">Generate account</button>
|
||||
<small id="accountNote">Publish creates a local account for island publishing.</small>
|
||||
<small id="accountNote">Publish creates an account for shared island publishing.</small>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
|
|
|||
61
styles.css
61
styles.css
|
|
@ -324,6 +324,8 @@ button.danger.active, .iconButton.danger.active { background: var(--danger); col
|
|||
|
||||
.drawerBody {
|
||||
overflow-y: auto;
|
||||
overflow-anchor: none;
|
||||
scroll-behavior: auto;
|
||||
padding: 8px;
|
||||
background:
|
||||
linear-gradient(45deg, rgba(255,255,255,.4) 25%, transparent 25%) 0 0/18px 18px,
|
||||
|
|
@ -472,7 +474,13 @@ button:disabled, .tool:disabled { opacity: .45; cursor: not-allowed; transform:
|
|||
.actionRow button { flex: 1; }
|
||||
.lineageNote { min-height: 18px; color: var(--muted); font-size: 12px; }
|
||||
|
||||
.assetList { display: grid; gap: 10px; }
|
||||
.assetList {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
align-content: start;
|
||||
grid-auto-rows: max-content;
|
||||
overflow-anchor: none;
|
||||
}
|
||||
.collectionToolbar {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
|
|
@ -909,8 +917,10 @@ body, button, input, select, textarea {
|
|||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
align-content: start;
|
||||
max-height: 360px;
|
||||
overflow-y: auto;
|
||||
overflow-anchor: none;
|
||||
padding-right: 4px;
|
||||
}
|
||||
.libraryEmptyNote {
|
||||
|
|
@ -2603,6 +2613,7 @@ body, button, input, select, textarea { font-size: 15px; }
|
|||
|
||||
.collectionViewTabs.tabs {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
align-items: start;
|
||||
border: 2px solid var(--line);
|
||||
box-shadow: 3px 3px 0 rgba(36,48,68,.12);
|
||||
margin: 2px 0 0;
|
||||
|
|
@ -2611,7 +2622,9 @@ body, button, input, select, textarea { font-size: 15px; }
|
|||
}
|
||||
|
||||
.collectionViewTab.tab {
|
||||
align-self: start;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
|
@ -2839,8 +2852,46 @@ body, button, input, select, textarea { font-size: 15px; }
|
|||
.studioDrawer { z-index: var(--drawer-z) !important; }
|
||||
}
|
||||
|
||||
/* Keep Collection list redraws from letting scroll anchoring snap the drawer to the top. */
|
||||
.drawerBody,
|
||||
.assetList {
|
||||
overflow-anchor: none;
|
||||
|
||||
/* Follow-up layout: let the palette consume the unused horizontal area below the editor and tool column. */
|
||||
@media (min-width: 761px) {
|
||||
#tab-draw .paintLayout {
|
||||
grid-template-columns: minmax(0, 1fr) clamp(220px, 18vw, 270px) !important;
|
||||
grid-template-rows: auto auto !important;
|
||||
align-items: start !important;
|
||||
}
|
||||
#tab-draw .paintMainColumn {
|
||||
display: contents !important;
|
||||
}
|
||||
#tab-draw .paintMainColumn #paintCanvas {
|
||||
grid-column: 1 !important;
|
||||
grid-row: 1 !important;
|
||||
width: 100% !important;
|
||||
max-width: none !important;
|
||||
justify-self: stretch !important;
|
||||
}
|
||||
#tab-draw .drawControlPanel {
|
||||
grid-column: 2 !important;
|
||||
grid-row: 1 !important;
|
||||
}
|
||||
#tab-draw .paintMainColumn .palettePanel {
|
||||
grid-column: 1 / -1 !important;
|
||||
grid-row: 2 !important;
|
||||
width: 100% !important;
|
||||
max-width: none !important;
|
||||
justify-self: stretch !important;
|
||||
padding: 6px !important;
|
||||
}
|
||||
#tab-draw .paintMainColumn .paletteGrid,
|
||||
.studioDrawer.advancedTools #tab-draw .paintMainColumn .paletteGrid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(28px, 1fr)) !important;
|
||||
gap: 4px !important;
|
||||
max-height: none !important;
|
||||
overflow: visible !important;
|
||||
}
|
||||
#tab-draw .paintMainColumn .paletteGrid button,
|
||||
#tab-draw .paintMainColumn .paletteSwatch {
|
||||
min-height: 28px !important;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue