master
ブランチ master
LittleBeast
クローン・ダウンロード
ZIP TAR.GZ
  • PHP 93.5%
  • Maron 5.3%
  • CSS 1.1%
  • Assembly 0.1%
  • Shell <0.1%
  • C <0.1%
プルリク 守矢諏訪子、2026-11-13 16:05、8379199
📁 blog
📁 config
📁 data
📁 doc
📁 log
📁 public
📁 src
📁 view
📄 .gitignore 216 B
📄 CHANGELOG.md 185 B
📄 LICENSE.md 1.8 KB
📄 README-JP.md 38.8 KB
📄 README.md 30.1 KB
📄 autoload.php 2.4 KB
📄 cron.php 7 KB
📄 gitler.php 6 KB
📄 newpost.php 2.6 KB
📄 route.php 14 KB
📄 sync.sh 297 B
📄 tester.php 4 KB
📄 util.php 24.3 KB
README.md

Little Beast

Simple, Pragmatic, Anti-bloat

GitHub repo sizelines count

Demo installation: https://lbdemo.technicalsuwako.moe/

What is Little Beast?

Little Beast is a PHP 8.5 or above framework made for 076.moe (game developer company) and technicalsuwako.moe (CEO's blog).
The core mentality is to extend what you need, and remove what you don't need.
Each core feature is split into libraries, so it is easy to choose what you need.
All modules are written from scratch, nothing is to be require a database.

Little Beast is TechnicalSuwako's final web project before moving back to game development completely.

What Little Beast is not?

  • General purpose framework
  • Hard to use
  • Meant for web developers
  • For everyone/wider audience
  • Tedious to install
  • Dogmatic
  • Democratically run

What Little Beast has not?

  • Database (everything is a file)
  • Dependencies
  • Package manager
  • Docker/Kubernetes/Vagrant/Nix/etc containers
  • Javascript
  • Bloatware
  • ORM
  • Queues or background job
  • Cloud/Serverless integration
  • Scaffolding
  • Code generation
  • Abstraction layers
  • Unnecessary files
  • DEI, Code of Conduct, or other discriminatory practises

Unique features

  • No database
  • No Composer or PEAR
  • No server overhead
  • No container
  • Maron template engine
  • Custom Markdown
  • ActivityPub
  • Multi-language support
  • Multi-blog support
  • 100% correct and beautiful HTML5 and CSS3
  • 100% correct PHP
  • SEO friendly
  • Modular CSS
  • Atom feeds
  • Built-in test suite

How to install

cd /var/www/htdocs
git clone https://gitler.moe/suwako/LittleBeast.git .
mv config/config.sample.php config/config.php

Configure HTTP server to run /public as root via php-fpm.
That's all!

OpenBSD server

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
}

Blogs and Atom feeds

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.
Without BLOGS, only blog exists, as before.

Static files (ASS_DOMAIN)

ASS_DOMAIN in config/config.php sets where static files (images, CSS, avatars, uploads) are served from. Without it, it is /static.

  • A path (for example /static) is a directory under public/ of this site: /static is public/static/.
  • 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).

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.

Database (MySQL/MariaDB, PostgreSQL, SQLite)

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, pdomysql, pgsql, pdopgsql, sqlite3, pdo_sqlite and sockets are not needed.
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']).

$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]);
});

  • The connection is opened on the first query. Queries with parameters use prepared statements, so values never need escaping.
  • 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.
  • 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.
  • 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.
  • 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).
  • 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.
  • 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.
  • 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_.

Converting databases

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.

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 dump file
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 or pg_dump file → PostgreSQL, replacing tables that already exist
Convert::run(new DumpSource('/tmp/dump.sql'), new DbTarget(new Db()), ['drop' => true]);

  • 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.
  • 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).
  • 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).
  • 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.
  • 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.

Git

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.

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

$repo = Repository::init('/var/www/git/suwako/test.git', 'sha1', 'main', 'group'); // or Repository::open(...)
$me = Person::now('諏訪子', 'suwako@example.com');
$tree = $repo->updateTree(null, ['README.md' => [Tree::FILE, "# Test\n"]]);
$id = $repo->writeCommit(Commit::create($tree, [], $me, $me, "First commit\n"));
$repo->refs->update('refs/heads/main', $id, $repo->zeroId()); // fails if the branch changed in the meantime

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');

  • 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).
  • 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).

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

// SSH: authorized_keys of the git user
// 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) => /* check access */ Repository::open('/var/www/git/'.$path)));

  • 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.
  • 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.

Gitler (Git hosting)

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.

  • 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.
  • 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.
  • 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.
  • 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:

useradd -m -d /var/www/git -s /bin/sh git
usermod -G git www   # php-fpm can write the repositories
usermod -G www git   # gitler.php can read config/config.php and 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

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.

  • Add the Cron job gitlergc (for example php cron.php add "@daily" gitlergc) to pack the repositories, or run php gitler.php gc.
  • 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.
  • 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.
  • 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.
  • 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.
  • 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.
  • 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.

TLS 1.3 and 1.2 (no php_openssl)

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.

  • 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.
  • 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).
  • 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.
  • Encryption runs in PHP, so large downloads are slower than with openssl (a few MB/s).

$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);

Comments

Set COMMENTS_ENABLED to true in config/config.php to show a comment section under every blog post (posting needs AUTH_ENABLED).

  • 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.
  • 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.
  • Every new comment notifies all admins, and users are notified when their comments get replies or likes, at /notifications.
  • Comments are stored in data/comments/ and notifications in data/notifications/.

Cron

Scheduled tasks (for example apfederate, which sends new blog posts to ActivityPub followers) are run by the system cron.
Set CRON_ENABLED to true in config/config.php (and FEDIINFO['domain'] when using ActivityPub),then add this line to the crontab of the user php-fpm runs as (crontab -u www -e):

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

Jobs are managed from the command line (or the admin panel):

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

Tasks are registered in register_cron_tasks() in util.php (used by cron.php and the admin panel). Built-in tasks:

  • apfederate: sends new blog posts to ActivityPub followers
  • apupdate: sends profile (actor) updates to ActivityPub followers
  • authpurge: deletes accounts whose email address was not verified within 7 days (when AUTH_VERIFY_EMAIL is enabled)
  • apdeliver: delivers microblog activities to other servers (multi-user mode, run every minute)
  • apcleanup: deletes remote posts and caches older than 90 days (multi-user mode, run daily)
  • 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)
  • gitlergc: packs the Gitler repositories (when GITLER_ENABLED is enabled, run daily)

Microblog (multi-user Fediverse)

By default ActivityPub is single-user: the blog actor (/ap/actor) sends new blog posts to its followers.
Setting FEDIINFO['isSingleUser'] to false (with AUTH_ENABLED and FEDIINFO['domain']) turns on multi-user mode:

  • The blog actor keeps working as before.
  • 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.
  • /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.
  • Admins can block whole servers and read reports at /microblog/admin.

Deliveries are queued, so add the cron jobs:

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

Uploaded images (PNG/JPEG/GIF, up to 4 MiB; check PHP's upload_max_filesize) have their metadata (Exif, text chunks) removed.

Admin panel

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:

  • 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.
  • Logs: the files in log/, with the last lines, search, download, clear and delete.
  • Cron: the jobs, with add, edit, enable or disable, delete and run now, and when the system cron last ran.
  • Cache: data/cache/ by kind (view, or delete one entry, a kind or everything) and the Fediverse caches of remote actors, WebFinger and images.
  • 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.

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.

Required PHP module

  • php_gmp
  • php_zlib (for Git; usually built into PHP)