master
ブランチ master
LittleBeast / README-JP.md

Little Beast

シンプル、実践的、アンチ・ブロート

GitHub repo sizelines count

サンプル:https://lbdemo.technicalsuwako.moe/

Little Beast とは?

Little Beast は PHP 8.5 以上向けのフレームワークで、076.moe(ゲーム開発会社)と technicalsuwako.moe(社長のブログ)向けに作られました。
メイン考え方は「必要な物だけ拡張し、不要な物は削除する」です。
各コア機能はライブラリに分割されている為、必要な物だけを選び易い設計になっています。
全てのモジュールはゼロから書かれており、データベースは一切必要ありません。

Little Beast はテクニカル諏訪子がゲーム開発に完全復帰する前の最後の Web プロジェクトです。

Little Beast が「ではない」物

  • 汎用フレームワーク
  • 使い憎い
  • Web 開発者向け
  • 万人向け
  • インストールが面倒
  • 教条的
  • 民主的に運営される

Little Beast が「持っていない」物

  • データベース(全てファイルベース)
  • 依存関係
  • パッケージマネージャー
  • Docker/Kubernetes/Vagrant/Nix 等のコンテナ
  • JavaScript
  • ブロートウェア
  • ORM
  • キューやバックグラウンドジョブ
  • クラウド/サーバーレス統合
  • スキャフォールディング
  • コード生成
  • 抽象化レイヤー
  • 不要なファイル
  • DEI、行動規範、その他の差別的慣行

独自機能

  • データベース不要
  • Composer や PEAR 不要
  • サーバーオーバーヘッドゼロ
  • コンテナ不要
  • Maron テンプレートエンジン
  • カスタム Markdown
  • ActivityPub
  • 画像ライブラリ(PNG・APNG・BMP・TGA・GIF・JPEG・WebP の読み書き。GD 等の拡張は不要)
  • 二段階認証(認証アプリのワンタイムパスワード、QR コード)
  • 多言語対応
  • マルチブログ対応
  • 100% 綺麗で正しい HTML5 と CSS3
  • 100% 正しい PHP
  • SEO フレンドリー
  • モジュラー CSS
  • Atom フィード
  • 組み込みテストスイート

インストール方法

cd /var/www/htdocs
git clone https://github.com/TechnicalSuwako/LittleBeast.git .
mv config/config.sample.php config/config.php

HTTP サーバーを設定して /public をルートとして php-fpm で実行して下さい。
それだけです!

OpenBSD サーバー

pkg_add php-8.5.5 php-gmp-8.5.5
rcctl enable php85_fpm httpd relayd
rcctl start php85_fpm httpd relayd

httpd

server "technicalsuwako.moe" {
  listen on * tls port 8443
  gzip-static
  tls {
    certificate "/etc/ssl/technicalsuwako.moe.crt"
    key "/etc/ssl/private/technicalsuwako.moe.key"
  }
  root "/htdocs/technicalsuwako.moe/www/public"
  directory index "index.php"
  location "/.well-known/acme-challenge/*" {
    root "/acme"
    request strip 2
  }

  location "/*.php" {
    fastcgi socket "/run/php-fpm.sock"
  }

  location "/*.php[/?]*" {
    fastcgi socket "/run/php-fpm.sock"
  }

  location "/" {
    directory index "index.php"
  }
  location match "/blog/" {
    request rewrite "/index.php"
  }
  location match "/about" {
    request rewrite "/index.php"
  }
  location match "/monero" {
    request rewrite "/index.php"
  }
  location match "/secret" {
    request rewrite "/index.php"
  }
  location match "/ap/" {
    request rewrite "/index.php"
  }
  location "/.well-known/webfinger" {
    request rewrite "/index.php"
  }
  location "*.atom" {
    request rewrite "/index.php"
  }
  location match "/comments" {
    request rewrite "/index.php"
  }
  location "/notifications" {
    request rewrite "/index.php"
  }
}

server "www.technicalsuwako.moe" {
  listen on * tls port 8443
  gzip-static
  tls {
    certificate "/etc/ssl/technicalsuwako.moe.crt"
    key "/etc/ssl/private/technicalsuwako.moe.key"
  }
  block return 301 "https://technicalsuwako.moe$REQUEST_URI"
}

relayd

relayd_addr="0.0.0.0"
router_addr="192.168.10.106"

table <httpd> { $router_addr }

http protocol reverse {
  tcp { nodelay, sack, socket buffer 65536, backlog 100 }
  tls ciphers "TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256:TLS_AES_128_GCM_SHA256"
  tls keypair "technicalsuwako.moe"

  return error

  match request header append "X-Forwarded-For" value "$REMOTE_ADDR"
  match request header append "X-Forwarded-Port" value "$REMOTE_PORT"

  #match response header set "Referrer-Policy" value "same-origin"
  match response header set "X-Frame-Options" value "deny"
  match response header set "X-Content-Type-Options" value "nosniff"
  match response header set "Referrer-Policy" value "strict-origin-when-cross-origin"
  match response header set "Strict-Transport-Security" value "max-age=31536000; includeSubDomains; preload"
  match response header set "Cross-Origin-Opener-Policy" value "same-origin"
  match response header set "Content-Security-Policy" value "img-src 'self' https://*.076.moe http://*.076.moe https://*.technicalsuwako.moe http://*.technicalsuwako.moe; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; object-src 'none';"
  match response header append "Permissions-Policy" value "accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=()"

  match request header "Accept-Encoding" value "gzip" tag "gzip"
  match response header "Content-Type" value "text/*" tag "compress"
  match response header "Content-Type" value "text/html" tag "charset=UTF-8"
  match response header "Content-Type" value "application/javascript" tag "compress"
  match response header "Content-Type" value "application/json" tag "compress"

  pass request quick header "Host" value "technicalsuwako.moe" forward to <httpd>

  pass
}

relay www_tls {
  listen on $relayd_addr port 443 tls
  protocol reverse

  # Default
  forward to <httpd> port 8443 check tcp
}

relay www_www {
  listen on $relayd_addr port 80
  protocol reverse

  # Default
  forward to <httpd> port 8080 check tcp
}

ブログと Atom フィード

1つのサイトに複数のブログを置けます。config/config.php の BLOGS の各ディレクトリ(例:blog、enblog)がブログになり、それぞれ /ディレクトリ.atom の Atom フィードがあります(一覧と検索結果のページの上にリンクがあります)。
BLOGS が無ければ、今まで通り blog だけです。

静的ファイル(ASS_DOMAIN)

config/config.php の ASS_DOMAIN で静的ファイル(画像、CSS、アイコン、アップロードしたファイル)の場所を決めます。無ければ /static です。

  • パス(例:/static)なら、このサイトの public/ の下のディレクトリです(/static は public/static/)。
  • URL(例:https://ass.example.com/static)なら、別のサーバー(サブドメイン等)です。アップロードしたファイルは今まで通り public/ の下の同じパス(public/static/)に保存するので、そのディレクトリを静的ファイルのサーバーと同期して下さい(又はそのサーバーから配信して下さい)。

テンプレートやコードでは、URLは ass_url('article/a.png')、ディスク上のファイルは ROOT.ass_path('article/a.png') を使います。

データベース(MySQL・MariaDB、PostgreSQL、SQLite)

Little Beast 自体はデータベースを使いませんが、必要な場合(既存のアプリの移植等)は Std\Lib\Db で繋げます。ドライバは PHP だけで書かれているので、mysqli・pdomysql・pgsql・pdopgsql・sqlite3・pdo_sqlite・sockets は不要です。
config/config.php で DB_ENABLED を true にして、DBINFO を設定して下さい(driver は mysql(MariaDB も同じ)、PostgreSQL の pgsql(ポートは普通5432)、又は sqlite(path にデータベースのファイル、相対パスはサイトのルートから、例:data/site.sqlite)。socket を指定すると host・port の代わりに UNIX ソケットを使う、PostgreSQL ではソケットのファイル又は .s.PGSQL.5432 が有るディレクトリ(例:/tmp)。tls は true、又は ['cafile' => '/etc/ssl/ca.pem'] 等の SSL コンテキストオプションの配列)。

$db = new \Std\Lib\Db();
$rows = $db->query('SELECT * FROM users WHERE id = ?', [1])['rows'];
$id = $db->query('INSERT INTO users (name) VALUES (?)', ['諏訪子'])['insertId']; // MySQL・SQLite
$id = $db->query('INSERT INTO users (name) VALUES (?) RETURNING id', ['諏訪子'])['rows'][0]['id']; // PostgreSQL
$db->transaction(function(\Std\Lib\Db $db): void {
  $db->query('UPDATE users SET points = points - ? WHERE id = ?', [100, 1]);
  $db->query('UPDATE users SET points = points + ? WHERE id = ?', [100, 2]);
});

  • 最初のクエリで接続します。パラメータ付きのクエリはプリペアドステートメントで実行するので、値をエスケープする必要はありません。
  • 整数の列は int、FLOAT・DOUBLE(PostgreSQL は real・double precision)は float、NULL は null、その他(DECIMAL・numeric、日付、JSON、BLOB 等)は文字列で返します。PostgreSQL の boolean は bool、bytea は生のバイト、配列は PHP の配列で返します。
  • エラーは Std\Lib\Db\DbException(getCode() は MySQL のエラーコード、例:重複は1062、sqlState は SQLSTATE、例:PostgreSQL の重複は23505)を投げ、log/db_log.txt に書きます。
  • MySQL の認証:mysql_native_password、caching_sha2_password(MySQL 8 の既定)と sha256_password(TLS でない TCP では、パスワードをサーバーの RSA の鍵で暗号化する)、mysql_clear_password(TLS か UNIX ソケットのみ)。MariaDB の client_ed25519、圧縮、LOAD DATA LOCAL INFILE は未対応です。
  • SQLite:Std\Lib\Db\Sqlite は SQLite 3 のファイルを自分で読み書きするので(ロールバックジャーナル、UTF-8)、sqlite3 のコマンド等でも開けます。途中で止まった書き込みは、次に開いた時にどちらでも元に戻ります。普通の SQL に対応しています:テーブル・インデックス・ビュー、ALTER TABLE(名前の変更、列の追加・削除・名前の変更)、INSERT(OR REPLACE・OR IGNORE、ON CONFLICT、RETURNING)、UPDATE … FROM、結合、GROUP BY と集約、サブクエリ、UNION・INTERSECT・EXCEPT、WITH、トランザクションとセーブポイント、普通の関数(文字列、数値、日付、JSON)、PRAGMA(table_info、index_list、user_version、integrity_check 等)。=・IN・範囲の検索にはインデックスを使います。WAL モード、トリガー、WITHOUT ROWID、仮想テーブル(FTS)、ウィンドウ関数、再帰の WITH、外部キーの確認、部分・式のインデックス、ATTACH は未対応です。ロックは flock() なので、サイトの動作中にファイルに書き込むのは PHP のプロセスだけにして下さい。プレースホルダは ?・?1・:name。UTF-8 でない文字列は BLOB として保存します(Std\Lib\Db\Sqlite\Blob で明示も可)。
  • PostgreSQL の認証:SCRAM-SHA-256(TLS ならチャネルバインディング付きの SCRAM-SHA-256-PLUS、オプションの channel_binding は prefer・require・disable)、MD5、password(allow_cleartext を true にしない限り、TLS か UNIX ソケットのみ)、trust。GSSAPI・SSPI は未対応です。
  • PostgreSQL:? のプレースホルダは $1、$2… にします(文字列・識別子・コメントの外のみ。? そのもの(jsonb の演算子等)は ?? と書く)。$1 もそのまま使えます。挿入した ID は無いので、RETURNING を使って下さい。トランザクションの中でエラーが起きた後の COMMIT は、PostgreSQL がロールバックするので DbException(SQLSTATE 25P02)を投げます。COPY、LISTEN の通知、実行中のクエリの取り消しは未対応です。PostgreSQL 16 で確認しました。
  • テストは偽物のサーバーを使います。PostgreSQL のテストを本物のサーバーでも実行するには、LB_TEST_PGSQL=pgsql://ユーザー:パスワード@ホスト:ポート/データベース を設定して下さい(TLS は ?cafile=/path/ca.pem を追加)。名前が lb_test_ で始まるテーブルを作って消します。

データベースの変換

Std\Lib\Db\Convert は、MySQL・MariaDB、PostgreSQL、SQLite、SQL のダンプのファイル、data/ の Little Beast の JSON のデータの間で、テーブルと行をどの方向にもコピーします。ドライバーと同じく PHP だけで書いてあります。

use Std\Lib\Db;
use Std\Lib\Db\Convert;
use Std\Lib\Db\Convert\{DbSource, DbTarget, DumpSource, DumpTarget, JsonSource, JsonTarget};

// data/(JSON)→ SQLite
$report = Convert::run(new JsonSource(ROOT.'/data'), new DbTarget(new Db(['driver' => 'sqlite', 'path' => 'data/site.sqlite'])));
// PostgreSQL → MySQL のダンプのファイル
Convert::run(new DbSource(new Db(['driver' => 'pgsql', 'host' => '127.0.0.1', 'port' => 5432, 'username' => 'lb', 'password' => '…', 'dbname' => 'lb'])),
             new DumpTarget('/tmp/lb.sql', 'mysql'));
// mysqldump・pg_dump のファイル → PostgreSQL(既に有るテーブルは置き換える)
Convert::run(new DumpSource('/tmp/dump.sql'), new DbTarget(new Db()), ['drop' => true]);

  • 読む元:DbSource(接続した Db)、DumpSource(mysqldump、pg_dump(プレーンの形式、COPY 又は --inserts)、sqlite3 .dump、DumpTarget で作った .sql のファイル(.sql.gz は PHP の zlib が必要)、方言は自動で判定)、JsonSource(データのディレクトリ)。書く先:DbTarget、DumpTarget(方言は mysql・pgsql・sqlite、mysql・psql・sqlite3 のコマンドで読み込めます)、JsonTarget。
  • オプション:tables(このテーブルだけ)、drop(書く先の同じ名前のテーブルを消す、無ければ既に有るテーブルはエラー)、progress(fn(string $table, int $rows))。結果は tables(テーブル毎のコピーした行数)と warnings(変換しなかった物)。
  • 列、主キー、ユニーク・普通のインデックス、NOT NULL、既定値、自動の番号(AUTO_INCREMENT、SERIAL・IDENTITY、SQLite の INTEGER PRIMARY KEY)を変換し、番号はコピーした行の続きから始まります。型は書く先の一番近い型にします(例:TINYINT(1) と boolean、JSON と jsonb、BLOB と bytea)。ENUM・SET は文字列、PostgreSQL の配列は JSON にします。時差の有る日時は UTC で保存します。外部キー、ビュー、トリガー、関数、全文・式のインデックスは変換しません(warnings に出ます)。
  • JSON のデータ:ユーザー、コメント、通知、Cron のジョブ、フォロワー、ActivityPub の投稿は名前が lb_ で始まるテーブルになり、他のファイル(cache/、ratelimit/、tmp/、ロック・一時ファイル、SQLite のデータベースを除く)は lb_files に入るので、戻すと同じファイルになります。他のテーブルは data/db/{テーブル}.json になります。data/ が空・無い場合は、テーブルが無いだけです。JsonTarget は、2番目の引数(上書き)が true か drop が無ければ、空でないディレクトリには書きません。
  • サイトが使っているデータベースを読む・書く場合は、先に書き込みを止めて下さい(又は新しいデータベースに変換)。変換中に元をロックしません。

Git

Std\Lib\Git は Git のリポジトリを PHP だけで読み書きします(PHP の zlib が必要、git のコマンドは不要)。SHA-1 と SHA-256 のリポジトリに対応しています。サイトでリポジトリをホストする為の基礎です。

use Std\Lib\Git\{Repository, Tree, Commit, Person, Diff, Blame};

$repo = Repository::init('/var/www/git/suwako/test.git', 'sha1', 'main', 'group'); // 又はRepository::open(...)
$me = Person::now('諏訪子', 'suwako@example.com');
$tree = $repo->updateTree(null, ['README.md' => [Tree::FILE, "# テスト\n"]]);
$id = $repo->writeCommit(Commit::create($tree, [], $me, $me, "最初のコミット\n"));
$repo->refs->update('refs/heads/main', $id, $repo->zeroId()); // 途中でブランチが変わっていれば失敗

foreach ($repo->log('main', ['path' => 'README.md', 'limit' => 20]) as $c) echo $c->id, ' ', $c->subject(), "\n";
echo $repo->blob($repo->resolve('main~1:README.md'));
echo Diff::unified($repo, 'main~1', 'main');
$lines = Blame::file($repo, 'main', 'README.md');

  • 保存:ルーズオブジェクト、パック(.idx v1・v2 と .rev、差分も解決)、参照と packed-refs、objects/info/alternates、core.sharedRepository。main~2、v1.0^{}、HEAD^2、main:パス/ファイル 等のリビジョンが使えます。Diff は git diff と同じ出力(Myers)、Blame は同じ行が有る最初の親を辿り、Maintenance::gc() は到達出来るオブジェクトを1つのパックにまとめます(Cron で実行)。
  • ネットワーク:Http::handle() は Smart HTTP(git clone https://…、git fetch、git push)、Ssh::handle() は SSH の git clone git@host:… と git push を処理します。プロトコル v0・v1・v2、浅いクローン(--depth、--deepen)、include-tag、サイドバンドの進捗、atomic なプッシュ、受け取ったオブジェクトの確認に対応しています。送るパックはディスクの差分を再利用します。アクセスの確認は呼ぶ側で行い、ReceivePack には check・update・after のコールバックを渡せます(保護されたブランチ等)。

// GET …/repo.git/info/refs、POST …/repo.git/git-upload-pack と git-receive-pack
Http::handle(Repository::open($dir), $action, ['push' => $userMayPush]);

// SSH:gitユーザーのauthorized_keys
// command="/usr/local/bin/php-8.5 /var/www/htdocs/gitssh.php suwako",no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty ssh-ed25519 AAAA…
exit(Ssh::handle((string)getenv('SSH_ORIGINAL_COMMAND'), fn(string $path, bool $write) => /* アクセスの確認 */ Repository::open('/var/www/git/'.$path)));

  • OpenBSD では php-fpm は /var/www に chroot されるので、リポジトリはその中に置き(例:git ユーザーのホームを /var/www/git に)、$shared = 'group' で作り、www を git グループに入れると、サイトと SSH の両方から書けます。SSH でプロトコル v2 を使うには、sshd_config に AcceptEnv GIT_PROTOCOL を追加して下さい。
  • 未対応:新しい差分の作成(ルーズで受け取ったオブジェクトは差分無しでパックにする)、シンパック、部分クローン(--filter)、--shallow-since・--shallow-exclude、git archive --remote、LFS、commit-graph・multi-pack-index の書き込み。

Gitler(Git のホスティング)

Gitler(/gitler)は上の Git のライブラリでサイトに Git のリポジトリをホストします:リポジトリのページ(ファイル、README、履歴、差分、blame、ブランチ、タグ、ZIP・TAR.GZ のダウンロード)、非公開のリポジトリ、共同作業者(読むだけ又は読み書き)、HTTPS と SSH のクローン・プッシュ。JavaScript は不要です。

  • config/config.php で GITLER_ENABLED を true(AUTH_ENABLED も必要)にして、GITINFO を設定して下さい:root はリポジトリのディレクトリ(php-fpm の chroot の中と外で同じ設定を使える様に、サイトのディレクトリからの相対パス。/var/www/git なら、サイトが /var/www/htdocs の場合は ../git、/var/www/htdocs/example.com/www の場合は ../../../git)、ssh はクローンの為に表示する SSH のアドレス(例:git@example.com、空なら HTTPS だけ)、sshPort、php は chroot の外の PHP のコマンド、objectFormat と branch(既定は master)は新しいリポジトリの形式とブランチ、maxRepos は1人の最大の数。
  • ロール:「Gitのリポジトリ」(自分のリポジトリを作る・管理する。管理者が付けるまで誰も持っていない)、「Gitのイシュー」(既定は全員)、「Gitのプルリクエスト」(既定は無し)。イシューとプルリクエストは今後。管理者は全部のリポジトリを見て変更出来ます。
  • HTTPS:git clone https://example.com/gitler/user/repo.git。公開のリポジトリはログイン無しでクローン出来ます。非公開のリポジトリとプッシュは、ユーザー名と /gitler/-/keys で作ったアクセストークン(読むだけ又は読み書き)を聞きます。二段階認証が無いユーザーはパスワードも使えます。プッシュ出来るのはブランチとタグだけです。OpenBSD の httpd が受け付けるリクエストの本文は既定で 1MB までなので、プッシュの為に server のブロックで増やして下さい(例:connection max request body 536870912)。
  • SSH:ユーザーは /gitler/-/keys で公開鍵を追加します。chroot の中にホームの有る git ユーザーを作り、www をそのグループに入れ、sshd が Gitler に鍵を聞く様にします:

useradd -m -d /var/www/git -s /bin/sh git
usermod -G git www   # php-fpmがリポジトリに書ける
usermod -G www git   # gitler.phpがconfig/config.phpとdata/を読める
chmod 2775 /var/www/git

# /etc/ssh/sshd_config
Match User git
	AuthorizedKeysCommand /usr/local/bin/php-8.5 /var/www/htdocs/gitler.php keys %u %t %k
	AuthorizedKeysCommandUser git
	AcceptEnv GIT_PROTOCOL
	PasswordAuthentication no

鍵毎に gitler.php serve <ユーザー> を実行するので、ユーザーが出来るのは git clone・git push(git@example.com:user/repo.git)だけです。AuthorizedKeysCommand を使わない場合は、php gitler.php authorized-keys > /var/www/git/.ssh/authorized_keys(例えば cron から)で同じ行を書けます。

  • リポジトリを整理する為に Cron のジョブ gitlergc を追加するか(例:php cron.php add "@daily" gitlergc)、php gitler.php gc を実行して下さい。
  • リポジトリのページには GitHub の様に言語の割合が出ます(バイト数で、ファイル名・拡張子・#! で決めます)。数えるのはプログラミングとマークアップの言語だけで、データ(JSON、YAML 等)、文章(Markdown 等)、外部のコード(vendor/、node_modules/ 等)、ドキュメント(docs/、examples/ 等)、生成されたファイルは数えません。ルートの .gitattributes の linguist-language、linguist-vendored、linguist-generated、linguist-documentation、linguist-detectable で変えられます(例:*.h linguist-language=C++)。結果はリポジトリの中の gitler-languages.json にツリー毎に保存し、一覧には既定のブランチの一番多い言語が出ます。
  • イシュー:ラベル、担当者、マイルストーン、Markdown の本文とコメント(「#3」は他のイシューへのリンク)。リポジトリを読めて「Gitのイシュー」のロールの有るユーザーが作ってコメント出来、書き込み権限の有る人がラベル等を付けて閉じます(作った人も閉じられます)。既定のブランチにプッシュしたコミットのメッセージの「fixes #3」「closes #3」「resolves #3」でイシューを閉じ、「#3」はイシューに記録します。設定でリポジトリ毎に無効に出来ます。データはリポジトリの中の gitler/ です。
  • Fediverse(マルチユーザーの ActivityPub が有効な場合):公開のリポジトリは @所有者.名前@ホスト のアカウントになり、Mastodon 等からフォローするとイシューとコメントが届き、返信するとコメント、リポジトリをメンションした投稿は新しいイシューになります。ForgeFed の Offer{Ticket} と Resolve にも対応します(イシューの Ticket は …/issues/3/ticket)。リポジトリの設定でオフにしたり、アカウントやドメインをブロック出来ます(サイト全体のドメインのブロックも効きます)。配信には cron の apdeliver を使い、SSH のプッシュ(git ユーザー)で閉じたイシューは gitler/spool/ に溜めて cron の notify が送ります。鍵の作成に GMP が必要です。
  • 通知:所有者と共同作業者は最初からリポジトリをウォッチし(新しいイシューと全部の動き)、他の人は作った・コメントした・担当の・メンション(@ユーザー)されたイシューの動きを受け取ります。リポジトリのページの「通知」でウォッチ・参加しているイシューだけ・無視を、イシューのページで受け取る・止めるを選べます。通知はサイトの「通知」に届き、MAILER_ENABLED ならメールでも届きます(/gitler/-/keys で止められます)。メールと SSH のプッシュの通知は cron の notify が送るので追加して下さい:php cron.php add "* * * * *" notify。
  • アーカイブ:リポジトリの設定でアーカイブすると読むだけになります(プッシュ、イシュー・コメント・ラベル・マイルストーンの変更、Fediverse からのコメントを止めます)。何時でも解除出来ます。
  • プルリクエスト(「Gitのプルリクエスト」のロールが必要):同じリポジトリのブランチ、フォーク(リポジトリのページの「フォークする」、「Gitのリポジトリ」のロールが必要)のブランチ、又はフォークしないで git push origin HEAD:refs/for/master/トピック(AGit、読めるだけのリポジトリにも。同じトピックにもう一度プッシュすると更新)から作れます。新しいブランチをプッシュすると、プルリクエストを作る URL が表示されます。会話、コミット、変更されたファイル(行番号を押すとその行にコメント、下書きをまとめてレビュー(コメント・承認・変更を依頼))が有り、書き込み権限の有る人がマージコミット・スカッシュ・リベース(出来れば fast-forward)でマージします(コンフリクトは PHP の3方向のマージで調べ、有ればローカルで解決)。本文の「fixes #3」でマージした時にイシューを閉じ、マージ先にプッシュで入ったプルリクエストはマージ済みになります。番号はイシューと同じで、変更は refs/pull/番号/head に有ります(git fetch origin refs/pull/3/head:pr-3)。リポジトリ毎に設定で無効に出来ます。

TLS 1.3・1.2(php_openssl は不要)

HTTPS のリクエスト(Curl、ActivityPub)、SMTP の STARTTLS(Mailer)、MySQL・PostgreSQL の TLS は PHP だけで書かれた TLS 1.3・1.2 のクライアント Std\Lib\Tls(php_gmp が必要)を使うので、php_openssl は不要です。RSA の鍵と署名(ActivityPub)は Std\Lib\Crypto\Rsa を使い、今までの PEM の鍵もそのまま使えます。

  • TLS 1.3 を優先し、TLS 1.2 にしか対応していないサーバーとは安全な暗号スイートだけで TLS 1.2 を使います(ECDHE と AES-GCM 又は ChaCha20-Poly1305、サーバーが対応していれば Extended Master Secret、再ネゴシエーションはしない)。CBC、RSA の鍵交換、DHE、TLS 1.1 以前は未対応です。TLS 1.3 に対応しているサーバーとの通信を攻撃者が TLS 1.2 に下げた場合は分かります。TLS 1.2 を拒否するには、オプションの 'min_version' => '1.3' を使って下さい。
  • 証明書は config/config.php の TLS_CAFILE(信頼する CA の PEM ファイル)で確認します。空なら、普通のシステムの場所を探します(OpenBSD は /etc/ssl/cert.pem)。httpd・php-fpm の chroot の中では /var/www/etc/ssl/cert.pem になるので、システムのファイルをそこにコピーして下さい。Windows にはこのファイルが無いので、TLS_CAFILE を設定して下さい(例:curl の cacert.pem)。
  • 暗号スイート:TLS 1.3 は TLS_CHACHA20_POLY1305_SHA256、TLS_AES_128_GCM_SHA256、TLS_AES_256_GCM_SHA384、TLS 1.2 は TLS_ECDHE_{ECDSA,RSA}_WITH_{CHACHA20_POLY1305_SHA256,AES_128_GCM_SHA256,AES_256_GCM_SHA384}。鍵交換:x25519、secp256r1、secp384r1。署名:RSA-PSS、ECDSA(P-256、P-384、P-521)、RSA PKCS#1 v1.5(証明書と TLS 1.2 のみ)。セッションの再開、0-RTT、クライアント証明書、失効の確認は未対応です。
  • 暗号化は PHP で計算するので、大きいファイルのダウンロードは openssl より遅いです(数 MB/秒)。

$s = \Std\Lib\Tls::connect('example.com', 443);
fwrite($s, "GET / HTTP/1.1\r\nHost: example.com\r\nConnection: close\r\n\r\n");
echo stream_get_contents($s);

コメント

config/config.php で COMMENTS_ENABLED を true にすると、全部のブログ記事の下にコメント欄が表示されます(書き込みには AUTH_ENABLED も必要)。

  • ログイン中のユーザーはコメント、返信、いいねが出来ます(Markdown:太字、斜体、取り消し線、コード、引用、リスト、リンク)。自分のコメントは削除出来、管理者とスタッフは全部のコメントを削除出来ます。JavaScript は不要です。
  • BLOGS で 'federate' => true のブログは FEDIINFO のアカウントが投稿し(apfederate)、その投稿(と返信)への Fediverse からの公開・未収載の返信がコメントになります。Fediverse での編集と削除も反映します。
  • 新しいコメントは全部の管理者に、自分のコメントへの返信といいねは本人に /notifications で通知します。
  • コメントは data/comments/、通知は data/notifications/ に保存します。

Cron

定期的なタスク(例えば、新しいブログ投稿を ActivityPub のフォロワーに送る apfederate)はシステムの cron で実行します。
config/config.php で CRON_ENABLED を true にして(ActivityPub を使う場合は FEDIINFO['domain'] も設定して)、php-fpm を実行するユーザーの crontab に以下の行を追加して下さい(crontab -u www -e):

* * * * * /usr/local/bin/php-8.5 /var/www/htdocs/cron.php

ジョブはコマンドライン(又は管理画面)で管理します:

php cron.php add "*/10 * * * *" apfederate
php cron.php list
php cron.php help

タスクは util.php の register_cron_tasks() で登録します(cron.php と管理画面で使います)。組み込みのタスク:

  • apfederate:新しいブログ投稿を ActivityPub のフォロワーに送る
  • apupdate:プロフィール(アクター)の更新を ActivityPub のフォロワーに送る
  • authpurge:7日以内にメールアドレスを確認しなかったアカウントを削除する(AUTH_VERIFY_EMAIL が有効の場合)
  • apdeliver:マイクロブログのアクティビティを他のサーバーに配信する(マルチユーザーモード、毎分実行)
  • apcleanup:90日より古いリモートの投稿とキャッシュを削除する(マルチユーザーモード、1日1回実行)
  • notify:通知のメールを送り、SSH のプッシュで Gitler のリポジトリに溜めた通知と配信を処理する(AUTH_ENABLED が有効の場合、毎分実行)
  • gitlergc:Gitler のリポジトリを整理する(GITLER_ENABLED が有効の場合、1日1回実行)

マイクロブログ(マルチユーザーの Fediverse)

既定では ActivityPub はシングルユーザーで、ブログのアクター(/ap/actor)が新しいブログ投稿をフォロワーに送ります。
FEDIINFO['isSingleUser'] を false にすると(AUTH_ENABLED と FEDIINFO['domain'] も必要)マルチユーザーモードになります:

  • ブログのアクターは今まで通り動きます。
  • Fediverse のロールを持つユーザーはそれぞれアクター(@ユーザー名@ドメイン)になります。ロールは BAN されたユーザー以外の全員に付与され、管理者はユーザーのプロフィールの編集画面で外せます。
  • /microblog にはホーム・ローカル・連合のタイムライン、投稿(注意書き、公開範囲、画像・動画4つまで)、返信、いいね、ブースト、フォロー(承認制も可)、ブロック、通報、検索(@user@host、投稿の URL、#タグ、文字)があります。JavaScript は不要です。
  • 管理者は /microblog/admin でサーバーごとブロックしたり、通報を確認したり出来ます。

配信は待ち行列に入るので、cron のジョブを追加して下さい:

php cron.php add "* * * * *" apdeliver
php cron.php add "@daily" apcleanup

アップロード出来るのは画像(PNG・JPEG・GIF・WebP、20 MiB まで)と動画(MP4・MKV・WebM、400 MiB まで)で、メタデータ(Exif、XMP、テキスト、MP4 の撮影日時・位置等)を取り除きます。動画は変換しないので、ブラウザで再生出来る形式(H.264 等)でアップロードして下さい。
大きいファイルを受け付ける様に、PHP の upload_max_filesize と post_max_size(添付4つ分)、httpd の connection max request body(既定は1 MiB、超えると「413 Payload Too Large」)も設定して下さい。動画を再生出来る様に、httpd の types に include "/usr/share/misc/mime.types" もお勧めします。他のサーバーのアイコンや画像は /microblog/media 経由で表示するので、CSP の img-src は 'self' のままで構いません(閲覧者の IP アドレスも相手のサーバーに渡りません)。画像は data/ap/remote/media/ に1週間キャッシュします。
ユーザーの鍵(RSA、2048ビット)は初めて使う時に作ります。

二段階認証

config/config.php で AUTH_2FA_ENABLED を true にすると、各ユーザーがプロフィールの編集の「二段階認証」から有効に出来ます。false なら誰も使えず、有効にしていたユーザーもパスワードだけでログインします。
設定画面には QR コードと秘密鍵(PC のパスワードマネージャー用)が表示されるので、認証アプリ(Aegis、Google Authenticator、KeePassXC 等)に登録して、表示された6桁のコードを入力すると有効になります。以後はパスワードの後にコードを入力します(TOTP、SHA-1、6桁、30秒。同じコードは2度使えません)。
端末を失くした場合は、管理者がそのユーザーの設定画面(/profile/ユーザー名/2fa)で自分のパスワードを入力すると無効に出来ます。

管理画面

config/config.php で ADMIN_ENABLED を true にすると(AUTH_ENABLED も必要)、管理者のロールを持つユーザーのメニューに「管理」が出て、/admin を開けます:

  • ユーザー:アカウントの一覧と検索、作成(登録が無効でも、メールアドレスの確認なし)、ロールの変更(全部外すと BAN)、メールアドレスを確認済みにする、全部のログインの取り消し、メールアドレス・パスワードの変更と二段階認証の無効化(この3つは自分のパスワードが必要)。表示名・アイコン等は今まで通りプロフィールの編集で変更します。
  • ログ:log/ のファイル。最後の行、検索、ダウンロード、空にする、削除。
  • Cron:ジョブの追加、変更、有効・無効、削除、今すぐ実行と、システムの cron の前回の実行。
  • キャッシュ:data/cache/ を種類毎に表示・削除(1件、種類毎、全部)と、Fediverse のキャッシュ(リモートのアクター、WebFinger、画像)の削除。
  • データベースの変換:上の変換をフォームで。読む元・書く先は JSON のデータ(data/)、SQLite のファイル、MySQL・MariaDB 又は PostgreSQL のサーバー、SQL のダンプ(アップロード又はサーバーのファイル、書く先はダウンロード又はサーバーに保存)、サイトのデータベース(DBINFO)。パスはサイトのディレクトリからの相対パスで、その外は使えません。アップロードしたファイルと変換したファイルは data/tmp/convert/ に1時間残ります。大きなファイルをアップロードするには、php.ini の upload_max_filesize と post_max_size を大きくして下さい。

新しいサイトには管理者が居なく、data/ が空の場合も有ります。ADMIN_SETUP_CODE に長いランダムな文字列(例:openssl rand -hex 16 の出力)を設定して /admin を開くと /admin/setup に移動するので、そのコードで最初の管理者を作ってログインします。その後 ADMIN_SETUP_CODE を '' に戻して下さい。管理者が居ればこのページは使えません。

必要な PHP モジュール

  • php_fileinfo(OpenBSDでデフォルトで有効している)
  • php_gmp
  • php_mbstring(OpenBSDでデフォルトで有効している)
  • php_zlib(Git、普通はPHPに組み込まれている)