スレッドとコメント) * lb_notifications data/notifications/{ユーザー名}.json(seqは順番) * lb_cron data/cron/cron.json(seqは順番) * lb_followers data/followers.txt(ブログのアクターのフォロワー) * lb_following data/following.txt * lb_ap_notes data/ap/notes/{ID}.json(dataにファイルの中身) * lb_files その他のファイル(パスと中身、キャッシュ・ロック・一時ファイルを除く) * 他のテーブルはdata/db/{テーブル名}.jsonになる(定義と行)。 */ final class Stores { public const JSON_FLAGS = JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT; /** 変換しないディレクトリ・ファイル(data/からの相対パス) */ public const SKIP = ['#^cache/#', '#^ratelimit/#', '#^tmp/#', '#^db/#', '#\.tmp$#', '#\.lock$#', '#^cron/run\.lock$#', '#(^|/)\.#']; /** テーブルの定義 */ public static function tables(): array { $text = fn(string $n, bool $notNull = false, ?int $size = null) => ['name' => $n, 'type' => $size ? 'varchar' : 'text', 'size' => $size, 'notNull' => $notNull]; $int = fn(string $n, bool $notNull = true) => ['name' => $n, 'type' => 'int', 'size' => 64, 'notNull' => $notNull]; $json = fn(string $n = 'data') => ['name' => $n, 'type' => 'json', 'notNull' => true]; return [ 'lb_users' => ['name' => 'lb_users', 'columns' => [$int('id'), $text('username', true, 255), $text('email'), $int('role', false), $json()], 'pk' => ['id'], 'indexes' => [['name' => 'lb_users_username', 'columns' => ['username'], 'unique' => true]]], 'lb_comment_threads' => ['name' => 'lb_comment_threads', 'columns' => [$text('thread', true, 255), $json()], 'pk' => ['thread'], 'indexes' => []], 'lb_comments' => ['name' => 'lb_comments', 'columns' => [$text('thread', true, 255), $text('id', true, 64), $int('seq'), $json()], 'pk' => ['thread', 'id'], 'indexes' => []], 'lb_comment_ap' => ['name' => 'lb_comment_ap', 'columns' => [$text('ap_id', true, 700), $json()], 'pk' => ['ap_id'], 'indexes' => []], 'lb_notifications' => ['name' => 'lb_notifications', 'columns' => [$text('username', true, 255), $int('seq'), $json()], 'pk' => ['username', 'seq'], 'indexes' => []], 'lb_cron' => ['name' => 'lb_cron', 'columns' => [$int('seq'), $json()], 'pk' => ['seq'], 'indexes' => []], 'lb_followers' => ['name' => 'lb_followers', 'columns' => [$int('seq'), $text('actor', true)], 'pk' => ['seq'], 'indexes' => []], 'lb_following' => ['name' => 'lb_following', 'columns' => [$int('seq'), $text('actor', true)], 'pk' => ['seq'], 'indexes' => []], 'lb_ap_notes' => ['name' => 'lb_ap_notes', 'columns' => [$text('id', true, 255), $json()], 'pk' => ['id'], 'indexes' => []], 'lb_files' => ['name' => 'lb_files', 'columns' => [$text('path', true, 700), ['name' => 'content', 'type' => 'blob', 'notNull' => true]], 'pk' => ['path'], 'indexes' => []], ]; } /** データのディレクトリからの相対パスが安全か(「..」・絶対パス・制御文字が無い) */ public static function safePath(string $rel): bool { if ($rel === '' || $rel[0] === '/' || str_contains($rel, '\\') || preg_match('/[\x00-\x1F]/', $rel) || preg_match('/^[A-Za-z]:/', $rel)) return false; foreach (explode('/', $rel) as $part) if ($part === '' || $part === '.' || $part === '..') return false; return true; } /** 名前(ユーザー名等)が安全か */ public static function safeName(string $n): bool { return (bool)preg_match('/^[A-Za-z0-9_][A-Za-z0-9_.-]*$/', $n) && !str_contains($n, '..'); } /** スレッドの名前(Comments::validThreadと同じ) */ public static function safeThread(string $t): bool { return (bool)preg_match('#^[A-Za-z0-9_-]+(?:/[A-Za-z0-9_-][A-Za-z0-9._-]*){1,2}$#', $t); } /** 普通のテーブルのファイル名 */ public static function tableFile(string $name): string { return 'db/'.rawurlencode($name).'.json'; } }