From 2a694f82908d1d98f02bbe9daeaa080eed073fc1 Mon Sep 17 00:00:00 2001 From: Terence Eden Date: Fri, 23 Feb 2024 15:08:25 +0000 Subject: [PATCH] Use correct inboxes This is a BIG change. * Receive a follow request * Sign a request back to the follower to get their details * Save their details to disk * Read their inbox * Sign an Accept message and post to their inbox On posting * Read all the followers' details * Get all shared inboxes or, if they don't exist, unique inboxes * Sign and post to those inboxes --- README.md | 1 + index.php | 221 +++++++++++++++++++++++++++++++++++------------------- 2 files changed, 146 insertions(+), 76 deletions(-) diff --git a/README.md b/README.md index 06ef1da..cf326ff 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ There are no tests, no checks, no security features, no header verifications, no * The `index.php` file performs a specific action depending on the path requested. * Log files are saved as .txt in the `/logs` directory. * Post files are saved as .json in the `/posts` directory. +* Followers' details are saved as .json in the `/data/followers` directory. * This has sloppy support for linking #hashtags, https:// URls, and @ mentions. ## Requirements diff --git a/index.php b/index.php index a4bb6e8..604c70c 100644 --- a/index.php +++ b/index.php @@ -215,29 +215,57 @@ 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 ); + $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 ); - // 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 ); + // 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 { - $followers_json = array(); + die(); } - // 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 ) ); + // 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 + // Create the Accept message to the new follower $message = [ "@context" => "https://www.w3.org/ns/activitystreams", "id" => "https://{$server}/{$guid}", @@ -245,27 +273,20 @@ "actor" => "https://{$server}/{$username}", "object" => [ "@context" => "https://www.w3.org/ns/activitystreams", - "id" => $inbox_id, + "id" => $follower_id, "type" => $inbox_type, - "actor" => $inbox_actor, + "actor" => $follower_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"; - + // 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, $host, $path ); + $headers = generate_signed_headers( $message, $follower_host, $follower_inbox_path, "POST" ); - // 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 ); + $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) ); @@ -297,49 +318,79 @@ // 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 ) { + function generate_signed_headers( $message, $host, $path, $method ) { 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 ); + // Timestamp this message was sent + $date = date( "D, d M Y H:i:s \G\M\T" ); - // Full signature header - $signature_header = 'keyId="' . $keyId . '",algorithm="rsa-sha256",headers="(request-target) host date digest",signature="' . $signature_b64 . '"'; + // 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 ); - // 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", - ); + // 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; } @@ -406,7 +457,9 @@ HTML; // Send Endpoint: // This takes the submitted message and checks the password is correct. - // It reads the `followers.json` file and sends the message to every server that is following this account. + // 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; @@ -505,29 +558,45 @@ HTML; 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 ); + // Read existing followers + $followers = glob( "data/followers/*.json" ); + + // Get all the inboxes + $inboxes = []; + foreach ( $followers as $follower ) { + $follower_info = json_decode( file_get_contents( $follower ), true ); + + // Shared inboxes + 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 $mh = curl_multi_init(); - // Loop through all the severs of the followers + // 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 ( $hosts as $host ) { - // TODO: Not every host uses /inbox - $path = "/inbox"; - + foreach ( $inboxes as $inbox ) { + + $inbox_host = parse_url( $inbox, PHP_URL_HOST ); + $inbox_path = parse_url( $inbox, PHP_URL_PATH ); + // Get the signed headers - $headers = generate_signed_headers( $message, $host, $path ); - - // Specify the URL of the remote server - $remoteServerUrl = "https://{$host}{$path}"; + $headers = generate_signed_headers( $message, $inbox_host, $inbox_path, "POST" ); // POST the message and header to the requester's inbox - $ch = curl_init( $remoteServerUrl ); + $ch = curl_init( $inbox ); curl_setopt( $ch, CURLOPT_RETURNTRANSFER, true ); curl_setopt( $ch, CURLOPT_CUSTOMREQUEST, "POST" ); curl_setopt( $ch, CURLOPT_POSTFIELDS, json_encode($message) );