Little Beast
Simple, Pragmatic, Anti-bloat
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 underpublic/of this site:/staticispublic/static/. - A URL (for example
https://ass.example.com/static) points to another server, such as a subdomain. Uploaded files are still saved underpublic/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) asfloat,NULLasnull, and everything else (includingDECIMAL/numeric, dates, JSON andBLOB) as strings. PostgreSQL'sbooleancomes back asbool,byteaas 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, andsqlStatethe SQLSTATE, for example 23505 for a duplicate key in PostgreSQL) and are written tolog/db_log.txt. - MySQL authentication:
mysql_native_password,caching_sha2_password(the MySQL 8 default) andsha256_password(over plain TCP the password is encrypted with the server's RSA key), andmysql_clear_password(TLS or UNIX socket only). MariaDB'sclient_ed25519, compression andLOAD DATA LOCAL INFILEare not supported. - SQLite:
Std\Lib\Db\Sqlitereads and writes SQLite 3 files itself (rollback journal, UTF-8), so they can also be opened with thesqlite3tool, 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),INSERTwithOR REPLACE/OR IGNORE,ON CONFLICTandRETURNING,UPDATE … FROM, joins,GROUP BYand aggregates, subqueries,UNION/INTERSECT/EXCEPT,WITH, transactions and savepoints, the usual functions (text, math, date and JSON), andPRAGMAsuch astable_info,index_list,user_versionandintegrity_check. Indexes are used for=,INand ranges. WAL mode, triggers,WITHOUT ROWID, virtual tables (FTS), window functions, recursiveWITH, foreign key enforcement, partial and expression indexes andATTACHare not supported. Locking usesflock(), so only let PHP processes write to the file while the site is running.?,?1and:nameplaceholders work; strings that are not valid UTF-8 are stored as BLOBs (or pass aStd\Lib\Db\Sqlite\Blob). - PostgreSQL authentication: SCRAM-SHA-256 (over TLS with channel binding, SCRAM-SHA-256-PLUS; the option
channel_bindingisprefer,requireordisable), MD5,password(TLS or UNIX socket only, unlessallow_cleartextistrue) andtrust. 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$1can be used directly too. There is no insert ID, so useRETURNING. ACOMMITafter an error in the transaction throwsDbException(SQLSTATE 25P02), because PostgreSQL rolls the transaction back.COPY,LISTENnotifications 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.pemfor TLS); they create and drop tables whose names start withlb_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 connectedDb),DumpSource(a.sqlfile, or.sql.gzwith PHP's zlib, frommysqldump,pg_dump(plain format,COPYor--inserts),sqlite3 .dumporDumpTarget; the dialect is detected) andJsonSource(a data directory). Targets:DbTarget,DumpTarget(dialectmysql,pgsqlorsqlite; the file can be loaded withmysql,psqlorsqlite3) andJsonTarget. - 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) andprogress(fn(string $table, int $rows)). The result hastables(rows copied per table) andwarnings(things that were not converted). - Columns, primary keys, unique and plain indexes,
NOT NULL, defaults and auto increment (AUTO_INCREMENT,SERIAL/identity, SQLite'sINTEGER PRIMARY KEY) are converted, and the next number continues after the copied rows. Types are mapped to the nearest type of the target (for exampleTINYINT(1)andboolean,JSONandjsonb,BLOBandbytea);ENUM/SETbecome 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 inwarnings). - JSON data: users, comments, notifications, Cron jobs, followers and ActivityPub notes become tables whose names start with
lb_, and every other file (exceptcache/,ratelimit/,tmp/, lock and temporary files and SQLite databases) goes intolb_files, so converting back gives the same files. Other tables becomedata/db/{table}.json. An empty or missingdata/directory simply has no tables.JsonTargetrefuses to write into a directory that is not empty unless its second argument (overwrite) istrueordropis 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 (
.idxv1/v2 and.rev; deltas are resolved), refs andpacked-refs,objects/info/alternates,core.sharedRepository. Revisions such asmain~2,v1.0^{},HEAD^2andmain:path/fileare understood.Diffgives the same output asgit diff(Myers),Blamefollows the first parent that has the same line, andMaintenance::gc()packs everything reachable into one pack (run it from Cron). - Network:
Http::handle()serves Smart HTTP (git clone https://…,git fetch,git push) andSsh::handle()servesgit clone git@host:…andgit pushover 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;ReceivePacktakescheck,updateandaftercallbacks (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/gitas the home of thegituser) and create them with$shared = 'group', withwwwin thegitgroup, so that both the website and SSH can write. For protocol v2 over SSH, addAcceptEnv GIT_PROTOCOLtosshd_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_ENABLEDtotrue(it needsAUTH_ENABLED) andGITINFOinconfig/config.php:rootis 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,../gitwhen the site is in/var/www/htdocs, or../../../gitwhen it is in/var/www/htdocs/example.com/www),sshthe SSH address shown for cloning (for examplegit@example.com, empty for HTTPS only),sshPort,phpthe PHP command outside the chroot,objectFormatandbranch(masterby default) for new repositories andmaxReposper 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 theserverblock, for exampleconnection max request body 536870912. - SSH: users add their public keys at
/gitler/-/keys. Create thegituser with its home inside the chroot, putwwwin 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 examplephp cron.php add "@daily" gitlergc) to pack the repositories, or runphp 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 withlinguist-language,linguist-vendored,linguist-generated,linguist-documentationandlinguist-detectablein the root.gitattributes(for example*.h linguist-language=C++). Results are stored per tree ingitler-languages.jsoninside the repository, and lists show the main language of the default branch. - Issues: labels, assignees, milestones, Markdown bodies and comments (
#3links 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 withfixes #3,closes #3orresolves #3, and any#3is recorded on the issue. Issues can be turned off per repository in its settings. The data lives ingitler/inside the repository. - Fediverse (when multi-user ActivityPub is on): public repositories become
@owner.name@hostaccounts. Follow one from Mastodon and the like to receive issues and comments; replies become comments, and posts mentioning the repository open new issues. ForgeFedOffer{Ticket}andResolveare 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 theapdelivercron job; issues closed by SSH pushes (thegituser) are queued ingitler/spool/and sent by thenotifycron 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 whenMAILER_ENABLEDis on (users can turn email off at/gitler/-/keys). Emails and notifications from SSH pushes are sent by thenotifycron 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 #3in 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 inrefs/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_CAFILEinconfig/config.php(a PEM file of trusted CAs). When it is empty, the usual system locations are tried (/etc/ssl/cert.pemon 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 setTLS_CAFILE(for example to curl'scacert.pem). - Cipher suites: TLS 1.3
TLS_CHACHA20_POLY1305_SHA256,TLS_AES_128_GCM_SHA256,TLS_AES_256_GCM_SHA384; TLS 1.2TLS_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' => trueinBLOGSare posted by theFEDIINFOaccount (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 indata/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 followersapupdate: sends profile (actor) updates to ActivityPub followersauthpurge: deletes accounts whose email address was not verified within 7 days (whenAUTH_VERIFY_EMAILis 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 (whenAUTH_ENABLEDis enabled, run every minute)gitlergc: packs the Gitler repositories (whenGITLER_ENABLEDis 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. /microbloghas 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,#tagsand 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 indata/tmp/convert/for an hour. Large uploads need a higherupload_max_filesizeandpost_max_sizeinphp.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)