"{$data}/inbox", "followers" => "{$data}/followers", "following" => "{$data}/following", "logs" => "{$data}/logs", "posts" => "posts", "images" => "images", ); // Create the directories if they don't already exist. foreach ( $directories as $directory ) { if( !is_dir( $directory ) ) { mkdir( $data ); mkdir( $directory ); } } // Get the information sent to this server $input = file_get_contents( "php://input" ); $body = json_decode( $input,true ); $bodyData = print_r( $body, true ); // If the root has been requested, manually set the path to `/` !empty( $_GET["path"] ) ? $path = $_GET["path"] : $path = "/"; // Routing: // The .htaccess changes /whatever to /?path=whatever // This runs the function of the path requested. switch ( $path ) { case ".well-known/webfinger": webfinger(); // Mandatory. Static. case rawurldecode( $username ): case "@" . rawurldecode( $username ): // Some software assumes usernames start with an `@` username(); // Mandatory. Static case "following": following(); // Mandatory. Can be static or dynamic. case "followers": followers(); // Mandatory. Can be static or dynamic. case "inbox": inbox(); // Mandatory. case "outbox": outbox(); // Optional. Dynamic. case "write": write(); // User interface for writing posts. case "send": send(); // API for posting content to the Fediverse. case "follow": follow(); // User interface for following an external user. case "follow_user": follow_user(); // API for following a user. case "read": view( "read" );// User interface for reading posts. case ".well-known/nodeinfo": wk_nodeinfo(); // Optional. Static. case "nodeinfo/2.1": nodeinfo(); // Optional. Static. case "/": view( "home" );// Optional. Can be dynamic default: die(); } // The WebFinger Protocol 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" => false, "discoverable" => true, "published" => "2024-02-29T12:34:56Z", "icon" => [ "type" => "Image", "mediaType" => "image/png", "url" => "https://{$server}/icon.png" ], "image" => [ "type" => "Image", "mediaType" => "image/png", "url" => "https://{$server}/banner.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, $directories; // Get all the files $following_files = glob( $directories["following"] . "/*.json"); // Number of users $totalItems = count( $following_files ); // Create a list of all the followers $items = array(); foreach ( $following_files as $following_file ) { $following = json_decode( file_get_contents( $following_file ), true ); $items[] = $following["id"]; } $following = array( "@context" => "https://www.w3.org/ns/activitystreams", "id" => "https://{$server}/following", "type" => "Collection", "totalItems" => $totalItems, "items" => $items ); header( "Content-Type: application/activity+json" ); echo json_encode( $following ); die(); } function followers() { global $server, $directories; // The number of followers is self-reported. // You can set this to any number you like. // Get all the files $follower_files = glob( $directories["followers"] . "/*.json"); // Number of users $totalItems = count( $follower_files ); // Create a list of everyone being followed $items = array(); foreach ( $follower_files as $follower_file ) { $following = json_decode( file_get_contents( $follower_file ),true ); $items[] = $following["id"]; } $followers = array( "@context" => "https://www.w3.org/ns/activitystreams", "id" => "https://{$server}/followers", "type" => "Collection", "totalItems" => $totalItems, "items" => $items ); header( "Content-Type: application/activity+json" ); echo json_encode( $followers ); die(); } // Inbox: // The `/inbox` is the main server. It receives all requests. function inbox() { global $body, $server, $username, $key_private, $directories; // Get the message and type $inbox_message = $body; $inbox_type = $inbox_message["type"]; // Messages to ignore. // Some servers are very chatty. They send lots of irrelevant messages. // Before even bothering to validate them, we can delete them. // Lemmy sends lots of announce messages. The object contains details of what the message is. if ( is_array( $inbox_message["object"] ) ) { if ( match( $inbox_message["object"]["type"] ) { "Follow", "Undo", "Dislike", "Like" => true, default => false, } ) { // Discard it, no further processing. die(); } } // Save any Follow, Create, Update, Announce, Like messages // This ignores Delete, Undo, and anything else if ( match( $inbox_type ) { "Follow", "Create", "Update", "Announce", "Like" => true, default => false, } ) { // Validate HTTP Message Signature // This logs whether the signature was validated or not if ( !verifyHTTPSignature() ) { die(); } // If the message is valid, save the message in `/data/inbox/` $uuid = uuid(); $inbox_filename = $uuid . "." . urlencode( $inbox_type ) . ".json"; file_put_contents( $directories["inbox"] . "/{$inbox_filename}", json_encode( $inbox_message ) ); } // This inbox only responds to follow requests. // A remote server sends the inbox follow request which is a JSON file saying who they are. // The details of the remote user's server is saved to a file so that future messages can be delivered to the follower. // An accept request is cryptographically signed and POST'd back to the remote server. if ( "Follow" != $inbox_type ) { die(); } // Get the parameters $follower_id = $inbox_message["id"]; // E.g. https://mastodon.social/(unique id) $follower_actor = $inbox_message["actor"]; // E.g. https://mastodon.social/users/Edent // Get the actor's profile as JSON $follower_actor_details = getDataFromURl( $follower_actor ); // Save the actor's data in `/data/followers/` $follower_filename = urlencode( $follower_actor ); file_put_contents( $directories["followers"] . "/{$follower_filename}.json", json_encode( $follower_actor_details ) ); // Get the new follower's Inbox $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 sentMessageToSingle( $follower_inbox, $message ); 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. 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 request $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 because there's no message sent. $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 GET request $headers = array( "Host: {$host}", "Date: {$date}", "Signature: {$signature_header}", "Accept: application/activity+json, application/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 view( $style ) { global $username, $server, $realName, $summary, $directories; $rawUsername = rawurldecode( $username ); // What sort of viewable page is this? switch ( $style ) { case "home": $h1 = "HomePage"; $directory = "posts"; break; case "read": $h1 = "InBox"; $directory = "inbox"; break; } // Counters for followers, following, and posts $follower_files = glob( $directories["followers"] . "/*.json" ); $totalFollowers = count( $follower_files ); $following_files = glob( $directories["following"] . "/*.json" ); $totalFollowing = count( $following_files ); // Show the HTML page echo <<< HTML {$h1} {$realName}

{$realName}

{$summary}

Following: {$totalFollowing} | Followers: {$totalFollowers}

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() { if ( isset( $_GET["announce"] ) && filter_var( $_GET["announce"], FILTER_VALIDATE_URL ) ) { $announceURl = $_GET["announce"]; } else { $announceURl = ""; } if ( isset( $_GET["like"] ) && filter_var( $_GET["like"], FILTER_VALIDATE_URL ) ) { $likeURl = $_GET["like"]; } else { $likeURl = ""; } if ( isset( $_GET["reply"] ) && filter_var( $_GET["reply"], FILTER_VALIDATE_URL ) ) { $replyURl = $_GET["reply"]; } else { $replyURl = ""; } echo <<< HTML Send Message
Send a message









Like a post




Boost a post




HTML; die(); } // Send Endpoint: // This takes the submitted message and checks the password is correct. // It reads all the followers' data in `data/followers`. // It constructs a list of shared inboxes and unique inboxes. // It sends the message to every server that is following this account. function send() { global $password, $server, $username, $key_private, $directories; // Does the posted password match the stored password? if( $password != $_POST["password"] ) { die(); } // What sort of message is being sent? $type = $_POST["type"]; // Likes and Announces have an identical message structure if ( "Like" == $type || "Announce" == $type ) { // Was a URl sent? if ( isset( $_POST["postURl"] ) && filter_var( $_POST["postURl"], FILTER_VALIDATE_URL ) ) { $postURl = $_POST["postURl"]; } else { echo "No valid URl sent."; die(); } if ( "Like" == $type ) { // The message will need to be sent to the inbox of the author of the message $inbox_single = getInboxFromMessageURl( $postURl ); } // Outgoing Message ID $guid = uuid(); // Construct the Message $message = [ "@context" => "https://www.w3.org/ns/activitystreams", "id" => "https://{$server}/posts/{$guid}.json", "type" => $type, "actor" => "https://{$server}/{$username}", "published"=> date( "c" ), "object" => $postURl ]; // Announces are sent to an audience // The audience is public and it is sent to all followers // TODO: Let the original poster know we boosted them if ( $type == "Announce" ) { $message = array_merge( $message, array( "to" => ["https://www.w3.org/ns/activitystreams#Public"], "cc" => ["https://{$server}/followers"]) ); } // Construct the Note // This is for saving in the logs $note = $message; } else if ( $type == "Create" ) { // Get the posted content $content = $_POST["content"]; // Is this a reply? if ( isset( $_POST["inReplyTo"] ) && filter_var( $_POST["inReplyTo"], FILTER_VALIDATE_URL ) ) { $inReplyTo = $_POST["inReplyTo"]; } else { $inReplyTo = null; } // Process the content into HTML to get hashtags etc list( "HTML" => $content, "TagArray" => $tags ) = process_content( $content ); // Is there an image attached? if ( isset( $_FILES['image']['tmp_name'] ) && ( "" != $_FILES['image']['tmp_name'] ) ) { // Get information about the image $image = $_FILES['image']['tmp_name']; $image_info = getimagesize( $image ); $image_ext = image_type_to_extension( $image_info[2] ); $image_mime = $image_info["mime"]; // Files are stored according to their hash // A hash of "abc123" is stored in "/images/abc123.jpg" $sha1 = sha1_file( $image ); $image_full_path = $directories["images"] . "/{$sha1}.{$image_ext}"; // Move media to the correct location move_uploaded_file( $image, $image_full_path ); // Get the alt text if ( isset( $_POST["alt"] ) ) { $alt = $_POST["alt"]; } else { $alt = ""; } // Construct the attachment value for the post $attachment = array( [ "type" => "Image", "mediaType" => "{$image_mime}", "url" => "https://{$server}/{$image_full_path}", "name" => $alt ] ); } else { $attachment = []; } // 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}", "inReplyTo" => $inReplyTo, "content" => $content, "contentMap" => ["en" => $content], "to" => ["https://www.w3.org/ns/activitystreams#Public"], "tag" => $tags, "attachment" => $attachment ]; // Construct the Message // The audience is public and it is sent to all followers $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 ]; } // Save the permalink $note_json = json_encode( $note ); file_put_contents( $directories["posts"] . "/{$guid}.json", print_r( $note_json, true ) ); // Is this message going to one user? (Usually a Like) if ( isset( $inbox_single ) ) { $messageSent = sentMessageToSingle( $inbox_single, $message ); } else { // Send to all the user's followers $messageSent = sendMessageToFollowers( $message ); } // Render the JSON so the user can see the POST has worked if ( $messageSent ) { header( "Location: https://{$server}/posts/{$guid}.json" ); die(); } else { echo "ERROR!"; die(); } } // POST a signed message to a single inbox function sentMessageToSingle( $inbox, $message ) { global $directories; $inbox_host = parse_url( $inbox, PHP_URL_HOST ); $inbox_path = parse_url( $inbox, PHP_URL_PATH ); // Generate the signed headers $headers = generate_signed_headers( $message, $inbox_host, $inbox_path, "POST" ); // POST the message and header to the requester's inbox $ch = curl_init( $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_setopt( $ch, CURLOPT_USERAGENT, USERAGENT ); curl_exec( $ch ); // Check for errors if( curl_errno( $ch ) ) { $timestamp = ( new DateTime() )->format( DATE_RFC3339_EXTENDED ); file_put_contents( $directories["logs"] . "/{$timestamp}.Error.txt", curl_error( $ch ) ); return false; } curl_close( $ch ); return true; } // POST a signed message to the inboxes of all followers function sendMessageToFollowers( $message ) { global $directories; // Read existing followers $followers = glob( $directories["followers"] . "/*.json" ); // Get all the inboxes $inboxes = []; foreach ( $followers as $follower ) { // Get the data about the follower $follower_info = json_decode( file_get_contents( $follower ), true ); // Some servers have "Shared inboxes" // If you have lots of followers on a single server, you only need to send the message once. if( isset( $follower_info["endpoints"]["sharedInbox"] ) ) { $sharedInbox = $follower_info["endpoints"]["sharedInbox"]; if ( !in_array( $sharedInbox, $inboxes ) ) { $inboxes[] = $sharedInbox; } } else { // If not, use the individual inbox $inbox = $follower_info["inbox"]; if ( !in_array( $inbox, $inboxes ) ) { $inboxes[] = $inbox; } } } // Prepare to use the multiple cURL handle // This makes it more efficient to send many simultaneous messages $mh = curl_multi_init(); // Loop through all the inboxes of the followers // Each server needs its own cURL handle // Each POST to an inbox needs to be signed separately foreach ( $inboxes as $inbox ) { $inbox_host = parse_url( $inbox, PHP_URL_HOST ); $inbox_path = parse_url( $inbox, PHP_URL_PATH ); // Generate the signed headers $headers = generate_signed_headers( $message, $inbox_host, $inbox_path, "POST" ); // POST the message and header to the requester's inbox $ch = curl_init( $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_setopt( $ch, CURLOPT_USERAGENT, USERAGENT ); // 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 ); return true; } // Content can be plain text. But to add clickable links and hashtags, it needs to be turned into HTML. // Tags are also included separately in the note function process_content( $content ) { global $server; // Convert any URls into hyperlinks $link_pattern = '/\bhttps?:\/\/\S+/iu'; // Sloppy regex $replacement = function ( $match ) { $url = htmlspecialchars( $match[0], ENT_QUOTES, "UTF-8" ); return "$url"; }; $content = preg_replace_callback( $link_pattern, $replacement, $content ); // Get any hashtags $hashtags = []; $hashtag_pattern = '/(?:^|\s)\#(\w+)/'; // Beginning of string, or whitespace, followed by # preg_match_all( $hashtag_pattern, $content, $hashtag_matches ); foreach ( $hashtag_matches[1] as $match ) { $hashtags[] = $match; } // Construct the tag value for the note object $tags = []; foreach ( $hashtags as $hashtag ) { $tags[] = array( "type" => "Hashtag", "name" => "#{$hashtag}", ); } // Add HTML links for hashtags into the text // Todo: Make these links do something. $content = preg_replace( $hashtag_pattern, " #$1", $content ); // Detect user mentions $usernames = []; $usernames_pattern = '/@(\S+)@(\S+)/'; // This is a *very* sloppy regex preg_match_all( $usernames_pattern, $content, $usernames_matches ); foreach ( $usernames_matches[0] as $match ) { $usernames[] = $match; } // Construct the mentions value for the note object // This goes in the generic "tag" property // TODO: Add this to the CC field & appropriate inbox foreach ( $usernames as $username ) { list( , $user, $domain ) = explode( "@", $username ); $tags[] = array( "type" => "Mention", "href" => "https://{$domain}/@{$user}", "name" => "{$username}" ); // Add HTML links to usernames $username_link = "$username"; $content = str_replace( $username, $username_link, $content ); } // Construct HTML breaks from carriage returns and line breaks $linebreak_patterns = array( "\r\n", "\r", "\n" ); // Variations of line breaks found in raw text $content = str_replace( $linebreak_patterns, "
", $content ); // Construct the content $content = "

{$content}

"; return [ "HTML" => $content, "TagArray" => $tags ]; } // When given the URl of a post, this looks up the post, finds the user, then returns their inbox or shared inbox function getInboxFromMessageURl( $url ) { // Get details about the message $messageData = getDataFromURl( $url ); // The author is the user who the message is attributed to if ( isset ( $messageData["attributedTo"] ) && filter_var( $messageData["attributedTo"], FILTER_VALIDATE_URL) ) { $profileData = getDataFromURl( $messageData["attributedTo"] ); } else { return null; } // Get the shared inbox or personal inbox if( isset( $profileData["endpoints"]["sharedInbox"] ) ) { $inbox = $profileData["endpoints"]["sharedInbox"]; } else { // If not, use the individual inbox $inbox = $profileData["inbox"]; } // Return the destination inbox if it is valid if ( filter_var( $inbox, FILTER_VALIDATE_URL) ) { return $inbox; } else { return null; } } // GET a request to a URl and returns structured data function getDataFromURl ( $url ) { // Check this is a valid https address if( ( filter_var( $url, FILTER_VALIDATE_URL) != true) || ( parse_url( $url, PHP_URL_SCHEME ) != "https" ) ) { die(); } // Split the URL $url_host = parse_url( $url, PHP_URL_HOST ); $url_path = parse_url( $url, PHP_URL_PATH ); // Generate signed headers for this request $headers = generate_signed_headers( null, $url_host, $url_path, "GET" ); // Set cURL options $ch = curl_init( $url ); curl_setopt( $ch, CURLOPT_RETURNTRANSFER, true ); curl_setopt( $ch, CURLOPT_HTTPHEADER, $headers ); curl_setopt( $ch, CURLOPT_USERAGENT, USERAGENT ); // Execute the cURL session $urlJSON = curl_exec( $ch ); // Check for errors if (curl_errno( $ch )) { // Handle cURL error die(); } // Close cURL session curl_close( $ch ); return json_decode( $urlJSON, true ); } // The Outbox contains a date-ordered list (newest first) of all the user's posts // This is optional. function outbox() { global $server, $username, $directories; // Get all posts $posts = array_reverse( glob( $directories["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(); } // This creates a UI for the user to follow another user function follow() { if ( isset( $_GET["account"] ) ) { $accountURl = htmlspecialchars( $_GET["account"] ); } else { $announceURl = ""; } echo <<< HTML Follow



HTML; die(); } // This receives a request to follow an external user // It looks up the external user's details // Then it sends a follow request // If the request is accepted, it saves the details in `data/following/` as a JSON file function follow_user() { global $password, $server, $username, $key_private, $directories; // Does the posted password match the stored password? if( $password != $_POST["password"] ) { echo "Wrong Password!"; die(); } // Get the posted content $user = $_POST["user"]; // Split the user (@user@example.com) into username and server list( , $follow_name, $follow_server ) = explode( "@", $user ); // Get the Webfinger // This request does not always need to be signed, but safest to do so anyway. $webfingerURl = "https://{$follow_server}/.well-known/webfinger?resource=acct:{$follow_name}@{$follow_server}"; $webfinger = getDataFromURl( $webfingerURl ); // Get the link to the user foreach( $webfinger["links"] as $link ) { if ( "self" == $link["rel"] ) { $profileURl = $link["href"]; } } if ( !isset( $profileURl ) ) { echo "No profile" . print_r( $webfinger, true ); die(); } // Get the user's details $profileData = getDataFromURl( $profileURl ); // Get the user's inbox $profileInbox = $profileData["inbox"]; // Create a follow request $guid = uuid(); $message = [ "@context" => "https://www.w3.org/ns/activitystreams", "id" => "https://{$server}/{$guid}", "type" => "Follow", "actor" => "https://{$server}/{$username}", "object" => $profileURl ]; // Sign a request to follow // The Accept is POSTed to the inbox on the server of the user who requested the follow sentMessageToSingle( $profileInbox, $message ); // Save the user's details $following_filename = urlencode( $profileURl ); file_put_contents( $directories["following"] . "/{$following_filename}.json", json_encode( $profileData ) ); // Render the JSON so the user can see the POST has worked header( "Location: https://{$server}/data/following/" . urlencode( $following_filename ) . ".json" ); die(); } // Verify the signature sent with the message. // This is optional // It is very confusing function verifyHTTPSignature() { global $input, $body, $server, $directories; // Get the headers send with the request $headers = getallheaders(); // Ensure the header keys match the format expected by the signature $headers = array_change_key_case( $headers, CASE_LOWER ); // 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}.Signature.Time_Failure.txt"; // Save headers and request data to the timestamped file in the logs directory file_put_contents( $directories["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 // See https://github.com/superseriousbusiness/gotosocial/issues/1186#issuecomment-1976166659 and https://github.com/snarfed/bridgy-fed/issues/430 for hs2019 if ( "SHA-256" == $digestAlgorithm || "hs2019" == $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}.Signature.Digest_Failure.txt"; // Save headers and request data to the timestamped file in the logs directory file_put_contents( $directories["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 = $_SERVER["REQUEST_URI"]; $signatureString .= "(request-target): {$method} {$target}\n"; } else if ( "host" == $signatureHeader ) { $host = strtolower( $_SERVER["HTTP_HOST"] ); $signatureString .= "host: {$host}\n"; } else { $signatureString .= "{$signatureHeader}: " . $headers[$signatureHeader] . "\n"; } } // Remove trailing newline $signatureString = trim( $signatureString ); // Get the Public Key // The link to the key might 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 $userData = getDataFromURl( $publicKeyURL ); $publicKey = $userData["publicKey"]["publicKeyPem"]; // Get the remaining parts $signature = base64_decode( $signatureParts["signature"] ); $algorithm = $signatureParts["algorithm"]; // There might be many different signing algorithms // TODO: Find a way to transform these automatically // See https://github.com/superseriousbusiness/gotosocial/issues/1186#issuecomment-1976166659 and https://github.com/snarfed/bridgy-fed/issues/430 for hs2019 if ( "hs2019" == $algorithm ) { $algorithm = "sha256"; } // 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 file_put_contents( $directories["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; } // The NodeInfo Protocol is used to identify servers. // It is looked up with `example.com/.well-known/nodeinfo` // See https://nodeinfo.diaspora.software/ function wk_nodeinfo() { global $server; $nodeinfo = array( "links" => array( array( "rel" => "self", "type" => "http://nodeinfo.diaspora.software/ns/schema/2.1", "href" => "https://{$server}/nodeinfo/2.1" ) ) ); header( "Content-Type: application/json" ); echo json_encode( $nodeinfo ); die(); } // The NodeInfo Protocol is used to identify servers. // It is looked up with `example.com/.well-known/nodeinfo` which points to this resource // See http://nodeinfo.diaspora.software/docson/index.html#/ns/schema/2.0#$$expand function nodeinfo() { global $server, $directories; // Get all posts $posts = glob( $directories["posts"] . "/*.json") ; // Number of posts $totalItems = count( $posts ); $nodeinfo = array( "version" => "2.1", // Version of the schema, not the software "software" => array( "name" => "Single File ActivityPub Server in PHP", "version" => "0.000000001", "repository" => "https://gitlab.com/edent/activitypub-single-php-file/" ), "protocols" => array( "activitypub"), "services" => array( "inbound" => array(), "outbound" => array() ), "openRegistrations" => false, "usage" => array( "users" => array( "total" => 1 ), "localPosts" => $totalItems ), "metadata"=> array( "nodeName" => "activitypub-single-php-file", "nodeDescription" => "This is a single PHP file which acts as an extremely basic ActivityPub server.", "spdx" => "AGPL-3.0-or-later" ) ); header( "Content-Type: application/json" ); echo json_encode( $nodeinfo ); die(); } // "One to stun, two to kill, three to make sure" die(); die(); die();