1000 requests per second, please use a different server. $timestamp = ( new DateTime() )->format( DATE_RFC3339_EXTENDED ); // 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 .htaccess changes /whatever to /?path=whatever // This runs the function of the path requested. !empty( $_GET["path"] ) ? $path = $_GET["path"] : home(); switch ($path) { case ".well-known/webfinger": webfinger(); // Mandatory. Static. case rawurldecode( $username ): username(); // Mandatory. Static case "following": following(); // Mandatory. Static case "followers": followers(); // Mandatory. Could be dynamic case "inbox": inbox(); // Mandatory. Only accepts follow requests. case "write": write(); // User interface for writing posts case "send": // API for posting content to the Fediverse send(); case "outbox": // Optional. Dynamic. outbox(); default: die(); } // The [WebFinger Protocol](https://docs.joinmastodon.org/spec/webfinger/) is used to identify accounts. // It is requested with `example.com/.well-known/webfinger?resource=acct:username@example.com` // This server only has one user, so it ignores the query string and always returns the same details. 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(); } // User: // Requesting `example.com/username` returns a JSON document with the user's information. 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", "outbox" => "https://{$server}/outbox", "preferredUsername" => rawurldecode($username), "name" => "{$realName}", "summary" => "{$summary}", "url" => "https://{$server}/{$username}", "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(); } // Follower / Following: // 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. function 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 `/inbox` is 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. 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 ); curl_exec( $ch ); // Check for errors if( curl_errno( $ch ) ) { file_put_contents( "error.txt", curl_error( $ch ) ); } curl_close($ch); die(); } // Unique ID: // 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. 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) ); } // Headers: // Every message that your server sends needs to be cryptographically signed with your Private Key. // This is a complicated process. Please read https://blog.joinmastodon.org/2018/07/how-to-make-friends-and-verify-requests/ for more information. function generate_signed_headers( $message, $host, $path ) { global $server, $username, $key_private; // Encode the message object 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 // https://www.php.net/manual/en/function.openssl-sign.php 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 Homepage: // This creates a basic HTML page. This content appears when someone visits the root of your site. function home() { global $username, $server, $realName, $summary; echo <<< HTML
{$summary}
This software is licenced under AGPL 3.0.
This site is a basic ActivityPub server designed to be a lightweight educational tool.
HTML; die(); } // User Interface for Writing: // This creates a basic HTML form. Type in your message and your password. It then POSTs the data to the `/send` endpoint. function write() { echo <<< HTML{$content}
"; return [ "HTML" => $content, "TagArray" => $tags ]; } // The Outbox contains a date-ordered list (newest first) of all the user's posts // This is optional. function outbox() { global $server, $username; // Get all posts $posts = array_reverse( glob("posts/" . "*.json") ); // Number of posts $totalItems = count( $posts ); // Create an ordered list $orderedItems = []; foreach ($posts as $post) { $orderedItems[] = array( "type" => "Create", "actor" => "https://{$server}/{$username}", "object" => "https://{$server}/{$post}" ); } // Create User's outbox $outbox = array( "@context" => "https://www.w3.org/ns/activitystreams", "id" => "https://{$server}/outbox", "type" => "OrderedCollection", "totalItems" => $totalItems, "summary" => "All the user's posts", "orderedItems" => $orderedItems ); // Render the page header( "Content-Type: application/activity+json" ); echo json_encode( $outbox ); die(); } // "One to stun, two to kill, three to make sure" die(); die(); die();