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; // The number of followers is self-reported // You can set this to any number you like // Get all the files $follower_files = glob("data/followers/*.json"); // Number of posts $totalItems = count( $follower_files ); $followers = array( "@context" => "https://www.w3.org/ns/activitystreams", "id" => "https://{$server}/followers", "type" => "Collection", "totalItems" => $totalItems, "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; // Validate HTTP Message Signature // This logs whether the signature was validated or not if ( !verifyHTTPSignature() ) { die(); } // 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.
{$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(); } // Verify the signature sent with the message. // This is optional // It is very confusing function verifyHTTPSignature() { global $input, $body, $server; // Get the headers send with the request $headers = getallheaders(); // Validate the timestamp is within ±30 seconds if ( !isset( $headers["Date"] ) ) { return null; } // No date set $dateHeader = $headers["Date"]; $headerDatetime = DateTime::createFromFormat('D, d M Y H:i:s T', $dateHeader); $currentDatetime = new DateTime(); // Calculate the time difference in seconds $timeDifference = abs($currentDatetime->getTimestamp() - $headerDatetime->getTimestamp()); if ($timeDifference > 30) { // Write a log detailing the error $timestamp = ( new DateTime() )->format( DATE_RFC3339_EXTENDED ); // Filename for the log $filename = "{$timestamp} Time Failure.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}", "Original Date:\n" . print_r( $dateHeader, true ) . "\n" . "Local Date:\n" . print_r( $currentDatetime->format('D, d M Y H:i:s T'), true ) . "\n" ); return false; } // Validate the Digest // It is the hash of the raw input string, in binary, encoded as base64 $digestString = $headers["Digest"]; // Usually in the form `SHA-256=Ofv56Jm9rlowLR9zTkfeMGLUG1JYQZj0up3aRPZgT0c=` // The Base64 encoding may have multiple `=` at the end. So split this at the first `=` $digestData = explode( "=", $digestString, 2 ); $digestAlgorithm = $digestData[0]; $digestHash = $digestData[1]; // There might be many different hashing algorithms // TODO: Find a way to transform these automatically if ( "SHA-256" == $digestAlgorithm ) { $digestAlgorithm = "sha256"; } else if ( "SHA-512" == $digestAlgorithm ) { $digestAlgorithm = "sha512"; } // Manually calculate the digest based on the data sent $digestCalculated = base64_encode( hash( $digestAlgorithm, $input, true ) ); // Does our calculation match what was sent? if ( !( $digestCalculated == $digestHash ) ) { // Write a log detailing the error $timestamp = ( new DateTime() )->format( DATE_RFC3339_EXTENDED ); // Filename for the log $filename = "{$timestamp} Digest Failure.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}", "Original Input:\n" . print_r( $input, true ) . "\n" . "Original Digest:\n" . print_r( $digestString, true ) . "\n" . "Calculated Digest:\n" . print_r( $digestCalculated, true ) . "\n" ); return false; } // Examine the signature $signatureHeader = $headers["Signature"]; // Extract key information from the Signature header $signatureParts = []; // Converts 'a=b,c=d e f' into ["a"=>"b", "c"=>"d e f"] // word="text" preg_match_all('/(\w+)="([^"]+)"/', $signatureHeader, $matches); foreach ($matches[1] as $index => $key) { $signatureParts[$key] = $matches[2][$index]; } // Manually reconstruct the header string $signatureHeaders = explode(" ", $signatureParts["headers"] ); $signatureString = ""; foreach ($signatureHeaders as $signatureHeader) { if ( "(request-target)" == $signatureHeader ) { $method = strtolower( $_SERVER["REQUEST_METHOD"] ); $target = strtolower( $_SERVER["REQUEST_URI"] ); $signatureString .= "(request-target): {$method} {$target}\n"; } else if ( "host" == $signatureHeader ) { $host = strtolower( $_SERVER["HTTP_HOST"] ); $signatureString .= "host: {$host}\n"; } else { // In the HTTP header, the keys use Title Case $signatureString .= "{$signatureHeader}: " . $headers[ ucwords( $signatureHeader, "-" ) ] . "\n"; } } // Remove trailing newline $signatureString = trim( $signatureString ); // Get the Public Key // The link to the key may be sent with the body, but is always sent in the Signature header. $publicKeyURL = $signatureParts["keyId"]; // This is usually in the form `https://example.com/user/username#main-key` // This is to differentiate if the user has multiple keys // TODO: Check the actual key // This request does not need to be signed. But it does need to specify that it wants a JSON response $context = stream_context_create( [ "http" => [ "header" => "Accept: application/activity+json" ] ] ); $userJSON = file_get_contents( $publicKeyURL, false, $context ); $userData = json_decode( $userJSON, true ); $publicKey = $userData["publicKey"]["publicKeyPem"]; // Get the remaining parts $signature = base64_decode( $signatureParts["signature"] ); $algorithm = $signatureParts["algorithm"]; // Finally! Calculate whether the signature is valid // Returns 1 if verified, 0 if not, false or -1 if an error occurred $verified = openssl_verify( $signatureString, $signature, $publicKey, $algorithm ); // Convert to boolean if ($verified === 1) { $verified = true; } elseif ($verified === 0) { $verified = false; } else { $verified = null; } // Write a log detailing the signature verification process $timestamp = ( new DateTime() )->format( DATE_RFC3339_EXTENDED ); // Filename for the log $filename = "{$timestamp} Signature ". json_encode( $verified ) . ".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}", "Original Body:\n" . print_r( $body, true ) . "\n\n" . "Original Headers:\n" . print_r( $headers, true ) . "\n\n" . "Signature Headers:\n" . print_r( $signatureHeaders, true ) . "\n\n" . "Calculated signatureString:\n" . print_r( $signatureString, true ) . "\n\n" . "Calculated algorithm:\n" . print_r( $algorithm, true ) . "\n\n" . "publicKeyURL:\n" . print_r( $publicKeyURL, true ) . "\n\n" . "publicKey:\n" . print_r( $publicKey, true ) . "\n" ); return $verified; } // "One to stun, two to kill, three to make sure" die(); die(); die();