Un server ActivityPub in un singolo file PHP? Ecco come fare... Il post di @tofeo
Qualsiasi programma per computer può essere progettato per essere eseguito da un singolo file, se la progettazione è abbastanza sbagliata! 🤣
Volevo creare il server #Fediverse più semplice possibile, che potesse essere utilizzato come strumento didattico per mostrare come funziona ActivityPub / Mastodon.
shkspr.mobi/blog/2024/02/activ…
ActivityPub Server in a Single PHP File
shkspr.mobi/blog/2024/02/activ…Any computer program can be designed to run from a single file if you architect it wrong enough!
I wanted to create the simplest possible Fediverse server which can be used as an educational tool to show how ActivityPub / Mastodon works.
The design goals were:
- Upload a single PHP file to the server.
- No databases or separate config files.
- Single Actor (i.e. not multi-user).
- Allow the Actor to be followed.
- Post plain-text messages to followers.
- Be roughly standards compliant.
And those goals have all been met! Check it out on GitLab. I warn you though, it is the nadir of bad coding. There are no tests, bugger-all security, scalability isn't considered, and it is a mess. But it works.
You can follow the test user
@[url=https://example.viii.fi/example]example@example.viii.fi[/url]Architecture
Firstly, I've slightly cheated on my "single file" stipulation. There's an.htaccessfile which turnsexample.com/whateverintoexample.com/index.php?path=whateverThe
index.phpfile then takes that path and does stuff. It also contains all the configuration variables which is very bad practice.Rather than using a database, it saves files to disk.
Again, this is not suitable for any real world use. This is an educational tool to help explain the basics of posting messages to the Fediverse. It requires absolutely no dependencies. You do not need to spin up a dockerised hypervisor to manage your node bundles and re-compile everything to WASM. Just FTP the file up to prod and you're done.
Walkthrough
This is a quick ramble through the code. It is reasonably well documented, I hope.Preamble
This is where you set up your account's name and bio. You also need to provide a public/private keypair. The posting page is protected with a password that also needs to be set here.
PHP // Set up the Actor's information $username = rawurlencode("example"); // Encoded as it is often used as part of a URl $realName = "E. Xample. Jr."; $summary = "Some text about the user."; $server = $_SERVER["SERVER_NAME"]; // Domain name this is hosted on // Generate locally or from cryptotools.net/rsagen // Newlines must be replaced with "\n" $key_private = "-----BEGIN RSA PRIVATE KEY-----\n...\n-----END RSA PRIVATE KEY-----"; $key_public = "-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----"; // Password for sending messages $password = "P4ssW0rd";Logging
ActivityPub is a "chatty" protocol. This takes all the requests your server receives and saves them in/logs/as a datestamped text file.
PHP // Get all headers and requests sent to this server $headers = print_r( getallheaders(), true ); $postData = print_r( $_POST, true ); $getData = print_r( $_GET, true ); $filesData = print_r( $_FILES, true ); $body = json_decode( file_get_contents( "php://input" ), true ); $bodyData = print_r( $body, true ); $requestData = print_r( $_REQUEST, true ); $serverData = print_r( $_SERVER, true ); // Get the type of request - used in the log filename if ( isset( $body["type"] ) ) { $type = " " . $body["type"]; } else { $type = ""; } // Create a timestamp in ISO 8601 format for the filename $timestamp = date( "c" ); // Filename for the log $filename = "{$timestamp}{$type}.txt"; // Save headers and request data to the timestamped file in the logs directory if( ! is_dir( "logs" ) ) { mkdir( "logs"); } file_put_contents( "logs/{$filename}", "Headers: \n$headers \n\n" . "Body Data: \n$bodyData \n\n" . "POST Data: \n$postData \n\n" . "GET Data: \n$getData \n\n" . "Files Data: \n$filesData \n\n" . "Request Data:\n$requestData\n\n" . "Server Data: \n$serverData \n\n" );Routing
The.htaccesschanges/whateverto/?path=whateverThis runs the function of the path requested.
PHP !empty( $_GET["path"] ) ? $path = $_GET["path"] : die(); switch ($path) { case ".well-known/webfinger": webfinger(); case rawurldecode( $username ): username(); case "following": following(); case "followers": followers(); case "inbox": inbox(); case "write": write(); case "send": send(); default: die(); }WebFinger
The WebFinger Protocol is used to identify accounts.It is requested withexample.com/.well-known/webfinger?resource=acct:username@example.comThis server only has one user, so it ignores the query string and always returns the same details.
PHP function webfinger() { global $username, $server; $webfinger = array( "subject" => "acct:{$username}@{$server}", "links" => array( array( "rel" => "self", "type" => "application/activity+json", "href" => "https://{$server}/{$username}" ) ) ); header( "Content-Type: application/json" ); echo json_encode( $webfinger ); die(); }Username
Requestingexample.com/usernamereturns a JSON document with the user's information.
PHP function username() { global $username, $realName, $summary, $server, $key_public; $user = array( "@context" => [ "https://www.w3.org/ns/activitystreams", "https://w3id.org/security/v1" ], "id" => "https://{$server}/{$username}", "type" => "Person", "following" => "https://{$server}/following", "followers" => "https://{$server}/followers", "inbox" => "https://{$server}/inbox", "preferredUsername" => rawurldecode($username), "name" => "{$realName}", "summary" => "{$summary}", "url" => "https://{$server}", "manuallyApprovesFollowers" => true, "discoverable" => true, "published" => "2024-02-12T11:51:00Z", "icon" => [ "type" => "Image", "mediaType" => "image/png", "url" => "https://{$server}/icon.png" ], "publicKey" => [ "id" => "https://{$server}/{$username}#main-key", "owner" => "https://{$server}/{$username}", "publicKeyPem" => $key_public ] ); header( "Content-Type: application/activity+json" ); echo json_encode( $user ); die(); }Following & Followers
These JSON documents show how many users are following / followers-of this account.The information here is self-attested. So you can lie and use any number you want.
PHPfunction following() { global $server; $following = array( "@context" => "https://www.w3.org/ns/activitystreams", "id" => "https://{$server}/following", "type" => "Collection", "totalItems" => 0, "items" => [] ); header( "Content-Type: application/activity+json" ); echo json_encode( $following ); die(); } function followers() { global $server; $followers = array( "@context" => "https://www.w3.org/ns/activitystreams", "id" => "https://{$server}/followers", "type" => "Collection", "totalItems" => 0, "items" => [] ); header( "Content-Type: application/activity+json" ); echo json_encode( $followers ); die(); }Inbox
The/inboxis the main server. It receives all requests. This server only responds to "Follow" requests.A remote server sends a follow request which is a JSON file saying who they are.This code does not cryptographically validate the headers of the received message.The name of the remote user's server is saved to a file so that future messages can be delivered to it.An accept request is cryptographically signed and POST'd back to the remote server.
PHP function inbox() { global $body, $server, $username, $key_private; // Get the message and type $inbox_message = $body; $inbox_type = $inbox_message["type"]; // This inbox only responds to follow requests if ( "Follow" != $inbox_type ) { die(); } // Get the parameters $inbox_id = $inbox_message["id"]; $inbox_actor = $inbox_message["actor"]; $inbox_host = parse_url( $inbox_actor, PHP_URL_HOST ); // Does this account have any followers? if( file_exists( "followers.json" ) ) { $followers_file = file_get_contents( "followers.json" ); $followers_json = json_decode( $followers_file, true ); } else { $followers_json = array(); } // Add user to list. Don't care about duplicate users, server is what's important $followers_json[$inbox_host]["users"][] = $inbox_actor; // Save the new followers file file_put_contents( "followers.json", print_r( json_encode( $followers_json ), true ) ); // Response Message ID // This isn't used for anything important so could just be a random number $guid = uuid(); // Create the Accept message $message = [ "@context" => "https://www.w3.org/ns/activitystreams", "id" => "https://{$server}/{$guid}", "type" => "Accept", "actor" => "https://{$server}/{$username}", "object" => [ "@context" => "https://www.w3.org/ns/activitystreams", "id" => $inbox_id, "type" => $inbox_type, "actor" => $inbox_actor, "object" => "https://{$server}/{$username}", ] ]; // The Accept is sent to the server of the user who requested the follow // TODO: The path doesn't *always* end with/inbox $host = $inbox_host; $path = parse_url( $inbox_actor, PHP_URL_PATH ) . "/inbox"; // Get the signed headers $headers = generate_signed_headers( $message, $host, $path ); // Specify the URL of the remote server's inbox // TODO: The path doesn't *always* end with /inbox $remoteServerUrl = $inbox_actor . "/inbox"; // POST the message and header to the requester's inbox $ch = curl_init( $remoteServerUrl ); curl_setopt( $ch, CURLOPT_RETURNTRANSFER, true ); curl_setopt( $ch, CURLOPT_CUSTOMREQUEST, "POST" ); curl_setopt( $ch, CURLOPT_POSTFIELDS, json_encode($message) ); curl_setopt( $ch, CURLOPT_HTTPHEADER, $headers ); $response = curl_exec( $ch ); // Check for errors if( curl_errno( $ch ) ) { file_put_contents( "error.txt", curl_error( $ch ) ); } curl_close($ch); die(); }UUID
Every message sent should have a unique ID. This can be anything you like. Some servers use a random number.I prefer a date-sortable string.
PHP function uuid() { return sprintf( "%08x-%04x-%04x-%04x-%012x", time(), mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0x3fff) | 0x8000, mt_rand(0, 0xffffffffffff) ); }Signing Headers
Every message that your server sends needs to be cryptographically signed with your Private Key.This is a complicated process. Please read "How to make friends and verify requests" for more information.
PHP function generate_signed_headers( $message, $host, $path ) { global $server, $username, $key_private; // Encode the message to JSON $message_json = json_encode( $message ); // Location of the Public Key $keyId = "https://{$server}/{$username}#main-key"; // Generate signing variables $hash = hash( "sha256", $message_json, true ); $digest = base64_encode( $hash ); $date = date( "D, d M Y H:i:s \G\M\T" ); // Get the Private Key $signer = openssl_get_privatekey( $key_private ); // Sign the path, host, date, and digest $stringToSign = "(request-target): post $path\nhost: $host\ndate: $date\ndigest: SHA-256=$digest"; // The signing function returns the variable $signature // php.net/manual/en/function.ope… openssl_sign( $stringToSign, $signature, $signer, OPENSSL_ALGO_SHA256 ); // Encode the signature $signature_b64 = base64_encode( $signature ); // Full signature header $signature_header = 'keyId="' . $keyId . '",algorithm="rsa-sha256",headers="(request-target) host date digest",signature="' . $signature_b64 . '"'; // Header for POST reply $headers = array( "Host: {$host}", "Date: {$date}", "Digest: SHA-256={$digest}", "Signature: {$signature_header}", "Content-Type: application/activity+json", "Accept: application/activity+json", ); return $headers; }User Interface for Writing
This creates a basic HTML form. Type in your message and your password. It then POSTs the data to the/sendendpoint.
PHP function write() { // Display an HTML form for the user to enter a message.echo <<< HTML<![url=https://mastodon.social/users/doctype]Aleks Dorohovich[/url] html><html lang="en-GB"> <head> <meta charset="UTF-8"> <title>Send Message</title> <style> *{font-family:sans-serif;font-size:1.1em;} </style> </head> <body> <form action="/send" method="post" enctype="multipart/form-data"> <label for="content">Your message:</label><br> <textarea id="content" name="content" rows="5" cols="32"></textarea><br> <label for="password">Password</label><br> <input type="password" name="password" id="password" size="32"><br> <input type="submit" value="Post Message"> </form> </body></html>HTML; die(); }Send Endpoint
This takes the submitted message and checks the password is correct.It reads thefollowers.jsonfile and sends the message to every server that is following this account.
PHP function send() { global $password, $server, $username, $key_private; // Does the posted password match the stored password? if( $password != $_POST["password"] ) { die(); } // Get the posted content $content = $_POST["content"]; // Current time - ISO8601 $timestamp = date( "c" ); // Outgoing Message ID $guid = uuid(); // Construct the Note // contentMap is used to prevent unnecessary "translate this post" pop ups // hardcoded to English $note = [ "@context" => array( "https://www.w3.org/ns/activitystreams" ), "id" => "https://{$server}/posts/{$guid}.json", "type" => "Note", "published" => $timestamp, "attributedTo" => "https://{$server}/{$username}", "content" => $content, "contentMap" => ["en" => $content], "to" => ["https://www.w3.org/ns/activitystreams#Public"] ]; // Construct the Message $message = [ "@context" => "https://www.w3.org/ns/activitystreams", "id" => "https://{$server}/posts/{$guid}.json", "type" => "Create", "actor" => "https://{$server}/{$username}", "to" => [ "https://www.w3.org/ns/activitystreams#Public" ], "cc" => [ "https://{$server}/followers" ], "object" => $note ]; // Create the context for the permalink $note = [ "@context" => "https://www.w3.org/ns/activitystreams", ...$note ]; // Save the permalink $note_json = json_encode( $note ); // Check for posts/ directory and create it if( ! is_dir( "posts" ) ) { mkdir( "posts"); } file_put_contents( "posts/{$guid}.json", print_r( $note_json, true ) ); // Read existing users and get their hosts $followers_file = file_get_contents( "followers.json" ); $followers_json = json_decode( $followers_file, true ); $hosts = array_keys( $followers_json ); // Prepare to use the multiple cURL handle $mh = curl_multi_init(); // Loop through all the severs of the followers // Each server needs its own cURL handle // Each POST to an inbox needs to be signed separately foreach ( $hosts as $host ) { $path = "/inbox"; // Get the signed headers $headers = generate_signed_headers( $message, $host, $path ); // Specify the URL of the remote server $remoteServerUrl = "https://{$host}{$path}"; // POST the message and header to the requester's inbox $ch = curl_init( $remoteServerUrl ); curl_setopt( $ch, CURLOPT_RETURNTRANSFER, true ); curl_setopt( $ch, CURLOPT_CUSTOMREQUEST, "POST" ); curl_setopt( $ch, CURLOPT_POSTFIELDS, json_encode($message) ); curl_setopt( $ch, CURLOPT_HTTPHEADER, $headers ); // Add the handle to the multi-handle curl_multi_add_handle( $mh, $ch ); } // Execute the multi-handle do { $status = curl_multi_exec( $mh, $active ); if ( $active ) { curl_multi_select( $mh ); } } while ( $active && $status == CURLM_OK ); // Close the multi-handle curl_multi_close( $mh ); // Render the JSON so the user can see the POST has worked header( "Location: https://{$server}/posts/{$guid}.json" ); die(); }Next Steps
This is not intended to be used in production. Ever. But if you would like to contribute more simple examples of how the protocol works, please come and play on GitLab.You can follow the test user
@[url=https://example.viii.fi/example]example@example.viii.fi[/url]Terence Eden / ActivityPub-Single-PHP-File · GitLab
A single PHP file which acts as a basic ActivityPub server.GitLab
reshared this
informapirata ⁂, Fediverso e Che succede nel Fediverso? reshared this.


" title="
Max - Poliverso 🇪🇺🇮🇹
in reply to Poliverso - notizie dal Fediverso ⁂ • — (Firenze) •@Poliverso - notizie dal Fediverso ⁂ @Le Alternative
Questa cosa mi interessa molto, qualche domanda:
1) perché sconsigliate di menzionare la comunità nella prima riga? Chiedo, perché a me viene spontaneo indicare subito a chi mi rivolgo e se non capisco il motivo entro 3 secondi me ne scordo.
Me ne scordo di cosa? 😁
2) è possibile vedere da citiverse.it quale sia l'handle di una comunità, in modo da poterla poi menzionare qui (oltre alla ricerca da Friendica tramite URL)? Avete pubblicato l'elenco delle comunità attuali ma in futuro potrebbero essercene di nuove e poterne ricavare l'handle sarebbe utile;
3) giorni fa ho creato un account su citiverse.it per poter interagire con le comunità, a questo punto mi viene il dubbio che avrei potuto fare tutto con il mio account su poliverso.org senza farne uno nuovo anche lì. Corretto?
EDIT:
4) se faccio il follow di una categoria (es. "Lombardia") vale anche come follow per tutte le sue sottocategorie? Non credo...
like this
Poliverso - notizie dal Fediverso ⁂ likes this.
Poliverso - notizie dal Fediverso ⁂
in reply to Max - Poliverso 🇪🇺🇮🇹 • •@Massimiliano Polito 🇪🇺🇮🇹 @Le Alternative
Questa cosa mi interessa molto, qualche domanda:
Perché viene una schifezza: la prima riga infatti diventa un titolo nelle piattaforme forum-like, quindi scrivere la menzione all'inizio rende orripilante il post. La cosa migliore è scrivere un primo paragrafo con un titolo riassuntivo della conversazione, poi la menzione e poi il resto del testo
😂😭
Generalmente è lo stesso nome della categoria. Per esempio se la categoria è
https://citiverse.it/category/13/NOMECATEGORIAallora l'handle è al 99%@NOMECATEGORIA@citiverse.itma quando è la prima volta che usi una comunità, ti conviene fare prima la ricerca, perché la comunità potrebbe non essere ancora stata collegata dalla tua istanzaNon necessariamente: per esempio io presefrisco utilizzare un account social (Mastodon o Friendica) per scrivere nuovi post su citiverse.it perché lo trovo più comodo. Ma l'esperienza Forum è molto più ordinata e, soprattutto, NodeBB recupera molto bene i contenuti dalle altre istanze. Quindi in un cero senso è vantaggioso usare un account NodeBB.
Inoltre se vuoi moderare una comunità, avere un account NodeBB è fondamentale.
Per concludere, direi che avere due account è meglio che averne uno solo
Max - Poliverso 🇪🇺🇮🇹
in reply to Poliverso - notizie dal Fediverso ⁂ • — (Firenze) •@Poliverso - notizie dal Fediverso ⁂ @Le Alternative
1) quindi il titolo del post, per chi posta da istanze tipo Friendica, viene ignorato da nodeBB?
Poliverso - notizie dal Fediverso ⁂ likes this.
Poliverso - notizie dal Fediverso ⁂ reshared this.
Poliverso - notizie dal Fediverso ⁂
in reply to Max - Poliverso 🇪🇺🇮🇹 • •@Massimiliano Polito 🇪🇺🇮🇹 @Le Alternative
No, in effetti non dovrebbe affatto essere ignorato, ma siccome il titolo non lo uso quasi mai, non saprei risponderti... Magari prova a pubblicare un nuovo post con Titolo e vedi che succede
Max - Poliverso 🇪🇺🇮🇹
in reply to Poliverso - notizie dal Fediverso ⁂ • — (Firenze) •@Poliverso - notizie dal Fediverso ⁂ @Le Alternative
Volevo mettere un messaggio di prova, sono andato a cercare il link del forum di test su citiverse.it e mi sono accorto che avevo mandato un messaggio di prova giorni fa per vedere come funzionava e manco a farlo a posta quel messaggio aveva un titolo.
NodeBB ha usato il titolo che avevo messo su Friendica come titolo del post che ha creato su citiverse ma anche come prima riga del suo testo.
Poliverso - notizie dal Fediverso ⁂ likes this.
Poliverso - notizie dal Fediverso ⁂
in reply to Max - Poliverso 🇪🇺🇮🇹 • •@Massimiliano Polito 🇪🇺🇮🇹 ti confermo che si vede perfettamente:
citiverse.it/topic/40dad86a-95…
@Le Alternative