| 1 | # Little Beast |
| 2 | Simple, Pragmatic, Anti-bloat |
| 3 | |
| 4 |  |
| 5 | [](https://ghloc.vercel.app/TechnicalSuwako/LittleBeast?filter=.php%24%2C.maron%24%2C.css%24) |
| 6 | |
| 7 | Demo installation: [https://lbdemo.technicalsuwako.moe/](https://lbdemo.technicalsuwako.moe/) |
| 8 | |
| 9 | ## What is Little Beast? |
| 10 | Little Beast is a PHP 8.5 or above framework made for 076.moe (game developer company) and technicalsuwako.moe (CEO's blog).\ |
| 11 | The core mentality is to extend what you need, and remove what you don't need.\ |
| 12 | Each core feature is split into libraries, so it is easy to choose what you need.\ |
| 13 | All modules are written from scratch, nothing is to be require a database. |
| 14 | |
| 15 | Little Beast is TechnicalSuwako's final web project before moving back to game development completely. |
| 16 | |
| 17 | ## What Little Beast is not? |
| 18 | * General purpose framework |
| 19 | * Hard to use |
| 20 | * Meant for web developers |
| 21 | * For everyone/wider audience |
| 22 | * Tedious to install |
| 23 | * Dogmatic |
| 24 | * Democratically run |
| 25 | |
| 26 | ## What Little Beast has not? |
| 27 | * Database (everything is a file) |
| 28 | * Dependencies |
| 29 | * Package manager |
| 30 | * Docker/Kubernetes/Vagrant/Nix/etc containers |
| 31 | * Javascript |
| 32 | * Bloatware |
| 33 | * ORM |
| 34 | * Queues or background job |
| 35 | * Cloud/Serverless integration |
| 36 | * Scaffolding |
| 37 | * Code generation |
| 38 | * Abstraction layers |
| 39 | * Unnecessary files |
| 40 | * DEI, Code of Conduct, or other discriminatory practises |
| 41 | |
| 42 | ## Unique features |
| 43 | * No database |
| 44 | * No Composer or PEAR |
| 45 | * No server overhead |
| 46 | * No container |
| 47 | * Maron template engine |
| 48 | * Custom Markdown |
| 49 | * ActivityPub |
| 50 | * Multi-language support |
| 51 | * Multi-blog support |
| 52 | * 100% correct and beautiful HTML5 and CSS3 |
| 53 | * 100% correct PHP |
| 54 | * SEO friendly |
| 55 | * Modular CSS |
| 56 | * Atom feeds |
| 57 | * Built-in test suite |
| 58 | |
| 59 | ## How to install |
| 60 | ```sh |
| 61 | cd /var/www/htdocs |
| 62 | git clone https://gitler.moe/suwako/LittleBeast.git . |
| 63 | mv config/config.sample.php config/config.php |
| 64 | ``` |
| 65 | |
| 66 | Configure HTTP server to run `/public` as root via php-fpm.\ |
| 67 | That's all! |
| 68 | |
| 69 | ### OpenBSD server |
| 70 | ```sh |
| 71 | pkg_add php-8.5.5 php-gmp-8.5.5 |
| 72 | rcctl enable php85_fpm httpd relayd |
| 73 | rcctl start php85_fpm httpd relayd |
| 74 | ``` |
| 75 | |
| 76 | #### httpd |
| 77 | ``` |
| 78 | server "technicalsuwako.moe" { |
| 79 | listen on * tls port 8443 |
| 80 | gzip-static |
| 81 | tls { |
| 82 | certificate "/etc/ssl/technicalsuwako.moe.crt" |
| 83 | key "/etc/ssl/private/technicalsuwako.moe.key" |
| 84 | } |
| 85 | root "/htdocs/technicalsuwako.moe/www/public" |
| 86 | directory index "index.php" |
| 87 | location "/.well-known/acme-challenge/*" { |
| 88 | root "/acme" |
| 89 | request strip 2 |
| 90 | } |
| 91 | |
| 92 | location "/*.php" { |
| 93 | fastcgi socket "/run/php-fpm.sock" |
| 94 | } |
| 95 | |
| 96 | location "/*.php[/?]*" { |
| 97 | fastcgi socket "/run/php-fpm.sock" |
| 98 | } |
| 99 | |
| 100 | location "/" { |
| 101 | directory index "index.php" |
| 102 | } |
| 103 | location match "/blog/" { |
| 104 | request rewrite "/index.php" |
| 105 | } |
| 106 | location match "/about" { |
| 107 | request rewrite "/index.php" |
| 108 | } |
| 109 | location match "/monero" { |
| 110 | request rewrite "/index.php" |
| 111 | } |
| 112 | location match "/secret" { |
| 113 | request rewrite "/index.php" |
| 114 | } |
| 115 | location match "/ap/" { |
| 116 | request rewrite "/index.php" |
| 117 | } |
| 118 | location "/.well-known/webfinger" { |
| 119 | request rewrite "/index.php" |
| 120 | } |
| 121 | location "*.atom" { |
| 122 | request rewrite "/index.php" |
| 123 | } |
| 124 | location match "/comments" { |
| 125 | request rewrite "/index.php" |
| 126 | } |
| 127 | location "/notifications" { |
| 128 | request rewrite "/index.php" |
| 129 | } |
| 130 | } |
| 131 | |
| 132 | server "www.technicalsuwako.moe" { |
| 133 | listen on * tls port 8443 |
| 134 | gzip-static |
| 135 | tls { |
| 136 | certificate "/etc/ssl/technicalsuwako.moe.crt" |
| 137 | key "/etc/ssl/private/technicalsuwako.moe.key" |
| 138 | } |
| 139 | block return 301 "https://technicalsuwako.moe$REQUEST_URI" |
| 140 | } |
| 141 | ``` |
| 142 | |
| 143 | #### relayd |
| 144 | ``` |
| 145 | relayd_addr="0.0.0.0" |
| 146 | router_addr="192.168.10.106" |
| 147 | |
| 148 | table <httpd> { $router_addr } |
| 149 | |
| 150 | http protocol reverse { |
| 151 | tcp { nodelay, sack, socket buffer 65536, backlog 100 } |
| 152 | tls ciphers "TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256:TLS_AES_128_GCM_SHA256" |
| 153 | tls keypair "technicalsuwako.moe" |
| 154 | |
| 155 | return error |
| 156 | |
| 157 | match request header append "X-Forwarded-For" value "$REMOTE_ADDR" |
| 158 | match request header append "X-Forwarded-Port" value "$REMOTE_PORT" |
| 159 | |
| 160 | #match response header set "Referrer-Policy" value "same-origin" |
| 161 | match response header set "X-Frame-Options" value "deny" |
| 162 | match response header set "X-Content-Type-Options" value "nosniff" |
| 163 | match response header set "Referrer-Policy" value "strict-origin-when-cross-origin" |
| 164 | match response header set "Strict-Transport-Security" value "max-age=31536000; includeSubDomains; preload" |
| 165 | match response header set "Cross-Origin-Opener-Policy" value "same-origin" |
| 166 | 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';" |
| 167 | match response header append "Permissions-Policy" value "accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=()" |
| 168 | |
| 169 | match request header "Accept-Encoding" value "gzip" tag "gzip" |
| 170 | match response header "Content-Type" value "text/*" tag "compress" |
| 171 | match response header "Content-Type" value "text/html" tag "charset=UTF-8" |
| 172 | match response header "Content-Type" value "application/javascript" tag "compress" |
| 173 | match response header "Content-Type" value "application/json" tag "compress" |
| 174 | |
| 175 | pass request quick header "Host" value "technicalsuwako.moe" forward to <httpd> |
| 176 | |
| 177 | pass |
| 178 | } |
| 179 | |
| 180 | relay www_tls { |
| 181 | listen on $relayd_addr port 443 tls |
| 182 | protocol reverse |
| 183 | |
| 184 | # Default |
| 185 | forward to <httpd> port 8443 check tcp |
| 186 | } |
| 187 | |
| 188 | relay www_www { |
| 189 | listen on $relayd_addr port 80 |
| 190 | protocol reverse |
| 191 | |
| 192 | # Default |
| 193 | forward to <httpd> port 8080 check tcp |
| 194 | } |
| 195 | ``` |
| 196 | |
| 197 | ### Blogs and Atom feeds |
| 198 | A site can have more than one blog: every directory in `BLOGS` in `config/config.php` (for example `blog`, `enblog`) is a blog with its own Atom feed at `/directory.atom`, linked from the top of its index and search result pages.\ |
| 199 | Without `BLOGS`, only `blog` exists, as before. |
| 200 | |
| 201 | ### Static files (ASS_DOMAIN) |
| 202 | `ASS_DOMAIN` in `config/config.php` sets where static files (images, CSS, avatars, uploads) are served from. Without it, it is `/static`. |
| 203 | * A path (for example `/static`) is a directory under `public/` of this site: `/static` is `public/static/`. |
| 204 | * A URL (for example `https://ass.example.com/static`) points to another server, such as a subdomain. Uploaded files are still saved under `public/` with the same path (`public/static/`), so sync that directory to the asset server (or serve it from there). |
| 205 | |
| 206 | In templates and code, use `ass_url('article/a.png')` for the URL and `ROOT.ass_path('article/a.png')` for the file on disk. |
| 207 | |
| 208 | ### Database (MySQL/MariaDB, PostgreSQL, SQLite) |
| 209 | Little Beast itself needs no database, but `Std\Lib\Db` can talk to one when a site has to (for example, when porting an existing application). The drivers are written in pure PHP, so mysqli, pdo_mysql, pgsql, pdo_pgsql, sqlite3, pdo_sqlite and sockets are not needed.\ |
| 210 | Set `DB_ENABLED` to `true` and fill in `DBINFO` in `config/config.php` (`driver` is `mysql`, which also works with MariaDB, `pgsql` for PostgreSQL, whose port is usually 5432, or `sqlite` with `path` set to the database file, relative to the site root, for example `data/site.sqlite`; `socket` uses a UNIX socket instead of `host` and `port`, for PostgreSQL either the socket file or the directory that has `.s.PGSQL.5432`, such as `/tmp`; `tls` is `true` or an array of SSL context options such as `['cafile' => '/etc/ssl/ca.pem']`). |
| 211 | ```php |
| 212 | $db = new \Std\Lib\Db(); |
| 213 | $rows = $db->query('SELECT * FROM users WHERE id = ?', [1])['rows']; |
| 214 | $id = $db->query('INSERT INTO users (name) VALUES (?)', ['諏訪子'])['insertId']; // MySQL, SQLite |
| 215 | $id = $db->query('INSERT INTO users (name) VALUES (?) RETURNING id', ['諏訪子'])['rows'][0]['id']; // PostgreSQL |
| 216 | $db->transaction(function(\Std\Lib\Db $db): void { |
| 217 | $db->query('UPDATE users SET points = points - ? WHERE id = ?', [100, 1]); |
| 218 | $db->query('UPDATE users SET points = points + ? WHERE id = ?', [100, 2]); |
| 219 | }); |
| 220 | ``` |
| 221 | * The connection is opened on the first query. Queries with parameters use prepared statements, so values never need escaping. |
| 222 | * Integer columns come back as `int`, `FLOAT`/`DOUBLE` (PostgreSQL: `real`/`double precision`) as `float`, `NULL` as `null`, and everything else (including `DECIMAL`/`numeric`, dates, JSON and `BLOB`) as strings. PostgreSQL's `boolean` comes back as `bool`, `bytea` as the raw bytes and arrays as PHP arrays. |
| 223 | * Errors throw `Std\Lib\Db\DbException` (`getCode()` is MySQL's error code, for example 1062 for a duplicate key, and `sqlState` the SQLSTATE, for example 23505 for a duplicate key in PostgreSQL) and are written to `log/db_log.txt`. |
| 224 | * MySQL authentication: `mysql_native_password`, `caching_sha2_password` (the MySQL 8 default) and `sha256_password` (over plain TCP the password is encrypted with the server's RSA key), and `mysql_clear_password` (TLS or UNIX socket only). MariaDB's `client_ed25519`, compression and `LOAD DATA LOCAL INFILE` are not supported. |
| 225 | * SQLite: `Std\Lib\Db\Sqlite` reads and writes SQLite 3 files itself (rollback journal, UTF-8), so they can also be opened with the `sqlite3` tool, and a write that was interrupted is rolled back the next time the file is opened, by either. It supports the common SQL: tables, indexes and views, `ALTER TABLE` (rename, add, drop and rename columns), `INSERT` with `OR REPLACE`/`OR IGNORE`, `ON CONFLICT` and `RETURNING`, `UPDATE … FROM`, joins, `GROUP BY` and aggregates, subqueries, `UNION`/`INTERSECT`/`EXCEPT`, `WITH`, transactions and savepoints, the usual functions (text, math, date and JSON), and `PRAGMA` such as `table_info`, `index_list`, `user_version` and `integrity_check`. Indexes are used for `=`, `IN` and ranges. WAL mode, triggers, `WITHOUT ROWID`, virtual tables (FTS), window functions, recursive `WITH`, foreign key enforcement, partial and expression indexes and `ATTACH` are not supported. Locking uses `flock()`, so only let PHP processes write to the file while the site is running. `?`, `?1` and `:name` placeholders work; strings that are not valid UTF-8 are stored as BLOBs (or pass a `Std\Lib\Db\Sqlite\Blob`). |
| 226 | * PostgreSQL authentication: SCRAM-SHA-256 (over TLS with channel binding, SCRAM-SHA-256-PLUS; the option `channel_binding` is `prefer`, `require` or `disable`), MD5, `password` (TLS or UNIX socket only, unless `allow_cleartext` is `true`) and `trust`. GSSAPI and SSPI are not supported. |
| 227 | * PostgreSQL: `?` placeholders become `$1`, `$2`… (outside strings, identifiers and comments; write `??` for a literal `?`, such as the jsonb operator), and `$1` can be used directly too. There is no insert ID, so use `RETURNING`. A `COMMIT` after an error in the transaction throws `DbException` (SQLSTATE 25P02), because PostgreSQL rolls the transaction back. `COPY`, `LISTEN` notifications and cancelling a running query are not supported. Tested with PostgreSQL 16. |
| 228 | * The tests use a mock server. To run the PostgreSQL tests against a real server as well, set `LB_TEST_PGSQL=pgsql://user:password@host:port/database` (add `?cafile=/path/ca.pem` for TLS); they create and drop tables whose names start with `lb_test_`. |
| 229 | |
| 230 | ### Converting databases |
| 231 | `Std\Lib\Db\Convert` copies tables and rows between MySQL/MariaDB, PostgreSQL, SQLite, SQL dump files and Little Beast's own JSON data in `data/`, in any direction. It is pure PHP, like the drivers. |
| 232 | ```php |
| 233 | use Std\Lib\Db; |
| 234 | use Std\Lib\Db\Convert; |
| 235 | use Std\Lib\Db\Convert\{DbSource, DbTarget, DumpSource, DumpTarget, JsonSource, JsonTarget}; |
| 236 | |
| 237 | // data/ (JSON) → SQLite |
| 238 | $report = Convert::run(new JsonSource(ROOT.'/data'), new DbTarget(new Db(['driver' => 'sqlite', 'path' => 'data/site.sqlite']))); |
| 239 | // PostgreSQL → MySQL dump file |
| 240 | Convert::run(new DbSource(new Db(['driver' => 'pgsql', 'host' => '127.0.0.1', 'port' => 5432, 'username' => 'lb', 'password' => '…', 'dbname' => 'lb'])), |
| 241 | new DumpTarget('/tmp/lb.sql', 'mysql')); |
| 242 | // mysqldump or pg_dump file → PostgreSQL, replacing tables that already exist |
| 243 | Convert::run(new DumpSource('/tmp/dump.sql'), new DbTarget(new Db()), ['drop' => true]); |
| 244 | ``` |
| 245 | * Sources: `DbSource` (a connected `Db`), `DumpSource` (a `.sql` file, or `.sql.gz` with PHP's zlib, from `mysqldump`, `pg_dump` (plain format, `COPY` or `--inserts`), `sqlite3 .dump` or `DumpTarget`; the dialect is detected) and `JsonSource` (a data directory). Targets: `DbTarget`, `DumpTarget` (dialect `mysql`, `pgsql` or `sqlite`; the file can be loaded with `mysql`, `psql` or `sqlite3`) and `JsonTarget`. |
| 246 | * Options: `tables` (only these tables), `drop` (drop tables with the same name in the target; without it, a table that already exists is an error) and `progress` (`fn(string $table, int $rows)`). The result has `tables` (rows copied per table) and `warnings` (things that were not converted). |
| 247 | * Columns, primary keys, unique and plain indexes, `NOT NULL`, defaults and auto increment (`AUTO_INCREMENT`, `SERIAL`/identity, SQLite's `INTEGER PRIMARY KEY`) are converted, and the next number continues after the copied rows. Types are mapped to the nearest type of the target (for example `TINYINT(1)` and `boolean`, `JSON` and `jsonb`, `BLOB` and `bytea`); `ENUM`/`SET` become text and PostgreSQL arrays become JSON. Times with a time zone are stored in UTC. Foreign keys, views, triggers, functions, full-text and expression indexes are not converted (they are listed in `warnings`). |
| 248 | * JSON data: users, comments, notifications, Cron jobs, followers and ActivityPub notes become tables whose names start with `lb_`, and every other file (except `cache/`, `ratelimit/`, `tmp/`, lock and temporary files and SQLite databases) goes into `lb_files`, so converting back gives the same files. Other tables become `data/db/{table}.json`. An empty or missing `data/` directory simply has no tables. `JsonTarget` refuses to write into a directory that is not empty unless its second argument (overwrite) is `true` or `drop` is set. |
| 249 | * When the source or target is the database the site is using, stop writes to it first (or convert into a new database), as the conversion does not lock the source. |
| 250 | |
| 251 | ### Git |
| 252 | `Std\Lib\Git` reads and writes Git repositories in pure PHP (it needs PHP's zlib, but no `git` command), for both SHA-1 and SHA-256 repositories. It is the base for hosting repositories on the site. |
| 253 | ```php |
| 254 | use Std\Lib\Git\{Repository, Tree, Commit, Person, Diff, Blame}; |
| 255 | |
| 256 | $repo = Repository::init('/var/www/git/suwako/test.git', 'sha1', 'main', 'group'); // or Repository::open(...) |
| 257 | $me = Person::now('諏訪子', 'suwako@example.com'); |
| 258 | $tree = $repo->updateTree(null, ['README.md' => [Tree::FILE, "# Test\n"]]); |
| 259 | $id = $repo->writeCommit(Commit::create($tree, [], $me, $me, "First commit\n")); |
| 260 | $repo->refs->update('refs/heads/main', $id, $repo->zeroId()); // fails if the branch changed in the meantime |
| 261 | |
| 262 | foreach ($repo->log('main', ['path' => 'README.md', 'limit' => 20]) as $c) echo $c->id, ' ', $c->subject(), "\n"; |
| 263 | echo $repo->blob($repo->resolve('main~1:README.md')); |
| 264 | echo Diff::unified($repo, 'main~1', 'main'); |
| 265 | $lines = Blame::file($repo, 'main', 'README.md'); |
| 266 | ``` |
| 267 | * Storage: loose objects, packfiles (`.idx` v1/v2 and `.rev`; deltas are resolved), refs and `packed-refs`, `objects/info/alternates`, `core.sharedRepository`. Revisions such as `main~2`, `v1.0^{}`, `HEAD^2` and `main:path/file` are understood. `Diff` gives the same output as `git diff` (Myers), `Blame` follows the first parent that has the same line, and `Maintenance::gc()` packs everything reachable into one pack (run it from Cron). |
| 268 | * Network: `Http::handle()` serves Smart HTTP (`git clone https://…`, `git fetch`, `git push`) and `Ssh::handle()` serves `git clone git@host:…` and `git push` over SSH, with protocol v0, v1 and v2, shallow clones (`--depth`, `--deepen`), `include-tag`, side-band progress, atomic pushes and checks of received objects. Packs that are sent reuse the deltas already on disk. Access checks are done by the caller; `ReceivePack` takes `check`, `update` and `after` callbacks (for example for protected branches). |
| 269 | ```php |
| 270 | // GET …/repo.git/info/refs, POST …/repo.git/git-upload-pack and git-receive-pack |
| 271 | Http::handle(Repository::open($dir), $action, ['push' => $userMayPush]); |
| 272 | |
| 273 | // SSH: authorized_keys of the git user |
| 274 | // 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… |
| 275 | exit(Ssh::handle((string)getenv('SSH_ORIGINAL_COMMAND'), fn(string $path, bool $write) => /* check access */ Repository::open('/var/www/git/'.$path))); |
| 276 | ``` |
| 277 | * On OpenBSD, php-fpm runs chrooted in `/var/www`, so keep the repositories inside it (for example `/var/www/git` as the home of the `git` user) and create them with `$shared = 'group'`, with `www` in the `git` group, so that both the website and SSH can write. For protocol v2 over SSH, add `AcceptEnv GIT_PROTOCOL` to `sshd_config`. |
| 278 | * Not supported: creating new deltas (objects pushed as loose files are packed without them), thin packs, partial clone (`--filter`), `--shallow-since`/`--shallow-exclude`, `git archive --remote`, LFS, and writing commit-graph or multi-pack-index files. |
| 279 | |
| 280 | ### Gitler (Git hosting) |
| 281 | Gitler (`/gitler`) hosts Git repositories on the site with the Git library above: repository pages (files, README, history, diffs, blame, branches, tags, ZIP/TAR.GZ downloads), private repositories, collaborators (read or read/write), and clone/push over HTTPS and SSH. It needs no JavaScript. |
| 282 | * Set `GITLER_ENABLED` to `true` (it needs `AUTH_ENABLED`) and `GITINFO` in `config/config.php`: `root` is the directory of the repositories (relative to the site root, so that the same setting works inside and outside the php-fpm chroot; for `/var/www/git`, `../git` when the site is in `/var/www/htdocs`, or `../../../git` when it is in `/var/www/htdocs/example.com/www`), `ssh` the SSH address shown for cloning (for example `git@example.com`, empty for HTTPS only), `sshPort`, `php` the PHP command outside the chroot, `objectFormat` and `branch` (`master` by default) for new repositories and `maxRepos` per user. |
| 283 | * Roles: 「Gitのリポジトリ」 (create and manage your own repositories; nobody has it until an admin gives it), 「Gitのイシュー」 (everybody by default) and 「Gitのプルリクエスト」 (nobody by default). The issue tracker and pull requests come later. Admins can see and change every repository. |
| 284 | * HTTPS: `git clone https://example.com/gitler/user/repo.git`. Public repositories can be cloned without signing in; private ones and pushes ask for the user name and an access token made at `/gitler/-/keys` (read-only or read/write). Users without two-factor authentication can also use their password. Pushes are limited to branches and tags. OpenBSD's httpd accepts request bodies up to 1 MB by default, so raise it for pushes in the `server` block, for example `connection max request body 536870912`. |
| 285 | * SSH: users add their public keys at `/gitler/-/keys`. Create the `git` user with its home inside the chroot, put `www` in its group, and let sshd ask Gitler for the keys: |
| 286 | ```sh |
| 287 | useradd -m -d /var/www/git -s /bin/sh git |
| 288 | usermod -G git www # php-fpm can write the repositories |
| 289 | usermod -G www git # gitler.php can read config/config.php and data/ |
| 290 | chmod 2775 /var/www/git |
| 291 | ``` |
| 292 | ``` |
| 293 | # /etc/ssh/sshd_config |
| 294 | Match User git |
| 295 | AuthorizedKeysCommand /usr/local/bin/php-8.5 /var/www/htdocs/gitler.php keys %u %t %k |
| 296 | AuthorizedKeysCommandUser git |
| 297 | AcceptEnv GIT_PROTOCOL |
| 298 | PasswordAuthentication no |
| 299 | ``` |
| 300 | Each key runs `gitler.php serve <user>`, so the user only gets `git clone`/`git push` (`git@example.com:user/repo.git`). Without `AuthorizedKeysCommand`, `php gitler.php authorized-keys > /var/www/git/.ssh/authorized_keys` (for example from cron) writes the same lines. |
| 301 | * Add the Cron job `gitlergc` (for example `php cron.php add "@daily" gitlergc`) to pack the repositories, or run `php gitler.php gc`. |
| 302 | * The repository page shows the share of each language like GitHub (by bytes; file names, extensions and `#!`). Only programming and markup languages count: data (JSON, YAML…), prose (Markdown…), vendored code (`vendor/`, `node_modules/`…), documentation (`docs/`, `examples/`…) and generated files are left out. Change it with `linguist-language`, `linguist-vendored`, `linguist-generated`, `linguist-documentation` and `linguist-detectable` in the root `.gitattributes` (for example `*.h linguist-language=C++`). Results are stored per tree in `gitler-languages.json` inside the repository, and lists show the main language of the default branch. |
| 303 | * Issues: labels, assignees, milestones, Markdown bodies and comments (`#3` links to another issue). Users who can read the repository and have the "Git issues" role open issues and comment; people with write access label and close them (authors can close their own). Commit messages pushed to the default branch close issues with `fixes #3`, `closes #3` or `resolves #3`, and any `#3` is recorded on the issue. Issues can be turned off per repository in its settings. The data lives in `gitler/` inside the repository. |
| 304 | * Fediverse (when multi-user ActivityPub is on): public repositories become `@owner.name@host` accounts. Follow one from Mastodon and the like to receive issues and comments; replies become comments, and posts mentioning the repository open new issues. ForgeFed `Offer{Ticket}` and `Resolve` are supported too (the issue's Ticket is at `…/issues/3/ticket`). Repository settings can turn it off and block accounts or domains (site-wide domain blocks apply as well). Delivery uses the `apdeliver` cron job; issues closed by SSH pushes (the `git` user) are queued in `gitler/spool/` and sent by the `notify` cron job. Generating the keys needs GMP. |
| 305 | * Notifications: owners and collaborators watch their repositories from the start (new issues and all activity); others get activity on issues they opened, commented on, are assigned to or are mentioned in (`@user`). The repository page's "通知" menu switches between watching, participating only and ignoring, and each issue page can subscribe or unsubscribe. Notifications appear on the site's notifications page, and by email too when `MAILER_ENABLED` is on (users can turn email off at `/gitler/-/keys`). Emails and notifications from SSH pushes are sent by the `notify` cron job, so add it: `php cron.php add "* * * * *" notify`. |
| 306 | * Archiving: archiving a repository in its settings makes it read-only (no pushes, no changes to issues, comments, labels or milestones, no comments from the Fediverse). It can be undone at any time. |
| 307 | * Pull requests (need the "Gitのプルリクエスト" role): open them from a branch in the same repository, from a branch in a fork ("フォークする" on the repository page, needs the "Gitのリポジトリ" role), or without forking via `git push origin HEAD:refs/for/master/topic` (AGit, also for repositories you can only read; pushing to the same topic again updates it). Pushing a new branch prints a URL for opening a pull request. Pull requests have a conversation, commits, and changed files (click a line number to comment on that line; collect draft comments into a review that comments, approves or requests changes). People with write access merge with a merge commit, squash, or rebase (fast-forward when possible); conflicts are checked with a 3-way merge in PHP and must be resolved locally. `fixes #3` in the description closes the issue on merge, and pull requests whose changes reach the target branch by a push are marked merged. They share numbering with issues, and the changes live in `refs/pull/N/head` (`git fetch origin refs/pull/3/head:pr-3`). They can be turned off per repository in its settings. |
| 308 | |
| 309 | ### TLS 1.3 and 1.2 (no php_openssl) |
| 310 | HTTPS requests (`Curl`, ActivityPub), SMTP STARTTLS (`Mailer`) and MySQL and PostgreSQL over TLS use `Std\Lib\Tls`, a TLS 1.3 and 1.2 client written in pure PHP (it needs `php_gmp`), so `php_openssl` is not needed. RSA keys and signatures (ActivityPub) use `Std\Lib\Crypto\Rsa`, and existing PEM keys keep working. |
| 311 | * TLS 1.3 is preferred. Servers that only speak TLS 1.2 get TLS 1.2 with secure cipher suites only (ECDHE with AES-GCM or ChaCha20-Poly1305, Extended Master Secret when the server supports it, no renegotiation); CBC, RSA key exchange and DHE are not supported, and neither is TLS 1.1 or older. A TLS 1.3 server that is pushed down to TLS 1.2 by an attacker is detected. Use the option `'min_version' => '1.3'` to refuse TLS 1.2. |
| 312 | * Certificates are checked against `TLS_CAFILE` in `config/config.php` (a PEM file of trusted CAs). When it is empty, the usual system locations are tried (`/etc/ssl/cert.pem` on OpenBSD). Inside the httpd/php-fpm chroot that path is `/var/www/etc/ssl/cert.pem`, so copy the system file there. Windows has no such file, so set `TLS_CAFILE` (for example to curl's `cacert.pem`). |
| 313 | * Cipher suites: 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}`. Key exchange: x25519, secp256r1, secp384r1. Signatures: RSA-PSS, ECDSA (P-256, P-384, P-521) and RSA PKCS#1 v1.5 (certificates and TLS 1.2 only). Session resumption, 0-RTT, client certificates and revocation checks are not supported. |
| 314 | * Encryption runs in PHP, so large downloads are slower than with openssl (a few MB/s). |
| 315 | ```php |
| 316 | $s = \Std\Lib\Tls::connect('example.com', 443); |
| 317 | fwrite($s, "GET / HTTP/1.1\r\nHost: example.com\r\nConnection: close\r\n\r\n"); |
| 318 | echo stream_get_contents($s); |
| 319 | ``` |
| 320 | |
| 321 | ### Comments |
| 322 | Set `COMMENTS_ENABLED` to `true` in `config/config.php` to show a comment section under every blog post (posting needs `AUTH_ENABLED`). |
| 323 | * Logged-in users can comment, reply and like (Markdown: bold, italics, strikethrough, code, quotes, lists and links). Users can delete their own comments, and admins and staff can delete any comment. No JavaScript is needed. |
| 324 | * Blogs with `'federate' => true` in `BLOGS` are posted by the `FEDIINFO` account (`apfederate`), and public or unlisted Fediverse replies to those posts (and to replies) become comments. Edits and deletions on the Fediverse are followed. |
| 325 | * Every new comment notifies all admins, and users are notified when their comments get replies or likes, at `/notifications`. |
| 326 | * Comments are stored in `data/comments/` and notifications in `data/notifications/`. |
| 327 | |
| 328 | ### Cron |
| 329 | Scheduled tasks (for example `apfederate`, which sends new blog posts to ActivityPub followers) are run by the system cron.\ |
| 330 | Set `CRON_ENABLED` to `true` in `config/config.php` (and `FEDIINFO['domain']` when using ActivityPub), |
| 331 | then add this line to the crontab of the user php-fpm runs as (`crontab -u www -e`): |
| 332 | ``` |
| 333 | * * * * * /usr/local/bin/php-8.5 /var/www/htdocs/cron.php |
| 334 | ``` |
| 335 | |
| 336 | Jobs are managed from the command line (or the admin panel): |
| 337 | ```sh |
| 338 | php cron.php add "*/10 * * * *" apfederate |
| 339 | php cron.php list |
| 340 | php cron.php help |
| 341 | ``` |
| 342 | |
| 343 | Tasks are registered in `register_cron_tasks()` in `util.php` (used by `cron.php` and the admin panel). Built-in tasks: |
| 344 | * `apfederate`: sends new blog posts to ActivityPub followers |
| 345 | * `apupdate`: sends profile (actor) updates to ActivityPub followers |
| 346 | * `authpurge`: deletes accounts whose email address was not verified within 7 days (when `AUTH_VERIFY_EMAIL` is enabled) |
| 347 | * `apdeliver`: delivers microblog activities to other servers (multi-user mode, run every minute) |
| 348 | * `apcleanup`: deletes remote posts and caches older than 90 days (multi-user mode, run daily) |
| 349 | * `notify`: sends notification emails and processes the notifications and deliveries queued in Gitler repositories by SSH pushes (when `AUTH_ENABLED` is enabled, run every minute) |
| 350 | * `gitlergc`: packs the Gitler repositories (when `GITLER_ENABLED` is enabled, run daily) |
| 351 | |
| 352 | ### Microblog (multi-user Fediverse) |
| 353 | By default ActivityPub is single-user: the blog actor (`/ap/actor`) sends new blog posts to its followers.\ |
| 354 | Setting `FEDIINFO['isSingleUser']` to `false` (with `AUTH_ENABLED` and `FEDIINFO['domain']`) turns on multi-user mode: |
| 355 | * The blog actor keeps working as before. |
| 356 | * Every user with the Fediverse role becomes their own actor (`@username@domain`). The role is given to everyone except banned users; an admin can take it away on the user's profile edit page. |
| 357 | * `/microblog` has Home, Local and Federated timelines, posting (content warnings, visibility, up to 4 images), replies, likes, boosts, follows (with optional follow approval), blocks, reports and search (`@user@host`, post URLs, `#tags` and text). No JavaScript is needed. |
| 358 | * Admins can block whole servers and read reports at `/microblog/admin`. |
| 359 | |
| 360 | Deliveries are queued, so add the cron jobs: |
| 361 | ```sh |
| 362 | php cron.php add "* * * * *" apdeliver |
| 363 | php cron.php add "@daily" apcleanup |
| 364 | ``` |
| 365 | Uploaded images (PNG/JPEG/GIF, up to 4 MiB; check PHP's `upload_max_filesize`) have their metadata (Exif, text chunks) removed. |
| 366 | |
| 367 | ### Admin panel |
| 368 | Set `ADMIN_ENABLED` to `true` in `config/config.php` (it needs `AUTH_ENABLED`). Users with the admin role then see 「管理」 in the menu, which opens `/admin`: |
| 369 | * Users: list and search accounts, create accounts (even when registration is off, without email verification), change roles (removing every role bans the user), mark an email address as verified, sign a user out everywhere, and change the email address or password or turn off two-factor authentication (these three need your own password). Display name, icon and the rest stay on the user's profile edit page. |
| 370 | * Logs: the files in `log/`, with the last lines, search, download, clear and delete. |
| 371 | * Cron: the jobs, with add, edit, enable or disable, delete and run now, and when the system cron last ran. |
| 372 | * Cache: `data/cache/` by kind (view, or delete one entry, a kind or everything) and the Fediverse caches of remote actors, WebFinger and images. |
| 373 | * Database conversion: the converter above as a form. Sources and targets are JSON data (`data/`), a SQLite file, a MySQL/MariaDB or PostgreSQL server, an SQL dump (uploaded or a file on the server; as a target, downloaded or saved on the server) and the site's own database (`DBINFO`). Paths are relative to the site root and must stay inside it. Uploaded and converted files are kept in `data/tmp/convert/` for an hour. Large uploads need a higher `upload_max_filesize` and `post_max_size` in `php.ini`. |
| 374 | |
| 375 | A new site has no admin yet, and `data/` may be empty. Set `ADMIN_SETUP_CODE` to a long random string (for example the output of `openssl rand -hex 16`) and open `/admin`: it goes to `/admin/setup`, where the code creates the first admin account and signs it in. Then set `ADMIN_SETUP_CODE` back to `''`. The setup page closes as soon as an admin exists. |
| 376 | |
| 377 | ## Required PHP module |
| 378 | * php_gmp |
| 379 | * php_zlib (for Git; usually built into PHP) |