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 $follower_id = $inbox_message["id"]; $follower_actor = $inbox_message["actor"]; $follower_host = parse_url( $follower_actor, PHP_URL_HOST ); $follower_path = parse_url( $follower_actor, PHP_URL_PATH ); // Get the actor's profile as JSON // Is the actor an https URl? if( ( filter_var( $follower_actor, FILTER_VALIDATE_URL) == true) && ( parse_url( $follower_actor, PHP_URL_SCHEME ) == "https" ) ) { // Request the JSON representation of the the user $ch = curl_init( $follower_actor ); // Get the signed headers $headers = generate_signed_headers( null, $follower_host, $follower_path, "GET" ); // Set cURL options curl_setopt( $ch, CURLOPT_RETURNTRANSFER, true); curl_setopt( $ch, CURLOPT_HTTPHEADER, $headers ); // Execute the cURL session $inbox_actor_json = curl_exec( $ch ); // Check for errors if (curl_errno($ch)) { // Handle cURL error die(); } // Close cURL session curl_close($ch); // Save the actor's data in `/data/followers/` if( ! is_dir( "data/followers" ) ) { mkdir( "data"); mkdir( "data/followers"); } $follower_filename = urlencode( $follower_actor ); file_put_contents( "data/followers/{$follower_filename}.json", $inbox_actor_json ); } else { die(); } // Get the new follower's Inbox $follower_actor_details = json_decode( $inbox_actor_json, true ); $follower_inbox = $follower_actor_details["inbox"]; // Response Message ID // This isn't used for anything important so could just be a random number $guid = uuid(); // Create the Accept message to the new follower $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" => $follower_id, "type" => $inbox_type, "actor" => $follower_actor, "object" => "https://{$server}/{$username}", ] ]; // The Accept is POSTed to the inbox on the server of the user who requested the follow $follower_inbox_path = parse_url( $follower_inbox, PHP_URL_PATH ); // Get the signed headers $headers = generate_signed_headers( $message, $follower_host, $follower_inbox_path, "POST" ); // POST the message and header to the requester's inbox $ch = curl_init( $follower_inbox ); 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, $method ) { global $server, $username, $key_private; // Location of the Public Key $keyId = "https://{$server}/{$username}#main-key"; // Get the Private Key $signer = openssl_get_privatekey( $key_private ); // Timestamp this message was sent $date = date( "D, d M Y H:i:s \G\M\T" ); // There are subtly different signing requirements for POST and GET // GET does not require a digest if ( "POST" == $method ) { // Encode the message object to JSON $message_json = json_encode( $message ); // Generate signing variables $hash = hash( "sha256", $message_json, true ); $digest = base64_encode( $hash ); // 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", ); } else if ( "GET" == $method ) { // Sign the path, host, date - NO DIGEST $stringToSign = "(request-target): get $path\nhost: $host\ndate: $date"; // 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",signature="' . $signature_b64 . '"'; // Header for POST reply $headers = array( "Host: {$host}", "Date: {$date}", "Signature: {$signature_header}", "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();