diff --git a/index.php b/index.php index b667ca5..35c22ff 100644 --- a/index.php +++ b/index.php @@ -9,6 +9,7 @@ * The Actor can send messages to followers. * The message can have linkable URls, hashtags, and mentions. * An image and alt text can be attached to the message. + * The Actor can follow remote accounts. * The Server saves logs about requests it receives and sends. * This code is NOT suitable for production use. * SPDX-License-Identifier: AGPL-3.0-or-later @@ -36,26 +37,29 @@ // Password for sending messages $password = "P4ssW0rd"; - /** No need to edit anything below here. **/ + /** No need to edit anything below here. But please go exploring! **/ // Internal data $server = $_SERVER["SERVER_NAME"]; // Do not change this! - // Set up where logs and messages go + // Set up where logs and messages go. + // If you want to, you can change these directories to something more suitable for you. $data = "data"; $directories = array( - "inbox" => "{$data}/inbox", - "followers" => "{$data}/followers", - "following" => "{$data}/following", - "logs" => "{$data}/logs", - "posts" => "posts", + "inbox" => "{$data}/inbox", + "followers" => "{$data}/followers", + "following" => "{$data}/following", + "logs" => "{$data}/logs", + "posts" => "posts", + "images" => "images", ); foreach ( $directories as $directory ) { if( !is_dir( $directory ) ) { mkdir( $data ); mkdir( $directory ); } } // Logging: - // ActivityPub is a "chatty" protocol. This takes all the requests your server receives and saves them as a datestamped text file. + // ActivityPub is a "chatty" protocol. + // This takes all the requests your server receives and saves them as a datestamped text file. // Get all headers and requests sent to this server $headers = print_r( getallheaders(), true ); @@ -72,19 +76,23 @@ // Get the type of request - used in the log filename if ( isset( $body["type"] ) ) { // Sanitise before using it in a filename - $type = "." . urlencode( $body["type"] ); + $type = urlencode( $body["type"] ); } else { // Sanitise the path requested - $type = "." . urlencode( $path ); + $type = urlencode( $path ); } - // Create a timestamp for the filename + // Create a timestamp for the filename. // This format has milliseconds, so should avoid logs being overwritten. // If you have > 1000 requests per second, please use a different server. $timestamp = ( new DateTime() )->format( DATE_RFC3339_EXTENDED ); + // Alternatively, use a UUID. These retain the date as hex encoded UNIX seconds. + // This means the log files will never clash - but it does make the filenames harder to read. + // $timestamp = uuid(); + // Filename for the log - $filename = "{$timestamp}{$type}.txt"; + $filename = "{$timestamp}.{$type}.txt"; // Save headers and request data to the timestamped file in the logs directory file_put_contents( $directories["logs"] . "/{$filename}", @@ -106,11 +114,11 @@ case rawurldecode( $username ): username(); // Mandatory. Static case "following": - following(); // Mandatory. Static + following(); // Mandatory. Can be static or dynamic. case "followers": - followers(); // Mandatory. Can be dynamic + followers(); // Mandatory. Can be static or dynamic. case "inbox": - inbox(); // Mandatory. Only accepts follow requests. + inbox(); // Mandatory. case "write": write(); // User interface for writing posts case "send": @@ -197,9 +205,10 @@ // Get all the files $following_files = glob( $directories["following"] . "/*.json"); - // Number of posts + // 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 ); @@ -224,9 +233,10 @@ // Get all the files $follower_files = glob( $directories["followers"] . "/*.json"); - // Number of posts + // 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 ); @@ -247,10 +257,6 @@ // 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. - // 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. function inbox() { global $body, $server, $username, $key_private, $directories; @@ -274,15 +280,17 @@ file_put_contents( $directories["inbox"] . "/{$inbox_filename}", json_encode( $inbox_message ) ); } - - // This inbox only responds to follow requests + // 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 - $follower_host = parse_url( $follower_actor, PHP_URL_HOST ); - $follower_path = parse_url( $follower_actor, PHP_URL_PATH ); + $follower_host = parse_url( $follower_actor, PHP_URL_HOST ); // E.g. mastodon.social + $follower_path = parse_url( $follower_actor, PHP_URL_PATH ); // E.g. /users/Edent // Get the actor's profile as JSON // Is the actor an https URl? @@ -305,7 +313,7 @@ // Check for errors if (curl_errno($ch)) { - // Handle cURL error + // TODO: Handle cURL error die(); } @@ -345,7 +353,7 @@ // 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 + // Generate the signed headers $headers = generate_signed_headers( $message, $follower_host, $follower_inbox_path, "POST" ); // POST the message and header to the requester's inbox @@ -430,7 +438,7 @@ "Accept: application/activity+json", ); } else if ( "GET" == $method ) { - // Sign the path, host, date - NO DIGEST + // 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 @@ -459,7 +467,7 @@ return $headers; } - // User Interface for Homepage: + // 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, $directories; @@ -491,10 +499,15 @@ HTML; // Loop through the posts foreach ($posts as $post) { + // Get the contents of the file $postJSON = file_get_contents($post); $postData = json_decode($postJSON, true); + + // Set up some common variables $postTime = $postData["published"]; $postHTML = $postData["content"]; + + // If the message has an image attached, display it. if ( isset($postData["attachment"]) ) { $postImgUrl = $postData["attachment"]["url"]; $postImgAlt = $postData["attachment"]["name"]; @@ -502,6 +515,7 @@ HTML; } else { $postImg = ""; } + // Display the post echo "

  • {$postHTML}
    {$postImg}
  • "; } @@ -546,8 +560,8 @@ HTML; // 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 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; @@ -572,12 +586,9 @@ HTML; // Files are stored according to their hash // A hash of "abc123" is stored in "/images/abc123.jpg" $sha1 = sha1_file( $image ); - $image_path = "images"; - $image_full_path = "{$image_path}/{$sha1}.{$image_ext}"; + $image_full_path = $directories["images"] . "/{$sha1}.{$image_ext}"; // Move media to the correct location - // Create a directory if it doesn't exist - if( ! is_dir( $image_path ) ) { mkdir( $image_path ); } move_uploaded_file( $image, $image_full_path ); // Get the alt text @@ -606,7 +617,7 @@ HTML; $guid = uuid(); // Construct the Note - // contentMap is used to prevent unnecessary "translate this post" pop ups + // `contentMap` is used to prevent unnecessary "translate this post" pop ups // hardcoded to English $note = [ "@context" => array( @@ -645,15 +656,16 @@ HTML; file_put_contents( $directories["posts"] . "/{$guid}.json", print_r( $note_json, true ) ); // Read existing followers - $followers = glob( "data/followers/*.json" ); + $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 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 ) ) { @@ -680,7 +692,7 @@ HTML; $inbox_host = parse_url( $inbox, PHP_URL_HOST ); $inbox_path = parse_url( $inbox, PHP_URL_PATH ); - // Get the signed headers + // Generate the signed headers $headers = generate_signed_headers( $message, $inbox_host, $inbox_path, "POST" ); // POST the message and header to the requester's inbox @@ -716,7 +728,7 @@ HTML; global $server; // Convert any URls into hyperlinks - $link_pattern = '/\bhttps?:\/\/\S+/iu'; + $link_pattern = '/\bhttps?:\/\/\S+/iu'; // Sloppy regex $replacement = function ( $match ) { $url = htmlspecialchars( $match[0], ENT_QUOTES, "UTF-8" ); return "$url"; @@ -741,6 +753,7 @@ HTML; } // Add HTML links for hashtags into the text + // Todo: Make these links do something. $content = preg_replace( $hashtag_pattern, " #$1", @@ -772,8 +785,8 @@ HTML; } // 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); + $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}

    "; @@ -858,14 +871,16 @@ HTML; // Get the posted content $user = $_POST["user"]; - // Split the user into username and server + // Split the user (@user@example.com) into username and server list( , $follow_name, $follow_server ) = explode( "@", $user ); // Get the Webfinger + // This request does not need to be signed. $webfingerURl = "https://{$follow_server}/.well-known/webfinger?resource=acct:{$follow_name}@{$follow_server}"; $webfingerJSON = file_get_contents( $webfingerURl ); $webfinger = json_decode( $webfingerJSON, true ); + // Get the link to the user foreach( $webfinger["links"] as $link ) { if ( "self" == $link["rel"] ) { $profileURl = $link["href"]; @@ -959,8 +974,9 @@ HTML; } // Validate the Digest - // It is the hash of the raw input string, in binary, encoded as base64 + // 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 );