this repo has no description
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

format code

alice a97bd1c8 0b4cd597

+1447 -1404
+1447 -1404
index.php
··· 1 1 <?php 2 - /* 2 + /* 3 3 * "This code is not a code of honour... no highly esteemed code is commemorated here... nothing valued is here." 4 4 * "What is here is dangerous and repulsive to us. This message is a warning about danger." 5 5 * This is a rudimentary, single-file, low complexity, minimum functionality, ActivityPub server. ··· 16 16 * For more information, please re-read. 17 17 */ 18 18 19 - // Preamble: Set your details here 20 - // This is where you set up your account's name and bio. 21 - // You also need to provide a public/private keypair. 22 - // The posting endpoint is protected with a password that also needs to be set here. 19 + // Preamble: Set your details here 20 + // This is where you set up your account's name and bio. 21 + // You also need to provide a public/private keypair. 22 + // The posting endpoint is protected with a password that also needs to be set here. 23 23 24 - // Set up the Actor's information here, or in the .env file 25 - $env = parse_ini_file('.env'); 26 - // Edit these: 27 - $username = rawurlencode( $env["USERNAME"] ); // Type the @ username that you want. Do not include an "@". 28 - $realName = $env["REALNAME"]; // This is the user's "real" name. 29 - $summary = $env["SUMMARY"]; // This is the bio of your user. 24 + // Set up the Actor's information here, or in the .env file 25 + $env = parse_ini_file('.env'); 26 + // Edit these: 27 + $username = rawurlencode($env["USERNAME"]); // Type the @ username that you want. Do not include an "@". 28 + $realName = $env["REALNAME"]; // This is the user's "real" name. 29 + $summary = $env["SUMMARY"]; // This is the bio of your user. 30 30 31 - // Generate locally or from https://cryptotools.net/rsagen 32 - // Newlines must be replaced with "\n" 33 - $key_private = str_replace('\n', "\n", $env["KEY_PRIVATE"] ); 34 - $key_public = str_replace('\n', "\n", $env["KEY_PUBLIC"] ); 31 + // Generate locally or from https://cryptotools.net/rsagen 32 + // Newlines must be replaced with "\n" 33 + $key_private = str_replace('\n', "\n", $env["KEY_PRIVATE"]); 34 + $key_public = str_replace('\n', "\n", $env["KEY_PUBLIC"]); 35 35 36 - // Password for sending messages 37 - $password = $env["PASSWORD"]; 36 + // Password for sending messages 37 + $password = $env["PASSWORD"]; 38 38 39 - /** No need to edit anything below here. But please go exploring! **/ 39 + /** No need to edit anything below here. But please go exploring! **/ 40 40 41 - // Internal data 42 - $server = $_SERVER["SERVER_NAME"]; // Do not change this! 41 + // Internal data 42 + $server = $_SERVER["SERVER_NAME"]; // Do not change this! 43 43 44 - // Some requests require a User-Agent string. 45 - define( "USERAGENT", "activitybot-single-php-file/0.0" ); 44 + // Some requests require a User-Agent string. 45 + define("USERAGENT", "activitybot-single-php-file/0.0"); 46 46 47 - // Set up where to save logs, posts, and images. 48 - // You can change these directories to something more suitable if you like. 49 - $data = "data"; 50 - $directories = array( 51 - "inbox" => "{$data}/inbox", 52 - "followers" => "{$data}/followers", 53 - "following" => "{$data}/following", 54 - "logs" => "{$data}/logs", 55 - "posts" => "posts", 56 - "images" => "images", 57 - ); 58 - // Create the directories if they don't already exist. 59 - foreach ( $directories as $directory ) { 60 - if( !is_dir( $directory ) ) { mkdir( $data ); mkdir( $directory ); } 47 + // Set up where to save logs, posts, and images. 48 + // You can change these directories to something more suitable if you like. 49 + $data = "data"; 50 + $directories = array( 51 + "inbox" => "{$data}/inbox", 52 + "followers" => "{$data}/followers", 53 + "following" => "{$data}/following", 54 + "logs" => "{$data}/logs", 55 + "posts" => "posts", 56 + "images" => "images", 57 + ); 58 + // Create the directories if they don't already exist. 59 + foreach ($directories as $directory) { 60 + if (!is_dir($directory)) { 61 + mkdir($data); 62 + mkdir($directory); 61 63 } 64 + } 62 65 63 - // Get the information sent to this server 64 - $input = file_get_contents( "php://input" ); 65 - $body = json_decode( $input,true ); 66 - $bodyData = print_r( $body, true ); 67 - 68 - // If the root has been requested, manually set the path to `/` 69 - !empty( $_GET["path"] ) ? $path = $_GET["path"] : $path = "/"; 66 + // Get the information sent to this server 67 + $input = file_get_contents("php://input"); 68 + $body = json_decode($input, true); 69 + $bodyData = print_r($body, true); 70 + 71 + // If the root has been requested, manually set the path to `/` 72 + !empty($_GET["path"]) ? $path = $_GET["path"] : $path = "/"; 70 73 71 - // Routing: 72 - // The .htaccess changes /whatever to /?path=whatever 73 - // This runs the function of the path requested. 74 - switch ( $path ) { 75 - case "/.well-known/webfinger": 76 - webfinger(); // Mandatory. Static. 77 - case "/.well-known/nodeinfo": 78 - wk_nodeinfo(); // Optional. Static. 79 - case "/nodeinfo/2.1": 80 - nodeinfo(); // Optional. Static. 81 - case "/". rawurldecode( $username ): 82 - case "/@" . rawurldecode( $username ): // Some software assumes usernames start with an `@` 83 - username(); // Mandatory. Static 84 - case "/following": 85 - following(); // Mandatory. Can be static or dynamic. 86 - case "/followers": 87 - followers(); // Mandatory. Can be static or dynamic. 88 - case "/inbox": 89 - inbox(); // Mandatory. 90 - case "/outbox": 91 - outbox(); // Optional. Dynamic. 92 - case "/action/send": 93 - send(); // API for posting content to the Fediverse. 94 - case "/action/follow": 95 - follow(); // API for following other accounts 96 - case "/action/unfollow": 97 - unfollow(); // API for unfollowing accounts 98 - case "/": 99 - view( "home" );// User interface for seeing what the user has posted. 100 - default: 101 - echo($path); 102 - header("HTTP/1.1 404 Not Found"); 103 - die(); 104 - } 74 + // Routing: 75 + // The .htaccess changes /whatever to /?path=whatever 76 + // This runs the function of the path requested. 77 + switch ($path) { 78 + case "/.well-known/webfinger": 79 + webfinger(); // Mandatory. Static. 80 + case "/.well-known/nodeinfo": 81 + wk_nodeinfo(); // Optional. Static. 82 + case "/nodeinfo/2.1": 83 + nodeinfo(); // Optional. Static. 84 + case "/" . rawurldecode($username): 85 + case "/@" . rawurldecode($username): // Some software assumes usernames start with an `@` 86 + username(); // Mandatory. Static 87 + case "/following": 88 + following(); // Mandatory. Can be static or dynamic. 89 + case "/followers": 90 + followers(); // Mandatory. Can be static or dynamic. 91 + case "/inbox": 92 + inbox(); // Mandatory. 93 + case "/outbox": 94 + outbox(); // Optional. Dynamic. 95 + case "/action/send": 96 + send(); // API for posting content to the Fediverse. 97 + case "/action/follow": 98 + follow(); // API for following other accounts 99 + case "/action/unfollow": 100 + unfollow(); // API for unfollowing accounts 101 + case "/": 102 + view("home"); // User interface for seeing what the user has posted. 103 + default: 104 + echo ($path); 105 + header("HTTP/1.1 404 Not Found"); 106 + die(); 107 + } 105 108 106 - // The WebFinger Protocol is used to identify accounts. 107 - // It is requested with `example.com/.well-known/webfinger?resource=acct:username@example.com` 108 - // This server only has one user, so it ignores the query string and always returns the same details. 109 - function webfinger() { 110 - global $username, $server; 109 + // The WebFinger Protocol is used to identify accounts. 110 + // It is requested with `example.com/.well-known/webfinger?resource=acct:username@example.com` 111 + // This server only has one user, so it ignores the query string and always returns the same details. 112 + function webfinger() 113 + { 114 + global $username, $server; 111 115 112 - $webfinger = array( 113 - "subject" => "acct:{$username}@{$server}", 114 - "links" => array( 115 - array( 116 - "rel" => "self", 117 - "type" => "application/activity+json", 118 - "href" => "https://{$server}/{$username}" 119 - ) 116 + $webfinger = array( 117 + "subject" => "acct:{$username}@{$server}", 118 + "links" => array( 119 + array( 120 + "rel" => "self", 121 + "type" => "application/activity+json", 122 + "href" => "https://{$server}/{$username}" 120 123 ) 121 - ); 122 - header( "Content-Type: application/json" ); 123 - echo json_encode( $webfinger ); 124 - die(); 125 - } 124 + ) 125 + ); 126 + header("Content-Type: application/json"); 127 + echo json_encode($webfinger); 128 + die(); 129 + } 126 130 127 - // User: 128 - // Requesting `example.com/username` returns a JSON document with the user's information. 129 - function username() { 130 - global $username, $realName, $summary, $server, $key_public; 131 + // User: 132 + // Requesting `example.com/username` returns a JSON document with the user's information. 133 + function username() 134 + { 135 + global $username, $realName, $summary, $server, $key_public; 131 136 132 - // Was HTML requested? 133 - // If so, probably a browser. Redirect to homepage. 134 - foreach (getallheaders() as $name => $value) { 135 - if ("Accept" == $name) { 136 - $accepts = explode( ",", $value ); 137 - if ( "text/html" == $accepts[0] ) { 138 - header( "Location: https://{$server}/" ); 139 - die(); 140 - } 137 + // Was HTML requested? 138 + // If so, probably a browser. Redirect to homepage. 139 + foreach (getallheaders() as $name => $value) { 140 + if ("Accept" == $name) { 141 + $accepts = explode(",", $value); 142 + if ("text/html" == $accepts[0]) { 143 + header("Location: https://{$server}/"); 144 + die(); 141 145 } 142 146 } 143 - 144 - $user = array( 145 - "@context" => [ 146 - "https://www.w3.org/ns/activitystreams", 147 - "https://w3id.org/security/v1" 148 - ], 149 - "id" => "https://{$server}/{$username}", 150 - "type" => "Application", 151 - "following" => "https://{$server}/following", 152 - "followers" => "https://{$server}/followers", 153 - "inbox" => "https://{$server}/inbox", 154 - "outbox" => "https://{$server}/outbox", 155 - "preferredUsername" => rawurldecode( $username ), 156 - "name" => "{$realName}", 157 - "summary" => "{$summary}", 158 - "url" => "https://{$server}/{$username}", 159 - "manuallyApprovesFollowers" => false, 160 - "discoverable" => true, 161 - "published" => "2024-02-29T12:34:56Z", 162 - "icon" => [ 163 - "type" => "Image", 164 - "mediaType" => "image/png", 165 - "url" => "https://{$server}/icon.png" 166 - ], 167 - "image" => [ 168 - "type" => "Image", 169 - "mediaType" => "image/png", 170 - "url" => "https://{$server}/banner.png" 171 - ], 172 - "publicKey" => [ 173 - "id" => "https://{$server}/{$username}#main-key", 174 - "owner" => "https://{$server}/{$username}", 175 - "publicKeyPem" => $key_public 176 - ] 177 - ); 178 - header( "Content-Type: application/activity+json" ); 179 - echo json_encode( $user ); 180 - die(); 181 147 } 182 148 183 - // Follower / Following: 184 - // These JSON documents show how many users are following / followers-of this account. 185 - // The information here is self-attested. So you can lie and use any number you want. 186 - function following() { 187 - global $server, $directories; 149 + $user = array( 150 + "@context" => [ 151 + "https://www.w3.org/ns/activitystreams", 152 + "https://w3id.org/security/v1" 153 + ], 154 + "id" => "https://{$server}/{$username}", 155 + "type" => "Application", 156 + "following" => "https://{$server}/following", 157 + "followers" => "https://{$server}/followers", 158 + "inbox" => "https://{$server}/inbox", 159 + "outbox" => "https://{$server}/outbox", 160 + "preferredUsername" => rawurldecode($username), 161 + "name" => "{$realName}", 162 + "summary" => "{$summary}", 163 + "url" => "https://{$server}/{$username}", 164 + "manuallyApprovesFollowers" => false, 165 + "discoverable" => true, 166 + "published" => "2024-02-29T12:34:56Z", 167 + "icon" => [ 168 + "type" => "Image", 169 + "mediaType" => "image/png", 170 + "url" => "https://{$server}/icon.png" 171 + ], 172 + "image" => [ 173 + "type" => "Image", 174 + "mediaType" => "image/png", 175 + "url" => "https://{$server}/banner.png" 176 + ], 177 + "publicKey" => [ 178 + "id" => "https://{$server}/{$username}#main-key", 179 + "owner" => "https://{$server}/{$username}", 180 + "publicKeyPem" => $key_public 181 + ] 182 + ); 183 + header("Content-Type: application/activity+json"); 184 + echo json_encode($user); 185 + die(); 186 + } 188 187 189 - // Get all the files 190 - $following_files = glob( $directories["following"] . "/*.json" ); 191 - // Number of users 192 - $totalItems = count( $following_files ); 188 + // Follower / Following: 189 + // These JSON documents show how many users are following / followers-of this account. 190 + // The information here is self-attested. So you can lie and use any number you want. 191 + function following() 192 + { 193 + global $server, $directories; 193 194 194 - // Sort users by most recent first 195 - usort( $following_files, function( $a, $b ) { 196 - return filemtime($b) - filemtime($a); 197 - }); 195 + // Get all the files 196 + $following_files = glob($directories["following"] . "/*.json"); 197 + // Number of users 198 + $totalItems = count($following_files); 198 199 199 - // Create a list of all accounts being followed 200 - $items = array(); 201 - foreach ( $following_files as $following_file ) { 202 - $following = json_decode( file_get_contents( $following_file ), true ); 203 - $items[] = $following["id"]; 204 - } 200 + // Sort users by most recent first 201 + usort($following_files, function ($a, $b) { 202 + return filemtime($b) - filemtime($a); 203 + }); 205 204 206 - $following = array( 207 - "@context" => "https://www.w3.org/ns/activitystreams", 208 - "id" => "https://{$server}/following", 209 - "type" => "Collection", 210 - "totalItems" => $totalItems, 211 - "items" => $items 212 - ); 213 - header( "Content-Type: application/activity+json" ); 214 - echo json_encode( $following ); 215 - die(); 205 + // Create a list of all accounts being followed 206 + $items = array(); 207 + foreach ($following_files as $following_file) { 208 + $following = json_decode(file_get_contents($following_file), true); 209 + $items[] = $following["id"]; 216 210 } 217 - function followers() { 218 - global $server, $directories; 219 - // The number of followers is self-reported. 220 - // You can set this to any number you like. 221 - 222 - // Get all the files 223 - $follower_files = glob( $directories["followers"] . "/*.json" ); 224 - // Number of users 225 - $totalItems = count( $follower_files ); 211 + 212 + $following = array( 213 + "@context" => "https://www.w3.org/ns/activitystreams", 214 + "id" => "https://{$server}/following", 215 + "type" => "Collection", 216 + "totalItems" => $totalItems, 217 + "items" => $items 218 + ); 219 + header("Content-Type: application/activity+json"); 220 + echo json_encode($following); 221 + die(); 222 + } 223 + function followers() 224 + { 225 + global $server, $directories; 226 + // The number of followers is self-reported. 227 + // You can set this to any number you like. 226 228 227 - // Sort users by most recent first 228 - usort( $follower_files, function( $a, $b ) { 229 - return filemtime($b) - filemtime($a); 230 - }); 229 + // Get all the files 230 + $follower_files = glob($directories["followers"] . "/*.json"); 231 + // Number of users 232 + $totalItems = count($follower_files); 231 233 232 - // Create a list of everyone being followed 233 - $items = array(); 234 - foreach ( $follower_files as $follower_file ) { 235 - $following = json_decode( file_get_contents( $follower_file ), true ); 236 - $items[] = $following["id"]; 237 - } 234 + // Sort users by most recent first 235 + usort($follower_files, function ($a, $b) { 236 + return filemtime($b) - filemtime($a); 237 + }); 238 238 239 - $followers = array( 240 - "@context" => "https://www.w3.org/ns/activitystreams", 241 - "id" => "https://{$server}/followers", 242 - "type" => "Collection", 243 - "totalItems" => $totalItems, 244 - "items" => $items 245 - ); 246 - header( "Content-Type: application/activity+json" ); 247 - echo json_encode( $followers ); 248 - die(); 239 + // Create a list of everyone being followed 240 + $items = array(); 241 + foreach ($follower_files as $follower_file) { 242 + $following = json_decode(file_get_contents($follower_file), true); 243 + $items[] = $following["id"]; 249 244 } 250 245 251 - // Inbox: 252 - // The `/inbox` is the main server. It receives all requests. 253 - function inbox() { 254 - global $body, $server, $username, $key_private, $directories; 246 + $followers = array( 247 + "@context" => "https://www.w3.org/ns/activitystreams", 248 + "id" => "https://{$server}/followers", 249 + "type" => "Collection", 250 + "totalItems" => $totalItems, 251 + "items" => $items 252 + ); 253 + header("Content-Type: application/activity+json"); 254 + echo json_encode($followers); 255 + die(); 256 + } 255 257 256 - // Get the message, type, and ID 257 - $inbox_message = $body; 258 - $inbox_type = $inbox_message["type"]; 258 + // Inbox: 259 + // The `/inbox` is the main server. It receives all requests. 260 + function inbox() 261 + { 262 + global $body, $server, $username, $key_private, $directories; 259 263 260 - // This inbox only sends responses to follow requests. 261 - // A remote server sends the inbox a follow request which is a JSON file saying who they are. 262 - // The details of the remote user's server is saved to a file so that future messages can be delivered to the follower. 263 - // An accept request is cryptographically signed and POST'd back to the remote server. 264 - if ( "Follow" == $inbox_type ) { 265 - // Validate HTTP Message Signature 266 - if ( !verifyHTTPSignature() ) { 267 - header("HTTP/1.1 401 Unauthorized"); 268 - die(); 269 - } 264 + // Get the message, type, and ID 265 + $inbox_message = $body; 266 + $inbox_type = $inbox_message["type"]; 270 267 271 - // Get the parameters 272 - $follower_id = $inbox_message["id"]; // E.g. https://mastodon.social/(unique id) 273 - $follower_actor = $inbox_message["actor"]; // E.g. https://mastodon.social/users/Edent 274 - 275 - // Get the actor's profile as JSON 276 - $follower_actor_details = getDataFromURl( $follower_actor ); 268 + // This inbox only sends responses to follow requests. 269 + // A remote server sends the inbox a follow request which is a JSON file saying who they are. 270 + // The details of the remote user's server is saved to a file so that future messages can be delivered to the follower. 271 + // An accept request is cryptographically signed and POST'd back to the remote server. 272 + if ("Follow" == $inbox_type) { 273 + // Validate HTTP Message Signature 274 + if (!verifyHTTPSignature()) { 275 + header("HTTP/1.1 401 Unauthorized"); 276 + die(); 277 + } 277 278 278 - // Save the actor's data in `/data/followers/` 279 - $follower_filename = urlencode( $follower_actor ); 280 - file_put_contents( $directories["followers"] . "/{$follower_filename}.json", json_encode( $follower_actor_details ) ); 281 - 282 - // Get the new follower's Inbox 283 - $follower_inbox = $follower_actor_details["inbox"]; 279 + // Get the parameters 280 + $follower_id = $inbox_message["id"]; // E.g. https://mastodon.social/(unique id) 281 + $follower_actor = $inbox_message["actor"]; // E.g. https://mastodon.social/users/Edent 282 + 283 + // Get the actor's profile as JSON 284 + $follower_actor_details = getDataFromURl($follower_actor); 285 + 286 + // Save the actor's data in `/data/followers/` 287 + $follower_filename = urlencode($follower_actor); 288 + file_put_contents($directories["followers"] . "/{$follower_filename}.json", json_encode($follower_actor_details)); 289 + 290 + // Get the new follower's Inbox 291 + $follower_inbox = $follower_actor_details["inbox"]; 284 292 285 - // Response Message ID 286 - // This isn't used for anything important so could just be a random number 287 - $guid = uuid(); 293 + // Response Message ID 294 + // This isn't used for anything important so could just be a random number 295 + $guid = uuid(); 288 296 289 - // Create the Accept message to the new follower 290 - $message = [ 297 + // Create the Accept message to the new follower 298 + $message = [ 299 + "@context" => "https://www.w3.org/ns/activitystreams", 300 + "id" => "https://{$server}/{$guid}", 301 + "type" => "Accept", 302 + "actor" => "https://{$server}/{$username}", 303 + "object" => [ 291 304 "@context" => "https://www.w3.org/ns/activitystreams", 292 - "id" => "https://{$server}/{$guid}", 293 - "type" => "Accept", 294 - "actor" => "https://{$server}/{$username}", 295 - "object" => [ 296 - "@context" => "https://www.w3.org/ns/activitystreams", 297 - "id" => $follower_id, 298 - "type" => $inbox_type, 299 - "actor" => $follower_actor, 300 - "object" => "https://{$server}/{$username}", 301 - ] 302 - ]; 305 + "id" => $follower_id, 306 + "type" => $inbox_type, 307 + "actor" => $follower_actor, 308 + "object" => "https://{$server}/{$username}", 309 + ] 310 + ]; 303 311 304 - // The Accept is POSTed to the inbox on the server of the user who requested the follow 305 - sendMessageToSingle( $follower_inbox, $message ); 306 - } else { 307 - // Messages to ignore. 308 - // Some servers are very chatty. They send lots of irrelevant messages. 309 - // Before even bothering to validate them, we can delete them. 312 + // The Accept is POSTed to the inbox on the server of the user who requested the follow 313 + sendMessageToSingle($follower_inbox, $message); 314 + } else { 315 + // Messages to ignore. 316 + // Some servers are very chatty. They send lots of irrelevant messages. 317 + // Before even bothering to validate them, we can delete them. 310 318 311 - // This server doesn't handle Add, Remove, Reject, Favourite, Replies, Repost 312 - // See https://www.w3.org/wiki/ActivityPub/Primer 313 - if ( "Add" == $inbox_type || 314 - "Remove" == $inbox_type || 315 - "Reject" == $inbox_type || 316 - "Like" == $inbox_type || 317 - "Create" == $inbox_type || 318 - "Announce" == $inbox_type) { 319 - // TODO: Better HTTP header 320 - die(); 321 - } 319 + // This server doesn't handle Add, Remove, Reject, Favourite, Replies, Repost 320 + // See https://www.w3.org/wiki/ActivityPub/Primer 321 + if ( 322 + "Add" == $inbox_type || 323 + "Remove" == $inbox_type || 324 + "Reject" == $inbox_type || 325 + "Like" == $inbox_type || 326 + "Create" == $inbox_type || 327 + "Announce" == $inbox_type 328 + ) { 329 + // TODO: Better HTTP header 330 + die(); 331 + } 322 332 323 - // Get a list of every account following us 324 - // Get all the files 325 - $followers_files = glob( $directories["followers"] . "/*.json"); 326 - 327 - // Create a list of all accounts being followed 328 - $followers_ids = array(); 329 - foreach ( $followers_files as $follower_file ) { 330 - $follower = json_decode( file_get_contents( $follower_file ), true ); 331 - $followers_ids[] = $follower["id"]; 332 - } 333 + // Get a list of every account following us 334 + // Get all the files 335 + $followers_files = glob($directories["followers"] . "/*.json"); 333 336 334 - // Is this from someone following us? 335 - in_array( $inbox_message["actor"], $followers_ids ) ? $from_follower = true: $from_follower = false; 337 + // Create a list of all accounts being followed 338 + $followers_ids = array(); 339 + foreach ($followers_files as $follower_file) { 340 + $follower = json_decode(file_get_contents($follower_file), true); 341 + $followers_ids[] = $follower["id"]; 342 + } 336 343 337 - // As long as one of these is true, the server will process it 338 - if ( !$from_follower ) { 339 - // Don't bother processing it at all. 340 - die(); 341 - } 344 + // Is this from someone following us? 345 + in_array($inbox_message["actor"], $followers_ids) ? $from_follower = true : $from_follower = false; 342 346 343 - // Validate HTTP Message Signature 344 - if ( !verifyHTTPSignature() ) { die(); } 347 + // As long as one of these is true, the server will process it 348 + if (!$from_follower) { 349 + // Don't bother processing it at all. 350 + die(); 351 + } 345 352 346 - // If this is an Undo (Unfollow) try to process it 347 - if ( "Undo" == $inbox_type ) { 348 - undo( $inbox_message ); 349 - } elseif (in_array($inbox_type, ["Accept", "Reject"])) { 350 - processFollowResponse($inbox_message); 351 - } else { 352 - die(); 353 - } 353 + // Validate HTTP Message Signature 354 + if (!verifyHTTPSignature()) { 355 + die(); 354 356 } 355 - 356 - // If the message is valid, save the message in `/data/inbox/` 357 - $uuid = uuid( $inbox_message ); 358 - $inbox_filename = $uuid . "." . urlencode( $inbox_type ) . ".json"; 359 - file_put_contents( $directories["inbox"] . "/{$inbox_filename}", json_encode( $inbox_message ) ); 360 357 361 - die(); 358 + // If this is an Undo (Unfollow) try to process it 359 + if ("Undo" == $inbox_type) { 360 + undo($inbox_message); 361 + } elseif (in_array($inbox_type, ["Accept", "Reject"])) { 362 + processFollowResponse($inbox_message); 363 + } else { 364 + die(); 365 + } 362 366 } 363 367 364 - // Unique ID: 365 - // Every message sent should have a unique ID. 366 - // This can be anything you like. Some servers use a random number. 367 - // I prefer a date-sortable string. 368 - function uuid( $message = null) { 369 - // UUIDs that this server *sends* will be [timestamp]-[random] 370 - // 65e99ab4-5d43-f074-b43e-463f9c5cf05c 371 - if ( is_null( $message ) ) { 372 - return sprintf( "%08x-%04x-%04x-%04x-%012x", 373 - time(), 374 - mt_rand(0, 0xffff), 375 - mt_rand(0, 0xffff), 376 - mt_rand(0, 0x3fff) | 0x8000, 377 - mt_rand(0, 0xffffffffffff) 378 - ); 379 - } else { 380 - // UUIDs that this server *saves* will be [timestamp]-[hash of message ID] 381 - // 65eadace-8f434346648f6b96df89dda901c5176b10a6d83961dd3c1ac88b59b2dc327aa4 368 + // If the message is valid, save the message in `/data/inbox/` 369 + $uuid = uuid($inbox_message); 370 + $inbox_filename = $uuid . "." . urlencode($inbox_type) . ".json"; 371 + file_put_contents($directories["inbox"] . "/{$inbox_filename}", json_encode($inbox_message)); 382 372 383 - // The message might have its own object 384 - if ( isset( $message["object"]["id"] ) ) { 385 - $id = $message["object"]["id"]; 386 - } else { 387 - $id = $message["id"]; 388 - } 373 + die(); 374 + } 375 + 376 + // Unique ID: 377 + // Every message sent should have a unique ID. 378 + // This can be anything you like. Some servers use a random number. 379 + // I prefer a date-sortable string. 380 + function uuid($message = null) 381 + { 382 + // UUIDs that this server *sends* will be [timestamp]-[random] 383 + // 65e99ab4-5d43-f074-b43e-463f9c5cf05c 384 + if (is_null($message)) { 385 + return sprintf( 386 + "%08x-%04x-%04x-%04x-%012x", 387 + time(), 388 + mt_rand(0, 0xffff), 389 + mt_rand(0, 0xffff), 390 + mt_rand(0, 0x3fff) | 0x8000, 391 + mt_rand(0, 0xffffffffffff) 392 + ); 393 + } else { 394 + // UUIDs that this server *saves* will be [timestamp]-[hash of message ID] 395 + // 65eadace-8f434346648f6b96df89dda901c5176b10a6d83961dd3c1ac88b59b2dc327aa4 389 396 390 - return sprintf( "%08x", time() ) . "-" . hash( "sha256", $id ); 397 + // The message might have its own object 398 + if (isset($message["object"]["id"])) { 399 + $id = $message["object"]["id"]; 400 + } else { 401 + $id = $message["id"]; 391 402 } 403 + 404 + return sprintf("%08x", time()) . "-" . hash("sha256", $id); 392 405 } 406 + } 393 407 394 - // Headers: 395 - // Every message that your server sends needs to be cryptographically signed with your Private Key. 396 - // This is a complicated process. 397 - // Please read https://blog.joinmastodon.org/2018/07/how-to-make-friends-and-verify-requests/ for more information. 398 - function generate_signed_headers( $message, $host, $path, $method ) { 399 - global $server, $username, $key_private; 400 - 401 - // Location of the Public Key 402 - $keyId = "https://{$server}/{$username}#main-key"; 408 + // Headers: 409 + // Every message that your server sends needs to be cryptographically signed with your Private Key. 410 + // This is a complicated process. 411 + // Please read https://blog.joinmastodon.org/2018/07/how-to-make-friends-and-verify-requests/ for more information. 412 + function generate_signed_headers($message, $host, $path, $method) 413 + { 414 + global $server, $username, $key_private; 403 415 404 - // Get the Private Key 405 - $signer = openssl_get_privatekey( $key_private ); 416 + // Location of the Public Key 417 + $keyId = "https://{$server}/{$username}#main-key"; 406 418 407 - // Timestamp this message was sent 408 - $date = date( "D, d M Y H:i:s \G\M\T" ); 419 + // Get the Private Key 420 + $signer = openssl_get_privatekey($key_private); 409 421 410 - // There are subtly different signing requirements for POST and GET. 411 - if ( "POST" == $method ) { 412 - // Encode the message object to JSON 413 - $message_json = json_encode( $message ); 414 - // Generate signing variables 415 - $hash = hash( "sha256", $message_json, true ); 416 - $digest = base64_encode( $hash ); 422 + // Timestamp this message was sent 423 + $date = date("D, d M Y H:i:s \G\M\T"); 417 424 418 - // Sign the path, host, date, and digest 419 - $stringToSign = "(request-target): post $path\nhost: $host\ndate: $date\ndigest: SHA-256=$digest"; 420 - 421 - // The signing function returns the variable $signature 422 - // https://www.php.net/manual/en/function.openssl-sign.php 423 - openssl_sign( 424 - $stringToSign, 425 - $signature, 426 - $signer, 427 - OPENSSL_ALGO_SHA256 428 - ); 429 - // Encode the signature 430 - $signature_b64 = base64_encode( $signature ); 425 + // There are subtly different signing requirements for POST and GET. 426 + if ("POST" == $method) { 427 + // Encode the message object to JSON 428 + $message_json = json_encode($message); 429 + // Generate signing variables 430 + $hash = hash("sha256", $message_json, true); 431 + $digest = base64_encode($hash); 431 432 432 - // Full signature header 433 - $signature_header = 'keyId="' . $keyId . '",algorithm="rsa-sha256",headers="(request-target) host date digest",signature="' . $signature_b64 . '"'; 433 + // Sign the path, host, date, and digest 434 + $stringToSign = "(request-target): post $path\nhost: $host\ndate: $date\ndigest: SHA-256=$digest"; 434 435 435 - // Header for POST request 436 - $headers = array( 437 - "Host: {$host}", 438 - "Date: {$date}", 439 - "Digest: SHA-256={$digest}", 440 - "Signature: {$signature_header}", 441 - "Content-Type: application/activity+json", 442 - "Accept: application/activity+json", 443 - ); 444 - } else if ( "GET" == $method ) { 445 - // Sign the path, host, date - NO DIGEST because there's no message sent. 446 - $stringToSign = "(request-target): get $path\nhost: $host\ndate: $date"; 447 - 448 - // The signing function returns the variable $signature 449 - // https://www.php.net/manual/en/function.openssl-sign.php 450 - openssl_sign( 451 - $stringToSign, 452 - $signature, 453 - $signer, 454 - OPENSSL_ALGO_SHA256 455 - ); 456 - // Encode the signature 457 - $signature_b64 = base64_encode( $signature ); 436 + // The signing function returns the variable $signature 437 + // https://www.php.net/manual/en/function.openssl-sign.php 438 + openssl_sign( 439 + $stringToSign, 440 + $signature, 441 + $signer, 442 + OPENSSL_ALGO_SHA256 443 + ); 444 + // Encode the signature 445 + $signature_b64 = base64_encode($signature); 458 446 459 - // Full signature header 460 - $signature_header = 'keyId="' . $keyId . '",algorithm="rsa-sha256",headers="(request-target) host date",signature="' . $signature_b64 . '"'; 447 + // Full signature header 448 + $signature_header = 'keyId="' . $keyId . '",algorithm="rsa-sha256",headers="(request-target) host date digest",signature="' . $signature_b64 . '"'; 461 449 462 - // Header for GET request 463 - $headers = array( 464 - "Host: {$host}", 465 - "Date: {$date}", 466 - "Signature: {$signature_header}", 467 - "Accept: application/activity+json, application/json", 468 - ); 469 - } 450 + // Header for POST request 451 + $headers = array( 452 + "Host: {$host}", 453 + "Date: {$date}", 454 + "Digest: SHA-256={$digest}", 455 + "Signature: {$signature_header}", 456 + "Content-Type: application/activity+json", 457 + "Accept: application/activity+json", 458 + ); 459 + } else if ("GET" == $method) { 460 + // Sign the path, host, date - NO DIGEST because there's no message sent. 461 + $stringToSign = "(request-target): get $path\nhost: $host\ndate: $date"; 462 + 463 + // The signing function returns the variable $signature 464 + // https://www.php.net/manual/en/function.openssl-sign.php 465 + openssl_sign( 466 + $stringToSign, 467 + $signature, 468 + $signer, 469 + OPENSSL_ALGO_SHA256 470 + ); 471 + // Encode the signature 472 + $signature_b64 = base64_encode($signature); 473 + 474 + // Full signature header 475 + $signature_header = 'keyId="' . $keyId . '",algorithm="rsa-sha256",headers="(request-target) host date",signature="' . $signature_b64 . '"'; 470 476 471 - return $headers; 477 + // Header for GET request 478 + $headers = array( 479 + "Host: {$host}", 480 + "Date: {$date}", 481 + "Signature: {$signature_header}", 482 + "Accept: application/activity+json, application/json", 483 + ); 472 484 } 473 485 474 - // User Interface for Homepage. 475 - // This creates a basic HTML page. This content appears when someone visits the root of your site. 476 - function view( $style ) { 477 - global $username, $server, $realName, $summary, $directories; 478 - $rawUsername = rawurldecode( $username ); 486 + return $headers; 487 + } 488 + 489 + // User Interface for Homepage. 490 + // This creates a basic HTML page. This content appears when someone visits the root of your site. 491 + function view($style) 492 + { 493 + global $username, $server, $realName, $summary, $directories; 494 + $rawUsername = rawurldecode($username); 495 + 496 + $h1 = "HomePage"; 497 + $directory = "posts"; 479 498 480 - $h1 = "HomePage"; 481 - $directory = "posts"; 482 - 483 - // Counters for followers, following, and posts 484 - $follower_files = glob( $directories["followers"] . "/*.json" ); 485 - $totalFollowers = count( $follower_files ); 486 - $following_files = glob( $directories["following"] . "/*.json" ); 487 - $totalFollowing = count( $following_files ); 499 + // Counters for followers, following, and posts 500 + $follower_files = glob($directories["followers"] . "/*.json"); 501 + $totalFollowers = count($follower_files); 502 + $following_files = glob($directories["following"] . "/*.json"); 503 + $totalFollowing = count($following_files); 488 504 489 - // Show the HTML page 490 - echo <<< HTML 505 + // Show the HTML page 506 + echo <<< HTML 491 507 <!DOCTYPE html> 492 508 <html lang="en-GB"> 493 509 <head> ··· 541 557 </header> 542 558 <ul> 543 559 HTML; 544 - // Get all the files in the directory 545 - $message_files = array_reverse( glob( "posts" . "/*.json") ); 560 + // Get all the files in the directory 561 + $message_files = array_reverse(glob("posts" . "/*.json")); 562 + 563 + // There are lots of messages. The UI will only show 200. 564 + $message_files = array_slice($message_files, 0, 1000); 565 + 566 + // Loop through the messages, get their conent: 567 + // Ensure messages are in the right order. 568 + $messages_ordered = []; 569 + foreach ($message_files as $message_file) { 570 + // Split the filename 571 + $file_parts = explode(".", $message_file); 572 + $type = $file_parts[1]; 573 + 574 + // Get the contents of the JSON 575 + $message = json_decode(file_get_contents($message_file), true); 546 576 547 - // There are lots of messages. The UI will only show 200. 548 - $message_files = array_slice( $message_files, 0, 1000 ); 577 + $published = $message["published"]; 549 578 550 - // Loop through the messages, get their conent: 551 - // Ensure messages are in the right order. 552 - $messages_ordered = []; 553 - foreach ( $message_files as $message_file ) { 554 - // Split the filename 555 - $file_parts = explode(".", $message_file ); 556 - $type = $file_parts[1]; 579 + // Place in an array where the key is the timestamp 580 + $messages_ordered[$published] = $message; 581 + } 557 582 558 - // Get the contents of the JSON 559 - $message = json_decode( file_get_contents( $message_file ), true ); 583 + // HTML is *probably* sanitised by the sender. But let's not risk it, eh? 584 + // Using the allow-list from https://docs.joinmastodon.org/spec/activitypub/#sanitization 585 + $allowed_elements = ["p", "span", "br", "a", "del", "pre", "code", "em", "strong", "b", "i", "u", "ul", "ol", "li", "blockquote"]; 586 + // Print the items in a list 587 + foreach ($messages_ordered as $message) { 588 + // The object of this *is* the message 589 + $object = $message; 560 590 561 - $published = $message["published"]; 591 + // Get basic details 592 + $id = $object["id"]; 593 + $published = $object["published"]; 562 594 563 - // Place in an array where the key is the timestamp 564 - $messages_ordered[$published] = $message; 565 - } 595 + // HTML for who wrote this 596 + $publishedHTML = "<a href=\"{$id}\">{$published}</a>"; 566 597 567 - // HTML is *probably* sanitised by the sender. But let's not risk it, eh? 568 - // Using the allow-list from https://docs.joinmastodon.org/spec/activitypub/#sanitization 569 - $allowed_elements = ["p", "span", "br", "a", "del", "pre", "code", "em", "strong", "b", "i", "u", "ul", "ol", "li", "blockquote"]; 570 - // Print the items in a list 571 - foreach ( $messages_ordered as $message ) { 572 - // The object of this *is* the message 573 - $object = $message; 574 - 575 - // Get basic details 576 - $id = $object["id"]; 577 - $published = $object["published"]; 598 + // For displaying the post's information 599 + $timeHTML = "<time datetime=\"{$published}\" class=\"u-url\" rel=\"bookmark\">{$publishedHTML}</time>"; 600 + 601 + // Get the actor who authored the message 602 + $actor = $object["attributedTo"]; 603 + 604 + // Assume that what comes after the final `/` in the URl is the name 605 + $actorArray = explode("/", $actor); 606 + $actorName = end($actorArray); 607 + $actorServer = parse_url($actor, PHP_URL_HOST); 608 + $actorUsername = "@{$actorName}@{$actorServer}"; 578 609 579 - // HTML for who wrote this 580 - $publishedHTML = "<a href=\"{$id}\">{$published}</a>"; 581 - 582 - // For displaying the post's information 583 - $timeHTML = "<time datetime=\"{$published}\" class=\"u-url\" rel=\"bookmark\">{$publishedHTML}</time>"; 610 + // Make i18n usernames readable and safe. 611 + $actorName = htmlspecialchars(rawurldecode($actorName)); 612 + $actorHTML = "<a href=\"$actor\">@{$actorName}</a>"; 584 613 585 - // Get the actor who authored the message 586 - $actor = $object["attributedTo"]; 587 - 588 - // Assume that what comes after the final `/` in the URl is the name 589 - $actorArray = explode( "/", $actor ); 590 - $actorName = end( $actorArray ); 591 - $actorServer = parse_url( $actor, PHP_URL_HOST ); 592 - $actorUsername = "@{$actorName}@{$actorServer}"; 593 - 594 - // Make i18n usernames readable and safe. 595 - $actorName = htmlspecialchars( rawurldecode( $actorName ) ); 596 - $actorHTML = "<a href=\"$actor\">@{$actorName}</a>"; 597 - 598 - // What type of message is this? 599 - $type = $message["type"]; 614 + // What type of message is this? 615 + $type = $message["type"]; 600 616 601 - // Get the HTML content 602 - $content = $message["content"]; 617 + // Get the HTML content 618 + $content = $message["content"]; 603 619 604 - // Sanitise the HTML 605 - $content = strip_tags( $content, $allowed_elements ); 620 + // Sanitise the HTML 621 + $content = strip_tags($content, $allowed_elements); 606 622 607 - // Is there is a Content Warning? 608 - if ( isset( $object["summary"] ) ) { 609 - $summary = $object["summary"]; 610 - $summary = strip_tags( $summary, $allowed_elements ); 611 - // Hide the content until the user interacts with it. 612 - $content = "<details><summary>{$summary}</summary>{$content}</details>"; 613 - } 623 + // Is there is a Content Warning? 624 + if (isset($object["summary"])) { 625 + $summary = $object["summary"]; 626 + $summary = strip_tags($summary, $allowed_elements); 627 + // Hide the content until the user interacts with it. 628 + $content = "<details><summary>{$summary}</summary>{$content}</details>"; 629 + } 614 630 615 - // Add any images 616 - if ( isset( $object["attachment"] ) ) { 617 - foreach ( $object["attachment"] as $attachment ) { 618 - // Only use things which have a MIME Type set 619 - if ( isset( $attachment["mediaType"] ) ) { 620 - $mediaURl = $attachment["url"]; 621 - $mime = $attachment["mediaType"]; 622 - // Use the first half of the MIME Type. 623 - // For example `image/png` or `video/mp4` 624 - $mediaType = explode( "/", $mime )[0]; 631 + // Add any images 632 + if (isset($object["attachment"])) { 633 + foreach ($object["attachment"] as $attachment) { 634 + // Only use things which have a MIME Type set 635 + if (isset($attachment["mediaType"])) { 636 + $mediaURl = $attachment["url"]; 637 + $mime = $attachment["mediaType"]; 638 + // Use the first half of the MIME Type. 639 + // For example `image/png` or `video/mp4` 640 + $mediaType = explode("/", $mime)[0]; 625 641 626 - if ( "image" == $mediaType ) { 627 - // Get the alt text 628 - isset( $attachment["name"] ) ? $alt = htmlspecialchars( $attachment["name"] ) : $alt = ""; 629 - $content .= "<img src='{$mediaURl}' alt='{$alt}'>"; 630 - } else if ( "video" == $mediaType ) { 631 - $content .= "<video controls><source src='{$mediaURl}' type='{$mime}'></video>"; 632 - }else if ( "audio" == $mediaType ) { 633 - $content .= "<audio controls src='{$mediaURl}' type='{$mime}'></audio>"; 634 - } 642 + if ("image" == $mediaType) { 643 + // Get the alt text 644 + isset($attachment["name"]) ? $alt = htmlspecialchars($attachment["name"]) : $alt = ""; 645 + $content .= "<img src='{$mediaURl}' alt='{$alt}'>"; 646 + } else if ("video" == $mediaType) { 647 + $content .= "<video controls><source src='{$mediaURl}' type='{$mime}'></video>"; 648 + } else if ("audio" == $mediaType) { 649 + $content .= "<audio controls src='{$mediaURl}' type='{$mime}'></audio>"; 635 650 } 636 651 } 637 652 } 653 + } 638 654 639 - $verb = "posted"; 640 - 641 - $messageHTML = "{$timeHTML} {$actorHTML} {$verb}: <blockquote class=\"e-content\">{$content}</blockquote>"; 642 - // Display the message 643 - echo "<li><article class=\"h-entry\">{$messageHTML}<br></article></li>"; 644 - } 645 - echo <<< HTML 655 + $verb = "posted"; 656 + 657 + $messageHTML = "{$timeHTML} {$actorHTML} {$verb}: <blockquote class=\"e-content\">{$content}</blockquote>"; 658 + // Display the message 659 + echo "<li><article class=\"h-entry\">{$messageHTML}<br></article></li>"; 660 + } 661 + echo <<< HTML 646 662 </ul> 647 663 </main> 648 664 </body> 649 665 </html> 650 666 HTML; 651 - die(); 667 + die(); 668 + } 669 + 670 + // Send Endpoint: 671 + // This takes the submitted message and checks the password is correct. 672 + // It reads all the followers' data in `data/followers`. 673 + // It constructs a list of shared inboxes and unique inboxes. 674 + // It sends the message to every server that is following this account. 675 + function send() 676 + { 677 + global $password, $server, $username, $key_private, $directories; 678 + 679 + // Does the posted password match the stored password? 680 + if ($password != $_POST["password"]) { 681 + header("HTTP/1.1 401 Unauthorized"); 682 + echo "Wrong password."; 683 + die(); 684 + } 685 + 686 + // Get the posted content 687 + $content = $_POST["content"]; 688 + 689 + // Is this a reply? 690 + if (isset($_POST["inReplyTo"]) && filter_var($_POST["inReplyTo"], FILTER_VALIDATE_URL)) { 691 + $inReplyTo = $_POST["inReplyTo"]; 692 + } else { 693 + $inReplyTo = null; 652 694 } 653 - 654 - // Send Endpoint: 655 - // This takes the submitted message and checks the password is correct. 656 - // It reads all the followers' data in `data/followers`. 657 - // It constructs a list of shared inboxes and unique inboxes. 658 - // It sends the message to every server that is following this account. 659 - function send() { 660 - global $password, $server, $username, $key_private, $directories; 661 695 662 - // Does the posted password match the stored password? 663 - if( $password != $_POST["password"] ) { 664 - header("HTTP/1.1 401 Unauthorized"); 665 - echo "Wrong password."; 666 - die(); 667 - } 668 - 669 - // Get the posted content 670 - $content = $_POST["content"]; 696 + // Process the content into HTML to get hashtags etc 697 + list("HTML" => $content, "TagArray" => $tags) = process_content($content); 671 698 672 - // Is this a reply? 673 - if ( isset( $_POST["inReplyTo"] ) && filter_var( $_POST["inReplyTo"], FILTER_VALIDATE_URL ) ) { 674 - $inReplyTo = $_POST["inReplyTo"]; 699 + // Is there an image attached? 700 + if (isset($_FILES['image']['tmp_name']) && ("" != $_FILES['image']['tmp_name'])) { 701 + // Get information about the image 702 + $image = $_FILES['image']['tmp_name']; 703 + $image_info = getimagesize($image); 704 + $image_ext = image_type_to_extension($image_info[2]); 705 + $image_mime = $image_info["mime"]; 706 + 707 + // Files are stored according to their hash 708 + // A hash of "abc123" is stored in "/images/abc123.jpg" 709 + $sha1 = sha1_file($image); 710 + $image_full_path = $directories["images"] . "/{$sha1}.{$image_ext}"; 711 + 712 + // Move media to the correct location 713 + move_uploaded_file($image, $image_full_path); 714 + 715 + // Get the alt text 716 + if (isset($_POST["alt"])) { 717 + $alt = $_POST["alt"]; 675 718 } else { 676 - $inReplyTo = null; 719 + $alt = ""; 677 720 } 678 721 679 - // Process the content into HTML to get hashtags etc 680 - list( "HTML" => $content, "TagArray" => $tags ) = process_content( $content ); 722 + // Construct the attachment value for the post 723 + $attachment = array([ 724 + "type" => "Image", 725 + "mediaType" => "{$image_mime}", 726 + "url" => "https://{$server}/{$image_full_path}", 727 + "name" => $alt 728 + ]); 729 + } else { 730 + $attachment = []; 731 + } 681 732 682 - // Is there an image attached? 683 - if ( isset( $_FILES['image']['tmp_name'] ) && ( "" != $_FILES['image']['tmp_name'] ) ) { 684 - // Get information about the image 685 - $image = $_FILES['image']['tmp_name']; 686 - $image_info = getimagesize( $image ); 687 - $image_ext = image_type_to_extension( $image_info[2] ); 688 - $image_mime = $image_info["mime"]; 733 + // Current time - ISO8601 734 + $timestamp = date("c"); 689 735 690 - // Files are stored according to their hash 691 - // A hash of "abc123" is stored in "/images/abc123.jpg" 692 - $sha1 = sha1_file( $image ); 693 - $image_full_path = $directories["images"] . "/{$sha1}.{$image_ext}"; 736 + // Outgoing Message ID 737 + $guid = uuid(); 694 738 695 - // Move media to the correct location 696 - move_uploaded_file( $image, $image_full_path ); 739 + // Construct the Note 740 + // `contentMap` is used to prevent unnecessary "translate this post" pop ups 741 + // hardcoded to English 742 + $note = [ 743 + "@context" => array( 744 + "https://www.w3.org/ns/activitystreams" 745 + ), 746 + "id" => "https://{$server}/posts/{$guid}.json", 747 + "type" => "Note", 748 + "published" => $timestamp, 749 + "attributedTo" => "https://{$server}/{$username}", 750 + "inReplyTo" => $inReplyTo, 751 + "content" => $content, 752 + "contentMap" => ["en" => $content], 753 + "to" => ["https://www.w3.org/ns/activitystreams#Public"], 754 + "tag" => $tags, 755 + "attachment" => $attachment 756 + ]; 697 757 698 - // Get the alt text 699 - if ( isset( $_POST["alt"] ) ) { 700 - $alt = $_POST["alt"]; 701 - } else { 702 - $alt = ""; 703 - } 758 + // Construct the Message 759 + // The audience is public and it is sent to all followers 760 + $message = [ 761 + "@context" => "https://www.w3.org/ns/activitystreams", 762 + "id" => "https://{$server}/posts/{$guid}.json", 763 + "type" => "Create", 764 + "actor" => "https://{$server}/{$username}", 765 + "to" => [ 766 + "https://www.w3.org/ns/activitystreams#Public" 767 + ], 768 + "cc" => [ 769 + "https://{$server}/followers" 770 + ], 771 + "object" => $note 772 + ]; 704 773 705 - // Construct the attachment value for the post 706 - $attachment = array( [ 707 - "type" => "Image", 708 - "mediaType" => "{$image_mime}", 709 - "url" => "https://{$server}/{$image_full_path}", 710 - "name" => $alt 711 - ] ); 712 - } else { 713 - $attachment = []; 714 - } 715 774 716 - // Current time - ISO8601 717 - $timestamp = date( "c" ); 775 + // Save the permalink 776 + $note_json = json_encode($note); 777 + file_put_contents($directories["posts"] . "/{$guid}.json", print_r($note_json, true)); 718 778 719 - // Outgoing Message ID 720 - $guid = uuid(); 779 + // Send to all the user's followers 780 + $messageSent = sendMessageToFollowers($message); 721 781 722 - // Construct the Note 723 - // `contentMap` is used to prevent unnecessary "translate this post" pop ups 724 - // hardcoded to English 725 - $note = [ 726 - "@context" => array( 727 - "https://www.w3.org/ns/activitystreams" 728 - ), 729 - "id" => "https://{$server}/posts/{$guid}.json", 730 - "type" => "Note", 731 - "published" => $timestamp, 732 - "attributedTo" => "https://{$server}/{$username}", 733 - "inReplyTo" => $inReplyTo, 734 - "content" => $content, 735 - "contentMap" => ["en" => $content], 736 - "to" => ["https://www.w3.org/ns/activitystreams#Public"], 737 - "tag" => $tags, 738 - "attachment" => $attachment 739 - ]; 782 + // Return the JSON so the user can see the POST has worked 783 + if ($messageSent) { 784 + header("Location: https://{$server}/posts/{$guid}.json"); 785 + die(); 786 + } else { 787 + header("HTTP/1.1 500 Internal Server Error"); 788 + echo "ERROR!"; 789 + die(); 790 + } 791 + } 740 792 741 - // Construct the Message 742 - // The audience is public and it is sent to all followers 743 - $message = [ 744 - "@context" => "https://www.w3.org/ns/activitystreams", 745 - "id" => "https://{$server}/posts/{$guid}.json", 746 - "type" => "Create", 747 - "actor" => "https://{$server}/{$username}", 748 - "to" => [ 749 - "https://www.w3.org/ns/activitystreams#Public" 750 - ], 751 - "cc" => [ 752 - "https://{$server}/followers" 753 - ], 754 - "object" => $note 755 - ]; 756 - 793 + function follow() 794 + { 795 + global $password, $server, $username, $directories; 757 796 758 - // Save the permalink 759 - $note_json = json_encode( $note ); 760 - file_put_contents( $directories["posts"] . "/{$guid}.json", print_r( $note_json, true ) ); 797 + // Verify directories exist and are writable 798 + if (!is_dir($directories['following']) || !is_writable($directories['following'])) { 799 + header("HTTP/1.1 500 Internal Server Error"); 800 + error_log("Following directory not writable"); 801 + echo "Server configuration error"; 802 + die(); 803 + } 761 804 762 - // Send to all the user's followers 763 - $messageSent = sendMessageToFollowers( $message ); 764 - 765 - // Return the JSON so the user can see the POST has worked 766 - if ( $messageSent ) { 767 - header( "Location: https://{$server}/posts/{$guid}.json" ); 768 - die(); 769 - } else { 770 - header("HTTP/1.1 500 Internal Server Error"); 771 - echo "ERROR!"; 772 - die(); 773 - } 805 + // Check password 806 + if ($password != $_POST["password"]) { 807 + header("HTTP/1.1 401 Unauthorized"); 808 + echo "Wrong password."; 809 + die(); 774 810 } 775 811 776 - function follow() { 777 - global $password, $server, $username, $directories; 812 + // Get and sanitize the account 813 + if (!isset($_POST["account"])) { 814 + header("HTTP/1.1 400 Bad Request"); 815 + echo "Missing account parameter"; 816 + die(); 817 + } 778 818 779 - // Verify directories exist and are writable 780 - if(!is_dir($directories['following']) || !is_writable($directories['following'])) { 781 - header("HTTP/1.1 500 Internal Server Error"); 782 - error_log("Following directory not writable"); 783 - echo "Server configuration error"; 784 - die(); 785 - } 819 + // Limit input size 820 + if (strlen($_POST["account"]) > 255) { 821 + header("HTTP/1.1 400 Bad Request"); 822 + echo "Account string too long"; 823 + die(); 824 + } 786 825 787 - // Check password 788 - if($password != $_POST["password"]) { 789 - header("HTTP/1.1 401 Unauthorized"); 790 - echo "Wrong password."; 791 - die(); 792 - } 826 + $account = trim(filter_var($_POST["account"], FILTER_SANITIZE_STRING)); 793 827 794 - // Get and sanitize the account 795 - if(!isset($_POST["account"])) { 796 - header("HTTP/1.1 400 Bad Request"); 797 - echo "Missing account parameter"; 798 - die(); 799 - } 800 - 801 - // Limit input size 802 - if(strlen($_POST["account"]) > 255) { 803 - header("HTTP/1.1 400 Bad Request"); 804 - echo "Account string too long"; 805 - die(); 806 - } 807 - 808 - $account = trim(filter_var($_POST["account"], FILTER_SANITIZE_STRING)); 809 - 810 - // If it starts with @, remove it 811 - if(str_starts_with($account, '@')) { 812 - $account = substr($account, 1); 813 - } 814 - 815 - // Split into user@domain and validate parts 816 - $parts = explode("@", $account); 817 - if(count($parts) != 2 || 818 - empty($parts[0]) || 819 - empty($parts[1]) || 820 - !preg_match('/^[a-zA-Z0-9_.-]+$/', $parts[0]) || // Validate username format 821 - !preg_match('/^[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/', $parts[1])) { // Basic domain format 822 - header("HTTP/1.1 400 Bad Request"); 823 - echo "Invalid account format. Use user@domain"; 824 - die(); 825 - } 826 - 827 - $targetUser = $parts[0]; 828 - $targetDomain = $parts[1]; 828 + // If it starts with @, remove it 829 + if (str_starts_with($account, '@')) { 830 + $account = substr($account, 1); 831 + } 829 832 830 - // Verify domain uses HTTPS 831 - if(!filter_var("https://{$targetDomain}", FILTER_VALIDATE_URL)) { 832 - header("HTTP/1.1 400 Bad Request"); 833 - echo "Invalid domain"; 834 - die(); 835 - } 833 + // Split into user@domain and validate parts 834 + $parts = explode("@", $account); 835 + if ( 836 + count($parts) != 2 || 837 + empty($parts[0]) || 838 + empty($parts[1]) || 839 + !preg_match('/^[a-zA-Z0-9_.-]+$/', $parts[0]) || // Validate username format 840 + !preg_match('/^[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/', $parts[1]) 841 + ) { // Basic domain format 842 + header("HTTP/1.1 400 Bad Request"); 843 + echo "Invalid account format. Use user@domain"; 844 + die(); 845 + } 836 846 837 - // Get WebFinger data with timeout and error handling 838 - $webfinger_url = "https://{$targetDomain}/.well-known/webfinger?resource=acct:{$targetUser}@{$targetDomain}"; 839 - $ch = curl_init($webfinger_url); 840 - curl_setopt_array($ch, [ 841 - CURLOPT_RETURNTRANSFER => true, 842 - CURLOPT_USERAGENT => USERAGENT, 843 - CURLOPT_TIMEOUT => 10, 844 - CURLOPT_FOLLOWLOCATION => true, 845 - CURLOPT_MAXREDIRS => 3, 846 - CURLOPT_PROTOCOLS => CURLPROTO_HTTPS, 847 - CURLOPT_SSL_VERIFYPEER => true, 848 - CURLOPT_SSL_VERIFYHOST => 2 849 - ]); 850 - 851 - $response = curl_exec($ch); 852 - 853 - if(curl_errno($ch)) { 854 - header("HTTP/1.1 502 Bad Gateway"); 855 - error_log("WebFinger fetch failed: " . curl_error($ch)); 856 - echo "Failed to fetch WebFinger data"; 857 - curl_close($ch); 858 - die(); 859 - } 860 - 861 - $status = curl_getinfo($ch, CURLINFO_HTTP_CODE); 862 - if($status !== 200) { 863 - header("HTTP/1.1 404 Not Found"); 864 - echo "Account not found"; 865 - curl_close($ch); 866 - die(); 867 - } 868 - 869 - curl_close($ch); 870 - 871 - $webfinger = json_decode($response, true); 872 - if(!$webfinger || !isset($webfinger['links'])) { 873 - header("HTTP/1.1 502 Bad Gateway"); 874 - echo "Invalid WebFinger response"; 875 - die(); 876 - } 847 + $targetUser = $parts[0]; 848 + $targetDomain = $parts[1]; 849 + 850 + // Verify domain uses HTTPS 851 + if (!filter_var("https://{$targetDomain}", FILTER_VALIDATE_URL)) { 852 + header("HTTP/1.1 400 Bad Request"); 853 + echo "Invalid domain"; 854 + die(); 855 + } 856 + 857 + // Get WebFinger data with timeout and error handling 858 + $webfinger_url = "https://{$targetDomain}/.well-known/webfinger?resource=acct:{$targetUser}@{$targetDomain}"; 859 + $ch = curl_init($webfinger_url); 860 + curl_setopt_array($ch, [ 861 + CURLOPT_RETURNTRANSFER => true, 862 + CURLOPT_USERAGENT => USERAGENT, 863 + CURLOPT_TIMEOUT => 10, 864 + CURLOPT_FOLLOWLOCATION => true, 865 + CURLOPT_MAXREDIRS => 3, 866 + CURLOPT_PROTOCOLS => CURLPROTO_HTTPS, 867 + CURLOPT_SSL_VERIFYPEER => true, 868 + CURLOPT_SSL_VERIFYHOST => 2 869 + ]); 870 + 871 + $response = curl_exec($ch); 872 + 873 + if (curl_errno($ch)) { 874 + header("HTTP/1.1 502 Bad Gateway"); 875 + error_log("WebFinger fetch failed: " . curl_error($ch)); 876 + echo "Failed to fetch WebFinger data"; 877 + curl_close($ch); 878 + die(); 879 + } 880 + 881 + $status = curl_getinfo($ch, CURLINFO_HTTP_CODE); 882 + if ($status !== 200) { 883 + header("HTTP/1.1 404 Not Found"); 884 + echo "Account not found"; 885 + curl_close($ch); 886 + die(); 887 + } 888 + 889 + curl_close($ch); 890 + 891 + $webfinger = json_decode($response, true); 892 + if (!$webfinger || !isset($webfinger['links'])) { 893 + header("HTTP/1.1 502 Bad Gateway"); 894 + echo "Invalid WebFinger response"; 895 + die(); 896 + } 877 897 878 - // Find ActivityPub actor URL 879 - $actor_url = null; 880 - foreach($webfinger['links'] as $link) { 881 - if($link['rel'] === 'self' && 882 - $link['type'] === 'application/activity+json' && 883 - filter_var($link['href'], FILTER_VALIDATE_URL) && 884 - parse_url($link['href'], PHP_URL_SCHEME) === 'https') { 885 - $actor_url = $link['href']; 886 - break; 887 - } 888 - } 898 + // Find ActivityPub actor URL 899 + $actor_url = null; 900 + foreach ($webfinger['links'] as $link) { 901 + if ( 902 + $link['rel'] === 'self' && 903 + $link['type'] === 'application/activity+json' && 904 + filter_var($link['href'], FILTER_VALIDATE_URL) && 905 + parse_url($link['href'], PHP_URL_SCHEME) === 'https' 906 + ) { 907 + $actor_url = $link['href']; 908 + break; 909 + } 910 + } 911 + 912 + if (!$actor_url) { 913 + header("HTTP/1.1 404 Not Found"); 914 + echo "Could not find ActivityPub account"; 915 + die(); 916 + } 889 917 890 - if(!$actor_url) { 891 - header("HTTP/1.1 404 Not Found"); 892 - echo "Could not find ActivityPub account"; 893 - die(); 894 - } 918 + // Get actor data 919 + try { 920 + $actor_data = getDataFromURl($actor_url); 921 + } catch (Exception $e) { 922 + header("HTTP/1.1 502 Bad Gateway"); 923 + error_log("Actor fetch failed: " . $e->getMessage()); 924 + echo "Failed to fetch account data"; 925 + die(); 926 + } 895 927 896 - // Get actor data 897 - try { 898 - $actor_data = getDataFromURl($actor_url); 899 - } catch(Exception $e) { 900 - header("HTTP/1.1 502 Bad Gateway"); 901 - error_log("Actor fetch failed: " . $e->getMessage()); 902 - echo "Failed to fetch account data"; 903 - die(); 904 - } 905 - 906 - // Verify required actor properties 907 - if(!isset($actor_data['inbox']) || 908 - !filter_var($actor_data['inbox'], FILTER_VALIDATE_URL) || 909 - !isset($actor_data['id']) || 910 - $actor_data['id'] !== $actor_url) { // Verify actor URL matches claimed ID 911 - header("HTTP/1.1 502 Bad Gateway"); 912 - echo "Invalid actor data"; 913 - die(); 914 - } 928 + // Verify required actor properties 929 + if ( 930 + !isset($actor_data['inbox']) || 931 + !filter_var($actor_data['inbox'], FILTER_VALIDATE_URL) || 932 + !isset($actor_data['id']) || 933 + $actor_data['id'] !== $actor_url 934 + ) { // Verify actor URL matches claimed ID 935 + header("HTTP/1.1 502 Bad Gateway"); 936 + echo "Invalid actor data"; 937 + die(); 938 + } 915 939 916 - // Check follow state 917 - $following_file = "{$directories['following']}/" . urlencode($actor_url) . ".json"; 918 - $pending_file = "{$directories['following']}/.pending/" . urlencode($actor_url) . ".json"; 919 - 920 - if(file_exists($following_file)) { 921 - header("HTTP/1.1 409 Conflict"); 922 - echo "Already following this account"; 923 - die(); 924 - } 925 - 926 - if(file_exists($pending_file)) { 927 - header("HTTP/1.1 409 Conflict"); 928 - echo "Follow request already pending"; 929 - die(); 930 - } 940 + // Check follow state 941 + $following_file = "{$directories['following']}/" . urlencode($actor_url) . ".json"; 942 + $pending_file = "{$directories['following']}/.pending/" . urlencode($actor_url) . ".json"; 931 943 932 - // Create follow activity with proper UUID 933 - $guid = uuid(); 934 - $message = [ 935 - "@context" => "https://www.w3.org/ns/activitystreams", 936 - "id" => "https://{$server}/follow/{$guid}", 937 - "type" => "Follow", 938 - "actor" => "https://{$server}/{$username}", 939 - "object" => $actor_url 940 - ]; 944 + if (file_exists($following_file)) { 945 + header("HTTP/1.1 409 Conflict"); 946 + echo "Already following this account"; 947 + die(); 948 + } 941 949 942 - // Ensure pending directory exists 943 - $pending_dir = "{$directories['following']}/.pending"; 944 - if(!is_dir($pending_dir)) { 945 - if(!mkdir($pending_dir, 0755, true)) { 946 - header("HTTP/1.1 500 Internal Server Error"); 947 - error_log("Could not create pending directory"); 948 - echo "Server configuration error"; 949 - die(); 950 - } 951 - } 950 + if (file_exists($pending_file)) { 951 + header("HTTP/1.1 409 Conflict"); 952 + echo "Follow request already pending"; 953 + die(); 954 + } 952 955 953 - // Save pending follow request first 954 - $pending_data = [ 955 - 'guid' => $guid, 956 - 'timestamp' => time(), 957 - 'actor_data' => $actor_data, 958 - 'message' => $message 959 - ]; 960 - 961 - if(!file_put_contents($pending_file, json_encode($pending_data))) { 962 - header("HTTP/1.1 500 Internal Server Error"); 963 - error_log("Failed to save pending follow"); 964 - echo "Failed to save follow request"; 965 - die(); 966 - } 956 + // Create follow activity with proper UUID 957 + $guid = uuid(); 958 + $message = [ 959 + "@context" => "https://www.w3.org/ns/activitystreams", 960 + "id" => "https://{$server}/follow/{$guid}", 961 + "type" => "Follow", 962 + "actor" => "https://{$server}/{$username}", 963 + "object" => $actor_url 964 + ]; 967 965 968 - // Send follow request 969 - $success = sendMessageToSingle($actor_data['inbox'], $message); 970 - 971 - if(!$success) { 972 - unlink($pending_file); // Clean up pending file 973 - header("HTTP/1.1 500 Internal Server Error"); 974 - echo "Failed to send follow request"; 975 - die(); 976 - } 966 + // Ensure pending directory exists 967 + $pending_dir = "{$directories['following']}/.pending"; 968 + if (!is_dir($pending_dir)) { 969 + if (!mkdir($pending_dir, 0755, true)) { 970 + header("HTTP/1.1 500 Internal Server Error"); 971 + error_log("Could not create pending directory"); 972 + echo "Server configuration error"; 973 + die(); 974 + } 975 + } 977 976 978 - // Return success 979 - header("Location: https://{$server}/following"); 980 - die(); 981 - } 977 + // Save pending follow request first 978 + $pending_data = [ 979 + 'guid' => $guid, 980 + 'timestamp' => time(), 981 + 'actor_data' => $actor_data, 982 + 'message' => $message 983 + ]; 982 984 983 - // Handle when remote server accepts/rejects the follow 984 - function processFollowResponse($activity) { 985 - global $directories; 986 - 987 - if(!isset($activity['object']['id'])) { 988 - return false; 989 - } 990 - 991 - // Extract original follow request ID 992 - $follow_id = $activity['object']['id']; 993 - $actor_url = $activity['actor']; 994 - $pending_file = "{$directories['following']}/.pending/" . urlencode($actor_url) . ".json"; 995 - $following_file = "{$directories['following']}/" . urlencode($actor_url) . ".json"; 996 - 997 - // Verify pending follow exists 998 - if(!file_exists($pending_file)) { 999 - return false; 1000 - } 1001 - 1002 - $pending_data = json_decode(file_get_contents($pending_file), true); 1003 - if(!$pending_data || $pending_data['message']['id'] !== $follow_id) { 1004 - return false; 1005 - } 1006 - 1007 - // Handle Accept/Reject 1008 - if($activity['type'] === 'Accept') { 1009 - // Move from pending to following 1010 - if(file_put_contents($following_file, json_encode($pending_data['actor_data']))) { 1011 - unlink($pending_file); 1012 - return true; 1013 - } 1014 - } else if($activity['type'] === 'Reject') { 1015 - // Just remove pending 1016 - unlink($pending_file); 1017 - return true; 1018 - } 1019 - 1020 - return false; 1021 - } 985 + if (!file_put_contents($pending_file, json_encode($pending_data))) { 986 + header("HTTP/1.1 500 Internal Server Error"); 987 + error_log("Failed to save pending follow"); 988 + echo "Failed to save follow request"; 989 + die(); 990 + } 1022 991 1023 - function unfollow() { 1024 - global $password, $server, $username, $directories; 1025 - 1026 - // Check password 1027 - if($password != $_POST["password"]) { 1028 - header("HTTP/1.1 401 Unauthorized"); 1029 - echo "Wrong password."; 1030 - die(); 1031 - } 992 + // Send follow request 993 + $success = sendMessageToSingle($actor_data['inbox'], $message); 1032 994 1033 - // Get account URL 1034 - if(!isset($_POST["account"]) || !filter_var($_POST["account"], FILTER_VALIDATE_URL)) { 1035 - header("HTTP/1.1 400 Bad Request"); 1036 - echo "Invalid account URL"; 1037 - die(); 1038 - } 1039 - 1040 - $actor_url = $_POST["account"]; 1041 - $following_file = "{$directories['following']}/" . urlencode($actor_url) . ".json"; 1042 - 1043 - if(!file_exists($following_file)) { 1044 - header("HTTP/1.1 404 Not Found"); 1045 - echo "Not following this account"; 1046 - die(); 1047 - } 1048 - 1049 - // Get actor data to find inbox 1050 - $actor_data = json_decode(file_get_contents($following_file), true); 1051 - if(!$actor_data || !isset($actor_data['inbox'])) { 1052 - header("HTTP/1.1 500 Internal Server Error"); 1053 - echo "Invalid following data"; 1054 - die(); 1055 - } 1056 - 1057 - // Create unfollow activity 1058 - $guid = uuid(); 1059 - $message = [ 1060 - "@context" => "https://www.w3.org/ns/activitystreams", 1061 - "id" => "https://{$server}/unfollow/{$guid}", 1062 - "type" => "Undo", 1063 - "actor" => "https://{$server}/{$username}", 1064 - "object" => [ 1065 - "type" => "Follow", 1066 - "actor" => "https://{$server}/{$username}", 1067 - "object" => $actor_url 1068 - ] 1069 - ]; 995 + if (!$success) { 996 + unlink($pending_file); // Clean up pending file 997 + header("HTTP/1.1 500 Internal Server Error"); 998 + echo "Failed to send follow request"; 999 + die(); 1000 + } 1070 1001 1071 - // Send unfollow request 1072 - $success = sendMessageToSingle($actor_data['inbox'], $message); 1073 - 1074 - if($success) { 1075 - unlink($following_file); 1076 - header("Location: https://{$server}/following"); 1077 - } else { 1078 - header("HTTP/1.1 500 Internal Server Error"); 1079 - echo "Failed to send unfollow request"; 1080 - } 1081 - die(); 1002 + // Return success 1003 + header("Location: https://{$server}/following"); 1004 + die(); 1082 1005 } 1083 1006 1007 + // Handle when remote server accepts/rejects the follow 1008 + function processFollowResponse($activity) 1009 + { 1010 + global $directories; 1084 1011 1085 - // POST a signed message to a single inbox 1086 - function sendMessageToSingle( $inbox, $message ) { 1087 - global $directories; 1012 + if (!isset($activity['object']['id'])) { 1013 + return false; 1014 + } 1088 1015 1089 - $inbox_host = parse_url( $inbox, PHP_URL_HOST ); 1090 - $inbox_path = parse_url( $inbox, PHP_URL_PATH ); 1016 + // Extract original follow request ID 1017 + $follow_id = $activity['object']['id']; 1018 + $actor_url = $activity['actor']; 1019 + $pending_file = "{$directories['following']}/.pending/" . urlencode($actor_url) . ".json"; 1020 + $following_file = "{$directories['following']}/" . urlencode($actor_url) . ".json"; 1091 1021 1092 - // Generate the signed headers 1093 - $headers = generate_signed_headers( $message, $inbox_host, $inbox_path, "POST" ); 1022 + // Verify pending follow exists 1023 + if (!file_exists($pending_file)) { 1024 + return false; 1025 + } 1094 1026 1095 - // POST the message and header to the requester's inbox 1096 - $ch = curl_init( $inbox ); 1097 - curl_setopt( $ch, CURLOPT_RETURNTRANSFER, true ); 1098 - curl_setopt( $ch, CURLOPT_CUSTOMREQUEST, "POST" ); 1099 - curl_setopt( $ch, CURLOPT_POSTFIELDS, json_encode( $message ) ); 1100 - curl_setopt( $ch, CURLOPT_HTTPHEADER, $headers ); 1101 - curl_setopt( $ch, CURLOPT_USERAGENT, USERAGENT ); 1102 - curl_exec( $ch ); 1027 + $pending_data = json_decode(file_get_contents($pending_file), true); 1028 + if (!$pending_data || $pending_data['message']['id'] !== $follow_id) { 1029 + return false; 1030 + } 1103 1031 1104 - // Check for errors 1105 - if( curl_errno( $ch ) ) { 1106 - $timestamp = ( new DateTime() )->format( DATE_RFC3339_EXTENDED ); 1107 - $error_message = curl_error( $ch ) . "\ninbox: {$inbox}\nmessage: " . json_encode($message); 1108 - file_put_contents( $directories["logs"] . "/{$timestamp}.Error.txt", $error_message ); 1109 - return false; 1032 + // Handle Accept/Reject 1033 + if ($activity['type'] === 'Accept') { 1034 + // Move from pending to following 1035 + if (file_put_contents($following_file, json_encode($pending_data['actor_data']))) { 1036 + unlink($pending_file); 1037 + return true; 1110 1038 } 1111 - curl_close( $ch ); 1039 + } else if ($activity['type'] === 'Reject') { 1040 + // Just remove pending 1041 + unlink($pending_file); 1112 1042 return true; 1113 1043 } 1114 1044 1115 - // POST a signed message to the inboxes of all followers 1116 - function sendMessageToFollowers( $message ) { 1117 - global $directories; 1118 - // Read existing followers 1119 - $followers = glob( $directories["followers"] . "/*.json" ); 1120 - 1121 - // Get all the inboxes 1122 - $inboxes = []; 1123 - foreach ( $followers as $follower ) { 1124 - // Get the data about the follower 1125 - $follower_info = json_decode( file_get_contents( $follower ), true ); 1045 + return false; 1046 + } 1126 1047 1127 - // Some servers have "Shared inboxes" 1128 - // If you have lots of followers on a single server, you only need to send the message once. 1129 - if( isset( $follower_info["endpoints"]["sharedInbox"] ) ) { 1130 - $sharedInbox = $follower_info["endpoints"]["sharedInbox"]; 1131 - if ( !in_array( $sharedInbox, $inboxes ) ) { 1132 - $inboxes[] = $sharedInbox; 1133 - } 1134 - } else { 1135 - // If not, use the individual inbox 1136 - $inbox = $follower_info["inbox"]; 1137 - if ( !in_array( $inbox, $inboxes ) ) { 1138 - $inboxes[] = $inbox; 1139 - } 1140 - } 1141 - } 1048 + function unfollow() 1049 + { 1050 + global $password, $server, $username, $directories; 1142 1051 1143 - // Prepare to use the multiple cURL handle 1144 - // This makes it more efficient to send many simultaneous messages 1145 - $mh = curl_multi_init(); 1052 + // Check password 1053 + if ($password != $_POST["password"]) { 1054 + header("HTTP/1.1 401 Unauthorized"); 1055 + echo "Wrong password."; 1056 + die(); 1057 + } 1146 1058 1147 - // Loop through all the inboxes of the followers 1148 - // Each server needs its own cURL handle 1149 - // Each POST to an inbox needs to be signed separately 1150 - foreach ( $inboxes as $inbox ) { 1151 - 1152 - $inbox_host = parse_url( $inbox, PHP_URL_HOST ); 1153 - $inbox_path = parse_url( $inbox, PHP_URL_PATH ); 1154 - 1155 - // Generate the signed headers 1156 - $headers = generate_signed_headers( $message, $inbox_host, $inbox_path, "POST" ); 1157 - 1158 - // POST the message and header to the requester's inbox 1159 - $ch = curl_init( $inbox ); 1160 - curl_setopt( $ch, CURLOPT_RETURNTRANSFER, true ); 1161 - curl_setopt( $ch, CURLOPT_CUSTOMREQUEST, "POST" ); 1162 - curl_setopt( $ch, CURLOPT_POSTFIELDS, json_encode( $message ) ); 1163 - curl_setopt( $ch, CURLOPT_HTTPHEADER, $headers ); 1164 - curl_setopt( $ch, CURLOPT_USERAGENT, USERAGENT ); 1059 + // Get account URL 1060 + if (!isset($_POST["account"]) || !filter_var($_POST["account"], FILTER_VALIDATE_URL)) { 1061 + header("HTTP/1.1 400 Bad Request"); 1062 + echo "Invalid account URL"; 1063 + die(); 1064 + } 1165 1065 1166 - // Add the handle to the multi-handle 1167 - curl_multi_add_handle( $mh, $ch ); 1168 - } 1066 + $actor_url = $_POST["account"]; 1067 + $following_file = "{$directories['following']}/" . urlencode($actor_url) . ".json"; 1169 1068 1170 - // Execute the multi-handle 1171 - do { 1172 - $status = curl_multi_exec( $mh, $active ); 1173 - if ( $active ) { 1174 - curl_multi_select( $mh ); 1175 - } 1176 - } while ( $active && $status == CURLM_OK ); 1069 + if (!file_exists($following_file)) { 1070 + header("HTTP/1.1 404 Not Found"); 1071 + echo "Not following this account"; 1072 + die(); 1073 + } 1177 1074 1178 - // Close the multi-handle 1179 - curl_multi_close( $mh ); 1180 - 1181 - return true; 1075 + // Get actor data to find inbox 1076 + $actor_data = json_decode(file_get_contents($following_file), true); 1077 + if (!$actor_data || !isset($actor_data['inbox'])) { 1078 + header("HTTP/1.1 500 Internal Server Error"); 1079 + echo "Invalid following data"; 1080 + die(); 1182 1081 } 1183 1082 1184 - // Content can be plain text. But to add clickable links and hashtags, it needs to be turned into HTML. 1185 - // Tags are also included separately in the note 1186 - function process_content( $content ) { 1187 - global $server; 1188 - 1189 - // Convert any URls into hyperlinks 1190 - $link_pattern = '/\bhttps?:\/\/\S+/iu'; // Sloppy regex 1191 - $replacement = function ( $match ) { 1192 - $url = htmlspecialchars( $match[0], ENT_QUOTES, "UTF-8" ); 1193 - return "<a href=\"$url\">$url</a>"; 1194 - }; 1195 - $content = preg_replace_callback( $link_pattern, $replacement, $content ); 1083 + // Create unfollow activity 1084 + $guid = uuid(); 1085 + $message = [ 1086 + "@context" => "https://www.w3.org/ns/activitystreams", 1087 + "id" => "https://{$server}/unfollow/{$guid}", 1088 + "type" => "Undo", 1089 + "actor" => "https://{$server}/{$username}", 1090 + "object" => [ 1091 + "type" => "Follow", 1092 + "actor" => "https://{$server}/{$username}", 1093 + "object" => $actor_url 1094 + ] 1095 + ]; 1196 1096 1197 - // Get any hashtags 1198 - $hashtags = []; 1199 - $hashtag_pattern = '/(?:^|\s)\#(\w+)/'; // Beginning of string, or whitespace, followed by # 1200 - preg_match_all( $hashtag_pattern, $content, $hashtag_matches ); 1201 - foreach ( $hashtag_matches[1] as $match ) { 1202 - $hashtags[] = $match; 1203 - } 1097 + // Send unfollow request 1098 + $success = sendMessageToSingle($actor_data['inbox'], $message); 1204 1099 1205 - // Construct the tag value for the note object 1206 - $tags = []; 1207 - foreach ( $hashtags as $hashtag ) { 1208 - $tags[] = array( 1209 - "type" => "Hashtag", 1210 - "name" => "#{$hashtag}", 1211 - ); 1212 - } 1100 + if ($success) { 1101 + unlink($following_file); 1102 + header("Location: https://{$server}/following"); 1103 + } else { 1104 + header("HTTP/1.1 500 Internal Server Error"); 1105 + echo "Failed to send unfollow request"; 1106 + } 1107 + die(); 1108 + } 1213 1109 1214 - // Add HTML links for hashtags into the text 1215 - // Todo: Make these links do something. 1216 - $content = preg_replace( 1217 - $hashtag_pattern, 1218 - " <a href='https://{$server}/tag/$1'>#$1</a>", 1219 - $content 1220 - ); 1221 1110 1222 - // Detect user mentions 1223 - $usernames = []; 1224 - $usernames_pattern = '/@(\S+)@(\S+)/'; // This is a *very* sloppy regex 1225 - preg_match_all( $usernames_pattern, $content, $usernames_matches ); 1226 - foreach ( $usernames_matches[0] as $match ) { 1227 - $usernames[] = $match; 1228 - } 1111 + // POST a signed message to a single inbox 1112 + function sendMessageToSingle($inbox, $message) 1113 + { 1114 + global $directories; 1229 1115 1230 - // Construct the mentions value for the note object 1231 - // This goes in the generic "tag" property 1232 - // TODO: Add this to the CC field & appropriate inbox 1233 - foreach ( $usernames as $username ) { 1234 - list( , $user, $domain ) = explode( "@", $username ); 1235 - $tags[] = array( 1236 - "type" => "Mention", 1237 - "href" => "https://{$domain}/@{$user}", 1238 - "name" => "{$username}" 1239 - ); 1116 + $inbox_host = parse_url($inbox, PHP_URL_HOST); 1117 + $inbox_path = parse_url($inbox, PHP_URL_PATH); 1240 1118 1241 - // Add HTML links to usernames 1242 - $username_link = "<a href=\"https://{$domain}/@{$user}\">$username</a>"; 1243 - $content = str_replace( $username, $username_link, $content ); 1244 - } 1119 + // Generate the signed headers 1120 + $headers = generate_signed_headers($message, $inbox_host, $inbox_path, "POST"); 1245 1121 1246 - // Construct HTML breaks from carriage returns and line breaks 1247 - $linebreak_patterns = array( "\r\n", "\r", "\n" ); // Variations of line breaks found in raw text 1248 - $content = str_replace( $linebreak_patterns, "<br/>", $content ); 1249 - 1250 - // Construct the content 1251 - $content = "<p>{$content}</p>"; 1122 + // POST the message and header to the requester's inbox 1123 + $ch = curl_init($inbox); 1124 + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); 1125 + curl_setopt($ch, CURLOPT_CUSTOMREQUEST, "POST"); 1126 + curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($message)); 1127 + curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); 1128 + curl_setopt($ch, CURLOPT_USERAGENT, USERAGENT); 1129 + curl_exec($ch); 1252 1130 1253 - return [ 1254 - "HTML" => $content, 1255 - "TagArray" => $tags 1256 - ]; 1131 + // Check for errors 1132 + if (curl_errno($ch)) { 1133 + $timestamp = (new DateTime())->format(DATE_RFC3339_EXTENDED); 1134 + $error_message = curl_error($ch) . "\ninbox: {$inbox}\nmessage: " . json_encode($message); 1135 + file_put_contents($directories["logs"] . "/{$timestamp}.Error.txt", $error_message); 1136 + return false; 1257 1137 } 1138 + curl_close($ch); 1139 + return true; 1140 + } 1258 1141 1259 - // When given the URl of a post, this looks up the post, finds the user, then returns their inbox or shared inbox 1260 - function getInboxFromMessageURl( $url ) { 1261 - 1262 - // Get details about the message 1263 - $messageData = getDataFromURl( $url ); 1142 + // POST a signed message to the inboxes of all followers 1143 + function sendMessageToFollowers($message) 1144 + { 1145 + global $directories; 1146 + // Read existing followers 1147 + $followers = glob($directories["followers"] . "/*.json"); 1264 1148 1265 - // The author is the user who the message is attributed to 1266 - if ( isset ( $messageData["attributedTo"] ) && filter_var( $messageData["attributedTo"], FILTER_VALIDATE_URL) ) { 1267 - $profileData = getDataFromURl( $messageData["attributedTo"] ); 1268 - } else { 1269 - return null; 1270 - } 1271 - 1272 - // Get the shared inbox or personal inbox 1273 - if( isset( $profileData["endpoints"]["sharedInbox"] ) ) { 1274 - $inbox = $profileData["endpoints"]["sharedInbox"]; 1149 + // Get all the inboxes 1150 + $inboxes = []; 1151 + foreach ($followers as $follower) { 1152 + // Get the data about the follower 1153 + $follower_info = json_decode(file_get_contents($follower), true); 1154 + 1155 + // Some servers have "Shared inboxes" 1156 + // If you have lots of followers on a single server, you only need to send the message once. 1157 + if (isset($follower_info["endpoints"]["sharedInbox"])) { 1158 + $sharedInbox = $follower_info["endpoints"]["sharedInbox"]; 1159 + if (!in_array($sharedInbox, $inboxes)) { 1160 + $inboxes[] = $sharedInbox; 1161 + } 1275 1162 } else { 1276 1163 // If not, use the individual inbox 1277 - $inbox = $profileData["inbox"]; 1164 + $inbox = $follower_info["inbox"]; 1165 + if (!in_array($inbox, $inboxes)) { 1166 + $inboxes[] = $inbox; 1167 + } 1278 1168 } 1169 + } 1279 1170 1280 - // Return the destination inbox if it is valid 1281 - if ( filter_var( $inbox, FILTER_VALIDATE_URL) ) { 1282 - return $inbox; 1283 - } else { 1284 - return null; 1171 + // Prepare to use the multiple cURL handle 1172 + // This makes it more efficient to send many simultaneous messages 1173 + $mh = curl_multi_init(); 1174 + 1175 + // Loop through all the inboxes of the followers 1176 + // Each server needs its own cURL handle 1177 + // Each POST to an inbox needs to be signed separately 1178 + foreach ($inboxes as $inbox) { 1179 + 1180 + $inbox_host = parse_url($inbox, PHP_URL_HOST); 1181 + $inbox_path = parse_url($inbox, PHP_URL_PATH); 1182 + 1183 + // Generate the signed headers 1184 + $headers = generate_signed_headers($message, $inbox_host, $inbox_path, "POST"); 1185 + 1186 + // POST the message and header to the requester's inbox 1187 + $ch = curl_init($inbox); 1188 + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); 1189 + curl_setopt($ch, CURLOPT_CUSTOMREQUEST, "POST"); 1190 + curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($message)); 1191 + curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); 1192 + curl_setopt($ch, CURLOPT_USERAGENT, USERAGENT); 1193 + 1194 + // Add the handle to the multi-handle 1195 + curl_multi_add_handle($mh, $ch); 1196 + } 1197 + 1198 + // Execute the multi-handle 1199 + do { 1200 + $status = curl_multi_exec($mh, $active); 1201 + if ($active) { 1202 + curl_multi_select($mh); 1285 1203 } 1204 + } while ($active && $status == CURLM_OK); 1205 + 1206 + // Close the multi-handle 1207 + curl_multi_close($mh); 1208 + 1209 + return true; 1210 + } 1211 + 1212 + // Content can be plain text. But to add clickable links and hashtags, it needs to be turned into HTML. 1213 + // Tags are also included separately in the note 1214 + function process_content($content) 1215 + { 1216 + global $server; 1217 + 1218 + // Convert any URls into hyperlinks 1219 + $link_pattern = '/\bhttps?:\/\/\S+/iu'; // Sloppy regex 1220 + $replacement = function ($match) { 1221 + $url = htmlspecialchars($match[0], ENT_QUOTES, "UTF-8"); 1222 + return "<a href=\"$url\">$url</a>"; 1223 + }; 1224 + $content = preg_replace_callback($link_pattern, $replacement, $content); 1225 + 1226 + // Get any hashtags 1227 + $hashtags = []; 1228 + $hashtag_pattern = '/(?:^|\s)\#(\w+)/'; // Beginning of string, or whitespace, followed by # 1229 + preg_match_all($hashtag_pattern, $content, $hashtag_matches); 1230 + foreach ($hashtag_matches[1] as $match) { 1231 + $hashtags[] = $match; 1286 1232 } 1287 1233 1288 - // GET a request to a URl and returns structured data 1289 - function getDataFromURl ( $url ) { 1290 - // Check this is a valid https address 1291 - if( 1292 - ( filter_var( $url, FILTER_VALIDATE_URL) != true) || 1293 - ( parse_url( $url, PHP_URL_SCHEME ) != "https" ) 1294 - ) { die(); } 1234 + // Construct the tag value for the note object 1235 + $tags = []; 1236 + foreach ($hashtags as $hashtag) { 1237 + $tags[] = array( 1238 + "type" => "Hashtag", 1239 + "name" => "#{$hashtag}", 1240 + ); 1241 + } 1242 + 1243 + // Add HTML links for hashtags into the text 1244 + // Todo: Make these links do something. 1245 + $content = preg_replace( 1246 + $hashtag_pattern, 1247 + " <a href='https://{$server}/tag/$1'>#$1</a>", 1248 + $content 1249 + ); 1295 1250 1296 - // Split the URL 1297 - $url_host = parse_url( $url, PHP_URL_HOST ); 1298 - $url_path = parse_url( $url, PHP_URL_PATH ); 1251 + // Detect user mentions 1252 + $usernames = []; 1253 + $usernames_pattern = '/@(\S+)@(\S+)/'; // This is a *very* sloppy regex 1254 + preg_match_all($usernames_pattern, $content, $usernames_matches); 1255 + foreach ($usernames_matches[0] as $match) { 1256 + $usernames[] = $match; 1257 + } 1258 + 1259 + // Construct the mentions value for the note object 1260 + // This goes in the generic "tag" property 1261 + // TODO: Add this to the CC field & appropriate inbox 1262 + foreach ($usernames as $username) { 1263 + list(, $user, $domain) = explode("@", $username); 1264 + $tags[] = array( 1265 + "type" => "Mention", 1266 + "href" => "https://{$domain}/@{$user}", 1267 + "name" => "{$username}" 1268 + ); 1299 1269 1300 - // Generate signed headers for this request 1301 - $headers = generate_signed_headers( null, $url_host, $url_path, "GET" ); 1270 + // Add HTML links to usernames 1271 + $username_link = "<a href=\"https://{$domain}/@{$user}\">$username</a>"; 1272 + $content = str_replace($username, $username_link, $content); 1273 + } 1302 1274 1303 - // Set cURL options 1304 - $ch = curl_init( $url ); 1305 - curl_setopt( $ch, CURLOPT_RETURNTRANSFER, true ); 1306 - curl_setopt( $ch, CURLOPT_FOLLOWLOCATION, true ); 1307 - curl_setopt( $ch, CURLOPT_HTTPHEADER, $headers ); 1308 - curl_setopt( $ch, CURLOPT_USERAGENT, USERAGENT ); 1275 + // Construct HTML breaks from carriage returns and line breaks 1276 + $linebreak_patterns = array("\r\n", "\r", "\n"); // Variations of line breaks found in raw text 1277 + $content = str_replace($linebreak_patterns, "<br/>", $content); 1309 1278 1310 - // Execute the cURL session 1311 - $urlJSON = curl_exec( $ch ); 1279 + // Construct the content 1280 + $content = "<p>{$content}</p>"; 1312 1281 1313 - $status_code = curl_getinfo( $ch, CURLINFO_RESPONSE_CODE ); 1282 + return [ 1283 + "HTML" => $content, 1284 + "TagArray" => $tags 1285 + ]; 1286 + } 1314 1287 1315 - // Check for errors 1316 - if ( curl_errno( $ch ) || $status_code == 404 ) { 1317 - // Handle cURL error 1318 - $timestamp = ( new DateTime() )->format( DATE_RFC3339_EXTENDED ); 1319 - $error_message = curl_error( $ch ) . "\nURl: {$url}\nHeaders: " . json_encode( $headers ); 1320 - file_put_contents( $directories["logs"] . "/{$timestamp}.Error.txt", $error_message ); 1321 - die(); 1322 - } 1288 + // When given the URl of a post, this looks up the post, finds the user, then returns their inbox or shared inbox 1289 + function getInboxFromMessageURl($url) 1290 + { 1323 1291 1324 - // Close cURL session 1325 - curl_close( $ch ); 1292 + // Get details about the message 1293 + $messageData = getDataFromURl($url); 1326 1294 1327 - return json_decode( $urlJSON, true ); 1295 + // The author is the user who the message is attributed to 1296 + if (isset($messageData["attributedTo"]) && filter_var($messageData["attributedTo"], FILTER_VALIDATE_URL)) { 1297 + $profileData = getDataFromURl($messageData["attributedTo"]); 1298 + } else { 1299 + return null; 1328 1300 } 1329 1301 1330 - // The Outbox contains a date-ordered list (newest first) of all the user's posts 1331 - // This is optional. 1332 - function outbox() { 1333 - global $server, $username, $directories; 1302 + // Get the shared inbox or personal inbox 1303 + if (isset($profileData["endpoints"]["sharedInbox"])) { 1304 + $inbox = $profileData["endpoints"]["sharedInbox"]; 1305 + } else { 1306 + // If not, use the individual inbox 1307 + $inbox = $profileData["inbox"]; 1308 + } 1334 1309 1335 - // Get all posts 1336 - $posts = array_reverse( glob( $directories["posts"] . "/*.json") ); 1337 - // Number of posts 1338 - $totalItems = count( $posts ); 1339 - // Create an ordered list 1340 - $orderedItems = []; 1341 - foreach ( $posts as $post ) { 1342 - $postData = json_decode( file_get_contents( $post ), true ); 1343 - $orderedItems[] = array( 1344 - "type" => $postData["type"], 1345 - "actor" => "https://{$server}/{$username}", 1346 - "object" => "https://{$server}/{$post}" 1347 - ); 1348 - } 1310 + // Return the destination inbox if it is valid 1311 + if (filter_var($inbox, FILTER_VALIDATE_URL)) { 1312 + return $inbox; 1313 + } else { 1314 + return null; 1315 + } 1316 + } 1349 1317 1350 - // Create User's outbox 1351 - $outbox = array( 1352 - "@context" => "https://www.w3.org/ns/activitystreams", 1353 - "id" => "https://{$server}/outbox", 1354 - "type" => "OrderedCollection", 1355 - "totalItems" => $totalItems, 1356 - "summary" => "All the user's posts", 1357 - "orderedItems" => $orderedItems 1358 - ); 1359 - 1360 - // Render the page 1361 - header( "Content-Type: application/activity+json" ); 1362 - echo json_encode( $outbox ); 1318 + // GET a request to a URl and returns structured data 1319 + function getDataFromURl($url) 1320 + { 1321 + // Check this is a valid https address 1322 + if ( 1323 + (filter_var($url, FILTER_VALIDATE_URL) != true) || 1324 + (parse_url($url, PHP_URL_SCHEME) != "https") 1325 + ) { 1363 1326 die(); 1364 1327 } 1365 1328 1366 - // Verify the signature sent with the message. 1367 - // This is optional 1368 - // It is very confusing 1369 - function verifyHTTPSignature() { 1370 - global $input, $body, $server, $directories; 1329 + // Split the URL 1330 + $url_host = parse_url($url, PHP_URL_HOST); 1331 + $url_path = parse_url($url, PHP_URL_PATH); 1371 1332 1372 - // What type of message is this? What's the time now? 1373 - // Used in the log filename. 1374 - $type = urlencode( $body["type"] ); 1375 - $timestamp = ( new DateTime() )->format( DATE_RFC3339_EXTENDED ); 1333 + // Generate signed headers for this request 1334 + $headers = generate_signed_headers(null, $url_host, $url_path, "GET"); 1376 1335 1377 - // Get the headers send with the request 1378 - $headers = getallheaders(); 1379 - // Ensure the header keys match the format expected by the signature 1380 - $headers = array_change_key_case( $headers, CASE_LOWER ); 1336 + // Set cURL options 1337 + $ch = curl_init($url); 1338 + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); 1339 + curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true); 1340 + curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); 1341 + curl_setopt($ch, CURLOPT_USERAGENT, USERAGENT); 1381 1342 1382 - // Validate the timestamp 1383 - // 7.2.4 of https://datatracker.ietf.org/doc/rfc9421/ 1384 - if ( !isset( $headers["date"] ) ) { 1385 - // No date set 1386 - // Filename for the log 1387 - $filename = "{$timestamp}.{$type}.Signature.Date_Failure.txt"; 1343 + // Execute the cURL session 1344 + $urlJSON = curl_exec($ch); 1388 1345 1389 - // Save headers and request data to the timestamped file in the logs directory 1390 - file_put_contents( $directories["logs"] . "/{$filename}", 1391 - "Original Body:\n" . print_r( $body, true ) . "\n\n" . 1392 - "Original Headers:\n" . print_r( $headers, true ) . "\n\n" 1393 - ); 1394 - return null; 1395 - } 1396 - $dateHeader = $headers["date"]; 1397 - $headerDatetime = DateTime::createFromFormat('D, d M Y H:i:s T', $dateHeader); 1398 - $currentDatetime = new DateTime(); 1346 + $status_code = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); 1399 1347 1400 - // First, check if the message was sent no more than ± 1 hour 1401 - // https://github.com/mastodon/mastodon/blob/82c2af0356ff888e9665b5b08fda58c7722be637/app/controllers/concerns/signature_verification.rb#L11 1402 - // Calculate the time difference in seconds 1403 - $timeDifference = abs( $currentDatetime->getTimestamp() - $headerDatetime->getTimestamp() ); 1404 - if ( $timeDifference > 3600 ) { 1405 - // Write a log detailing the error 1406 - // Filename for the log 1407 - $filename = "{$timestamp}.{$type}.Signature.Delay_Failure.txt"; 1348 + // Check for errors 1349 + if (curl_errno($ch) || $status_code == 404) { 1350 + // Handle cURL error 1351 + $timestamp = (new DateTime())->format(DATE_RFC3339_EXTENDED); 1352 + $error_message = curl_error($ch) . "\nURl: {$url}\nHeaders: " . json_encode($headers); 1353 + file_put_contents($directories["logs"] . "/{$timestamp}.Error.txt", $error_message); 1354 + die(); 1355 + } 1408 1356 1409 - // Save headers and request data to the timestamped file in the logs directory 1410 - file_put_contents( $directories["logs"] . "/{$filename}", 1411 - "Header Date:\n" . print_r( $dateHeader, true ) . "\n" . 1412 - "Server Date:\n" . print_r( $currentDatetime->format('D, d M Y H:i:s T'), true ) ."\n" . 1413 - "Original Body:\n" . print_r( $body, true ) . "\n\n" . 1414 - "Original Headers:\n" . print_r( $headers, true ) . "\n\n" 1415 - ); 1416 - return false; 1417 - } 1357 + // Close cURL session 1358 + curl_close($ch); 1418 1359 1419 - // Is there a significant difference between the Date header and the published timestamp? 1420 - // Two minutes chosen because Friendica is frequently more than a minute skewed 1421 - $published = $body["published"]; 1422 - $publishedDatetime = new DateTime($published); 1423 - // Calculate the time difference in seconds 1424 - $timeDifference = abs( $publishedDatetime->getTimestamp() - $headerDatetime->getTimestamp() ); 1425 - if ( $timeDifference > 120 ) { 1426 - // Write a log detailing the error 1427 - // Filename for the log 1428 - $filename = "{$timestamp}.{$type}.Signature.Time_Failure.txt"; 1360 + return json_decode($urlJSON, true); 1361 + } 1429 1362 1430 - // Save headers and request data to the timestamped file in the logs directory 1431 - file_put_contents( $directories["logs"] . "/{$filename}", 1432 - "Header Date:\n" . print_r( $dateHeader, true ) . "\n" . 1433 - "Published Date:\n" . print_r( $publishedDatetime->format('D, d M Y H:i:s T'), true ) ."\n" . 1434 - "Original Body:\n" . print_r( $body, true ) . "\n\n" . 1435 - "Original Headers:\n" . print_r( $headers, true ) . "\n\n" 1436 - ); 1437 - return false; 1438 - } 1363 + // The Outbox contains a date-ordered list (newest first) of all the user's posts 1364 + // This is optional. 1365 + function outbox() 1366 + { 1367 + global $server, $username, $directories; 1439 1368 1440 - // Validate the Digest 1441 - // It is the hash of the raw input string, in binary, encoded as base64. 1442 - $digestString = $headers["digest"]; 1369 + // Get all posts 1370 + $posts = array_reverse(glob($directories["posts"] . "/*.json")); 1371 + // Number of posts 1372 + $totalItems = count($posts); 1373 + // Create an ordered list 1374 + $orderedItems = []; 1375 + foreach ($posts as $post) { 1376 + $postData = json_decode(file_get_contents($post), true); 1377 + $orderedItems[] = array( 1378 + "type" => $postData["type"], 1379 + "actor" => "https://{$server}/{$username}", 1380 + "object" => "https://{$server}/{$post}" 1381 + ); 1382 + } 1443 1383 1444 - // Usually in the form `SHA-256=Ofv56Jm9rlowLR9zTkfeMGLUG1JYQZj0up3aRPZgT0c=` 1445 - // The Base64 encoding may have multiple `=` at the end. So split this at the first `=` 1446 - $digestData = explode( "=", $digestString, 2 ); 1447 - $digestAlgorithm = $digestData[0]; 1448 - $digestHash = $digestData[1]; 1384 + // Create User's outbox 1385 + $outbox = array( 1386 + "@context" => "https://www.w3.org/ns/activitystreams", 1387 + "id" => "https://{$server}/outbox", 1388 + "type" => "OrderedCollection", 1389 + "totalItems" => $totalItems, 1390 + "summary" => "All the user's posts", 1391 + "orderedItems" => $orderedItems 1392 + ); 1449 1393 1450 - // There might be many different hashing algorithms 1451 - // TODO: Find a way to transform these automatically 1452 - // See https://github.com/superseriousbusiness/gotosocial/issues/1186#issuecomment-1976166659 and https://github.com/snarfed/bridgy-fed/issues/430 for hs2019 1453 - if ( "SHA-256" == $digestAlgorithm || "hs2019" == $digestAlgorithm ) { 1454 - $digestAlgorithm = "sha256"; 1455 - } else if ( "SHA-512" == $digestAlgorithm ) { 1456 - $digestAlgorithm = "sha512"; 1457 - } 1394 + // Render the page 1395 + header("Content-Type: application/activity+json"); 1396 + echo json_encode($outbox); 1397 + die(); 1398 + } 1458 1399 1459 - // Manually calculate the digest based on the data sent 1460 - $digestCalculated = base64_encode( hash( $digestAlgorithm, $input, true ) ); 1400 + // Verify the signature sent with the message. 1401 + // This is optional 1402 + // It is very confusing 1403 + function verifyHTTPSignature() 1404 + { 1405 + global $input, $body, $server, $directories; 1461 1406 1462 - // Does our calculation match what was sent? 1463 - if ( !( $digestCalculated == $digestHash ) ) { 1464 - // Write a log detailing the error 1465 - $filename = "{$timestamp}.{$type}.Signature.Digest_Failure.txt"; 1407 + // What type of message is this? What's the time now? 1408 + // Used in the log filename. 1409 + $type = urlencode($body["type"]); 1410 + $timestamp = (new DateTime())->format(DATE_RFC3339_EXTENDED); 1466 1411 1467 - // Save headers and request data to the timestamped file in the logs directory 1468 - file_put_contents( $directories["logs"] . "/{$filename}", 1469 - "Original Input:\n" . print_r( $input, true ) . "\n" . 1470 - "Original Digest:\n" . print_r( $digestString, true ) . "\n" . 1471 - "Calculated Digest:\n" . print_r( $digestCalculated, true ) . "\n" 1472 - ); 1473 - return false; 1474 - } 1412 + // Get the headers send with the request 1413 + $headers = getallheaders(); 1414 + // Ensure the header keys match the format expected by the signature 1415 + $headers = array_change_key_case($headers, CASE_LOWER); 1475 1416 1476 - // Examine the signature 1477 - $signatureHeader = $headers["signature"]; 1417 + // Validate the timestamp 1418 + // 7.2.4 of https://datatracker.ietf.org/doc/rfc9421/ 1419 + if (!isset($headers["date"])) { 1420 + // No date set 1421 + // Filename for the log 1422 + $filename = "{$timestamp}.{$type}.Signature.Date_Failure.txt"; 1478 1423 1479 - // Extract key information from the Signature header 1480 - $signatureParts = []; 1481 - // Converts 'a=b,c=d e f' into ["a"=>"b", "c"=>"d e f"] 1482 - // word="text" 1483 - preg_match_all('/(\w+)="([^"]+)"/', $signatureHeader, $matches); 1484 - foreach ( $matches[1] as $index => $key ) { 1485 - $signatureParts[$key] = $matches[2][$index]; 1486 - } 1424 + // Save headers and request data to the timestamped file in the logs directory 1425 + file_put_contents( 1426 + $directories["logs"] . "/{$filename}", 1427 + "Original Body:\n" . print_r($body, true) . "\n\n" . 1428 + "Original Headers:\n" . print_r($headers, true) . "\n\n" 1429 + ); 1430 + return null; 1431 + } 1432 + $dateHeader = $headers["date"]; 1433 + $headerDatetime = DateTime::createFromFormat('D, d M Y H:i:s T', $dateHeader); 1434 + $currentDatetime = new DateTime(); 1487 1435 1488 - // Manually reconstruct the header string 1489 - $signatureHeaders = explode(" ", $signatureParts["headers"] ); 1490 - $signatureString = ""; 1491 - foreach ( $signatureHeaders as $signatureHeader ) { 1492 - if ( "(request-target)" == $signatureHeader ) { 1493 - $method = strtolower( $_SERVER["REQUEST_METHOD"] ); 1494 - $target = $_SERVER["REQUEST_URI"]; 1495 - $signatureString .= "(request-target): {$method} {$target}\n"; 1496 - } else if ( "host" == $signatureHeader ) { 1497 - $host = strtolower( $_SERVER["HTTP_HOST"] ); 1498 - $signatureString .= "host: {$host}\n"; 1499 - } else { 1500 - $signatureString .= "{$signatureHeader}: " . $headers[$signatureHeader] . "\n"; 1501 - } 1502 - } 1436 + // First, check if the message was sent no more than ± 1 hour 1437 + // https://github.com/mastodon/mastodon/blob/82c2af0356ff888e9665b5b08fda58c7722be637/app/controllers/concerns/signature_verification.rb#L11 1438 + // Calculate the time difference in seconds 1439 + $timeDifference = abs($currentDatetime->getTimestamp() - $headerDatetime->getTimestamp()); 1440 + if ($timeDifference > 3600) { 1441 + // Write a log detailing the error 1442 + // Filename for the log 1443 + $filename = "{$timestamp}.{$type}.Signature.Delay_Failure.txt"; 1503 1444 1504 - // Remove trailing newline 1505 - $signatureString = trim( $signatureString ); 1445 + // Save headers and request data to the timestamped file in the logs directory 1446 + file_put_contents( 1447 + $directories["logs"] . "/{$filename}", 1448 + "Header Date:\n" . print_r($dateHeader, true) . "\n" . 1449 + "Server Date:\n" . print_r($currentDatetime->format('D, d M Y H:i:s T'), true) . "\n" . 1450 + "Original Body:\n" . print_r($body, true) . "\n\n" . 1451 + "Original Headers:\n" . print_r($headers, true) . "\n\n" 1452 + ); 1453 + return false; 1454 + } 1506 1455 1507 - // Get the Public Key 1508 - // The link to the key might be sent with the body, but is always sent in the Signature header. 1509 - $publicKeyURL = $signatureParts["keyId"]; 1456 + // Is there a significant difference between the Date header and the published timestamp? 1457 + // Two minutes chosen because Friendica is frequently more than a minute skewed 1458 + $published = $body["published"]; 1459 + $publishedDatetime = new DateTime($published); 1460 + // Calculate the time difference in seconds 1461 + $timeDifference = abs($publishedDatetime->getTimestamp() - $headerDatetime->getTimestamp()); 1462 + if ($timeDifference > 120) { 1463 + // Write a log detailing the error 1464 + // Filename for the log 1465 + $filename = "{$timestamp}.{$type}.Signature.Time_Failure.txt"; 1510 1466 1511 - // This is usually in the form `https://example.com/user/username#main-key` 1512 - // This is to differentiate if the user has multiple keys 1513 - // TODO: Check the actual key 1514 - $userData = getDataFromURl( $publicKeyURL ); 1515 - $publicKey = $userData["publicKey"]["publicKeyPem"]; 1467 + // Save headers and request data to the timestamped file in the logs directory 1468 + file_put_contents( 1469 + $directories["logs"] . "/{$filename}", 1470 + "Header Date:\n" . print_r($dateHeader, true) . "\n" . 1471 + "Published Date:\n" . print_r($publishedDatetime->format('D, d M Y H:i:s T'), true) . "\n" . 1472 + "Original Body:\n" . print_r($body, true) . "\n\n" . 1473 + "Original Headers:\n" . print_r($headers, true) . "\n\n" 1474 + ); 1475 + return false; 1476 + } 1516 1477 1517 - // Check that the actor's key is the same as the key used to sign the message 1518 - // Get the actor's public key 1519 - $actorData = getDataFromURl( $body["actor"] ); 1520 - $actorPublicKey = $actorData["publicKey"]["publicKeyPem"]; 1478 + // Validate the Digest 1479 + // It is the hash of the raw input string, in binary, encoded as base64. 1480 + $digestString = $headers["digest"]; 1521 1481 1522 - if ( $publicKey != $actorPublicKey ) { 1523 - // Filename for the log 1524 - $filename = "{$timestamp}.{$type}.Signature.Mismatch_Failure.txt"; 1482 + // Usually in the form `SHA-256=Ofv56Jm9rlowLR9zTkfeMGLUG1JYQZj0up3aRPZgT0c=` 1483 + // The Base64 encoding may have multiple `=` at the end. So split this at the first `=` 1484 + $digestData = explode("=", $digestString, 2); 1485 + $digestAlgorithm = $digestData[0]; 1486 + $digestHash = $digestData[1]; 1525 1487 1526 - // Save headers and request data to the timestamped file in the logs directory 1527 - file_put_contents( $directories["logs"] . "/{$filename}", 1528 - "Original Body:\n" . print_r( $body, true ) . "\n\n" . 1529 - "Original Headers:\n" . print_r( $headers, true ) . "\n\n" . 1530 - "Signature Headers:\n" . print_r( $signatureHeaders, true ) . "\n\n" . 1531 - "publicKeyURL:\n" . print_r( $publicKeyURL, true ) . "\n\n" . 1532 - "publicKey:\n" . print_r( $publicKey, true ) . "\n\n" . 1533 - "actorPublicKey:\n" . print_r( $actorPublicKey, true ) . "\n" 1534 - ); 1535 - return false; 1536 - } 1537 - 1538 - // Get the remaining parts 1539 - $signature = base64_decode( $signatureParts["signature"] ); 1540 - $algorithm = $signatureParts["algorithm"]; 1488 + // There might be many different hashing algorithms 1489 + // TODO: Find a way to transform these automatically 1490 + // See https://github.com/superseriousbusiness/gotosocial/issues/1186#issuecomment-1976166659 and https://github.com/snarfed/bridgy-fed/issues/430 for hs2019 1491 + if ("SHA-256" == $digestAlgorithm || "hs2019" == $digestAlgorithm) { 1492 + $digestAlgorithm = "sha256"; 1493 + } else if ("SHA-512" == $digestAlgorithm) { 1494 + $digestAlgorithm = "sha512"; 1495 + } 1541 1496 1542 - // There might be many different signing algorithms 1543 - // TODO: Find a way to transform these automatically 1544 - // See https://github.com/superseriousbusiness/gotosocial/issues/1186#issuecomment-1976166659 and https://github.com/snarfed/bridgy-fed/issues/430 for hs2019 1545 - if ( "hs2019" == $algorithm ) { 1546 - $algorithm = "sha256"; 1547 - } 1497 + // Manually calculate the digest based on the data sent 1498 + $digestCalculated = base64_encode(hash($digestAlgorithm, $input, true)); 1499 + 1500 + // Does our calculation match what was sent? 1501 + if (!($digestCalculated == $digestHash)) { 1502 + // Write a log detailing the error 1503 + $filename = "{$timestamp}.{$type}.Signature.Digest_Failure.txt"; 1548 1504 1549 - // Finally! Calculate whether the signature is valid 1550 - // Returns 1 if verified, 0 if not, false or -1 if an error occurred 1551 - $verified = openssl_verify( 1552 - $signatureString, 1553 - $signature, 1554 - $publicKey, 1555 - $algorithm 1505 + // Save headers and request data to the timestamped file in the logs directory 1506 + file_put_contents( 1507 + $directories["logs"] . "/{$filename}", 1508 + "Original Input:\n" . print_r($input, true) . "\n" . 1509 + "Original Digest:\n" . print_r($digestString, true) . "\n" . 1510 + "Calculated Digest:\n" . print_r($digestCalculated, true) . "\n" 1556 1511 ); 1512 + return false; 1513 + } 1557 1514 1558 - // Convert to boolean 1559 - if ( $verified === 1 ) { 1560 - $verified = true; 1561 - } elseif ( $verified === 0 ) { 1562 - $verified = false; 1515 + // Examine the signature 1516 + $signatureHeader = $headers["signature"]; 1517 + 1518 + // Extract key information from the Signature header 1519 + $signatureParts = []; 1520 + // Converts 'a=b,c=d e f' into ["a"=>"b", "c"=>"d e f"] 1521 + // word="text" 1522 + preg_match_all('/(\w+)="([^"]+)"/', $signatureHeader, $matches); 1523 + foreach ($matches[1] as $index => $key) { 1524 + $signatureParts[$key] = $matches[2][$index]; 1525 + } 1526 + 1527 + // Manually reconstruct the header string 1528 + $signatureHeaders = explode(" ", $signatureParts["headers"]); 1529 + $signatureString = ""; 1530 + foreach ($signatureHeaders as $signatureHeader) { 1531 + if ("(request-target)" == $signatureHeader) { 1532 + $method = strtolower($_SERVER["REQUEST_METHOD"]); 1533 + $target = $_SERVER["REQUEST_URI"]; 1534 + $signatureString .= "(request-target): {$method} {$target}\n"; 1535 + } else if ("host" == $signatureHeader) { 1536 + $host = strtolower($_SERVER["HTTP_HOST"]); 1537 + $signatureString .= "host: {$host}\n"; 1563 1538 } else { 1564 - $verified = null; 1539 + $signatureString .= "{$signatureHeader}: " . $headers[$signatureHeader] . "\n"; 1565 1540 } 1566 - 1541 + } 1542 + 1543 + // Remove trailing newline 1544 + $signatureString = trim($signatureString); 1545 + 1546 + // Get the Public Key 1547 + // The link to the key might be sent with the body, but is always sent in the Signature header. 1548 + $publicKeyURL = $signatureParts["keyId"]; 1549 + 1550 + // This is usually in the form `https://example.com/user/username#main-key` 1551 + // This is to differentiate if the user has multiple keys 1552 + // TODO: Check the actual key 1553 + $userData = getDataFromURl($publicKeyURL); 1554 + $publicKey = $userData["publicKey"]["publicKeyPem"]; 1555 + 1556 + // Check that the actor's key is the same as the key used to sign the message 1557 + // Get the actor's public key 1558 + $actorData = getDataFromURl($body["actor"]); 1559 + $actorPublicKey = $actorData["publicKey"]["publicKeyPem"]; 1560 + 1561 + if ($publicKey != $actorPublicKey) { 1567 1562 // Filename for the log 1568 - $filename = "{$timestamp}.{$type}.Signature.". json_encode( $verified ) . ".txt"; 1563 + $filename = "{$timestamp}.{$type}.Signature.Mismatch_Failure.txt"; 1569 1564 1570 1565 // Save headers and request data to the timestamped file in the logs directory 1571 - file_put_contents( $directories["logs"] . "/{$filename}", 1572 - "Original Body:\n" . print_r( $body, true ) . "\n\n" . 1573 - "Original Headers:\n" . print_r( $headers, true ) . "\n\n" . 1574 - "Signature Headers:\n" . print_r( $signatureHeaders, true ) . "\n\n" . 1575 - "Calculated signatureString:\n" . print_r( $signatureString, true ) . "\n\n" . 1576 - "Calculated algorithm:\n" . print_r( $algorithm, true ) . "\n\n" . 1577 - "publicKeyURL:\n" . print_r( $publicKeyURL, true ) . "\n\n" . 1578 - "publicKey:\n" . print_r( $publicKey, true ) . "\n\n" . 1579 - "actorPublicKey:\n" . print_r( $actorPublicKey, true ) . "\n" 1566 + file_put_contents( 1567 + $directories["logs"] . "/{$filename}", 1568 + "Original Body:\n" . print_r($body, true) . "\n\n" . 1569 + "Original Headers:\n" . print_r($headers, true) . "\n\n" . 1570 + "Signature Headers:\n" . print_r($signatureHeaders, true) . "\n\n" . 1571 + "publicKeyURL:\n" . print_r($publicKeyURL, true) . "\n\n" . 1572 + "publicKey:\n" . print_r($publicKey, true) . "\n\n" . 1573 + "actorPublicKey:\n" . print_r($actorPublicKey, true) . "\n" 1580 1574 ); 1575 + return false; 1576 + } 1581 1577 1582 - return $verified; 1578 + // Get the remaining parts 1579 + $signature = base64_decode($signatureParts["signature"]); 1580 + $algorithm = $signatureParts["algorithm"]; 1581 + 1582 + // There might be many different signing algorithms 1583 + // TODO: Find a way to transform these automatically 1584 + // See https://github.com/superseriousbusiness/gotosocial/issues/1186#issuecomment-1976166659 and https://github.com/snarfed/bridgy-fed/issues/430 for hs2019 1585 + if ("hs2019" == $algorithm) { 1586 + $algorithm = "sha256"; 1583 1587 } 1584 1588 1585 - // The NodeInfo Protocol is used to identify servers. 1586 - // It is looked up with `example.com/.well-known/nodeinfo` 1587 - // See https://nodeinfo.diaspora.software/ 1588 - function wk_nodeinfo() { 1589 - global $server; 1589 + // Finally! Calculate whether the signature is valid 1590 + // Returns 1 if verified, 0 if not, false or -1 if an error occurred 1591 + $verified = openssl_verify( 1592 + $signatureString, 1593 + $signature, 1594 + $publicKey, 1595 + $algorithm 1596 + ); 1590 1597 1591 - $nodeinfo = array( 1592 - "links" => array( 1593 - array( 1594 - "rel" => "self", 1595 - "type" => "http://nodeinfo.diaspora.software/ns/schema/2.1", 1596 - "href" => "https://{$server}/nodeinfo/2.1" 1597 - ) 1598 - ) 1599 - ); 1600 - header( "Content-Type: application/json" ); 1601 - echo json_encode( $nodeinfo ); 1602 - die(); 1598 + // Convert to boolean 1599 + if ($verified === 1) { 1600 + $verified = true; 1601 + } elseif ($verified === 0) { 1602 + $verified = false; 1603 + } else { 1604 + $verified = null; 1603 1605 } 1604 1606 1605 - // The NodeInfo Protocol is used to identify servers. 1606 - // It is looked up with `example.com/.well-known/nodeinfo` which points to this resource 1607 - // See http://nodeinfo.diaspora.software/docson/index.html#/ns/schema/2.0#$$expand 1608 - function nodeinfo() { 1609 - global $server, $directories; 1607 + // Filename for the log 1608 + $filename = "{$timestamp}.{$type}.Signature." . json_encode($verified) . ".txt"; 1610 1609 1611 - // Get all posts 1612 - $posts = glob( $directories["posts"] . "/*.json") ; 1613 - // Number of posts 1614 - $totalItems = count( $posts ); 1610 + // Save headers and request data to the timestamped file in the logs directory 1611 + file_put_contents( 1612 + $directories["logs"] . "/{$filename}", 1613 + "Original Body:\n" . print_r($body, true) . "\n\n" . 1614 + "Original Headers:\n" . print_r($headers, true) . "\n\n" . 1615 + "Signature Headers:\n" . print_r($signatureHeaders, true) . "\n\n" . 1616 + "Calculated signatureString:\n" . print_r($signatureString, true) . "\n\n" . 1617 + "Calculated algorithm:\n" . print_r($algorithm, true) . "\n\n" . 1618 + "publicKeyURL:\n" . print_r($publicKeyURL, true) . "\n\n" . 1619 + "publicKey:\n" . print_r($publicKey, true) . "\n\n" . 1620 + "actorPublicKey:\n" . print_r($actorPublicKey, true) . "\n" 1621 + ); 1615 1622 1616 - $nodeinfo = array( 1617 - "version" => "2.1", // Version of the schema, not the software 1618 - "software" => array( 1619 - "name" => "Single File ActivityPub Server in PHP", 1620 - "version" => "0.000000001", 1621 - "repository" => "https://gitlab.com/edent/activitypub-single-php-file/" 1622 - ), 1623 - "protocols" => array( "activitypub"), 1624 - "services" => array( 1625 - "inbound" => array(), 1626 - "outbound" => array() 1627 - ), 1628 - "openRegistrations" => false, 1629 - "usage" => array( 1630 - "users" => array( 1631 - "total" => 1 1632 - ), 1633 - "localPosts" => $totalItems 1623 + return $verified; 1624 + } 1625 + 1626 + // The NodeInfo Protocol is used to identify servers. 1627 + // It is looked up with `example.com/.well-known/nodeinfo` 1628 + // See https://nodeinfo.diaspora.software/ 1629 + function wk_nodeinfo() 1630 + { 1631 + global $server; 1632 + 1633 + $nodeinfo = array( 1634 + "links" => array( 1635 + array( 1636 + "rel" => "self", 1637 + "type" => "http://nodeinfo.diaspora.software/ns/schema/2.1", 1638 + "href" => "https://{$server}/nodeinfo/2.1" 1639 + ) 1640 + ) 1641 + ); 1642 + header("Content-Type: application/json"); 1643 + echo json_encode($nodeinfo); 1644 + die(); 1645 + } 1646 + 1647 + // The NodeInfo Protocol is used to identify servers. 1648 + // It is looked up with `example.com/.well-known/nodeinfo` which points to this resource 1649 + // See http://nodeinfo.diaspora.software/docson/index.html#/ns/schema/2.0#$$expand 1650 + function nodeinfo() 1651 + { 1652 + global $server, $directories; 1653 + 1654 + // Get all posts 1655 + $posts = glob($directories["posts"] . "/*.json"); 1656 + // Number of posts 1657 + $totalItems = count($posts); 1658 + 1659 + $nodeinfo = array( 1660 + "version" => "2.1", // Version of the schema, not the software 1661 + "software" => array( 1662 + "name" => "Single File ActivityPub Server in PHP", 1663 + "version" => "0.000000001", 1664 + "repository" => "https://gitlab.com/edent/activitypub-single-php-file/" 1665 + ), 1666 + "protocols" => array("activitypub"), 1667 + "services" => array( 1668 + "inbound" => array(), 1669 + "outbound" => array() 1670 + ), 1671 + "openRegistrations" => false, 1672 + "usage" => array( 1673 + "users" => array( 1674 + "total" => 1 1634 1675 ), 1635 - "metadata"=> array( 1636 - "nodeName" => "activitypub-single-php-file", 1637 - "nodeDescription" => "This is a single PHP file which acts as an extremely basic ActivityPub server.", 1638 - "spdx" => "AGPL-3.0-or-later" 1639 - ) 1640 - ); 1641 - header( "Content-Type: application/json" ); 1642 - echo json_encode( $nodeinfo ); 1643 - die(); 1676 + "localPosts" => $totalItems 1677 + ), 1678 + "metadata" => array( 1679 + "nodeName" => "activitypub-single-php-file", 1680 + "nodeDescription" => "This is a single PHP file which acts as an extremely basic ActivityPub server.", 1681 + "spdx" => "AGPL-3.0-or-later" 1682 + ) 1683 + ); 1684 + header("Content-Type: application/json"); 1685 + echo json_encode($nodeinfo); 1686 + die(); 1687 + } 1688 + 1689 + // Perform the Undo action requested 1690 + function undo($message) 1691 + { 1692 + global $server, $directories; 1693 + 1694 + // Get some basic data 1695 + $type = $message["type"]; 1696 + $id = $message["id"]; 1697 + // The thing being undone 1698 + $object = $message["object"]; 1699 + 1700 + // Does the thing being undone have its own ID or Type? 1701 + if (isset($object["id"])) { 1702 + $object_id = $object["id"]; 1703 + } else { 1704 + $object_id = $id; 1644 1705 } 1645 1706 1646 - // Perform the Undo action requested 1647 - function undo( $message ) { 1648 - global $server, $directories; 1707 + if (isset($object["type"])) { 1708 + $object_type = $object["type"]; 1709 + } else { 1710 + $object_type = $type; 1711 + } 1649 1712 1650 - // Get some basic data 1651 - $type = $message["type"]; 1652 - $id = $message["id"]; 1653 - // The thing being undone 1654 - $object = $message["object"]; 1713 + // Inbox items are stored as the hash of the original ID 1714 + $object_id_hash = hash("sha256", $object_id); 1655 1715 1656 - // Does the thing being undone have its own ID or Type? 1657 - if ( isset( $object["id"] ) ) { 1658 - $object_id = $object["id"]; 1659 - } else { 1660 - $object_id = $id; 1661 - } 1716 + // Find all the inbox messages which have that ID 1717 + $inbox_files = glob($directories["inbox"] . "/*.json"); 1718 + foreach ($inbox_files as $inbox_file) { 1719 + // Filenames are `data/inbox/[date]-[SHA256 hash0].[Type].json 1720 + // Find the position of the first hyphen and the first dot 1721 + $hyphenPosition = strpos($inbox_file, '-'); 1722 + $dotPosition = strpos($inbox_file, '.'); 1662 1723 1663 - if ( isset( $object["type"] ) ) { 1664 - $object_type = $object["type"]; 1724 + if ($hyphenPosition !== false && $dotPosition !== false) { 1725 + // Extract the text between the hyphen and the first dot 1726 + $file_id_hash = substr($inbox_file, $hyphenPosition + 1, $dotPosition - $hyphenPosition - 1); 1665 1727 } else { 1666 - $object_type = $type; 1728 + // Ignore the file and move to the next. 1729 + continue; 1667 1730 } 1668 1731 1669 - // Inbox items are stored as the hash of the original ID 1670 - $object_id_hash = hash( "sha256", $object_id ); 1671 - 1672 - // Find all the inbox messages which have that ID 1673 - $inbox_files = glob( $directories["inbox"] . "/*.json" ); 1674 - foreach ( $inbox_files as $inbox_file ) { 1675 - // Filenames are `data/inbox/[date]-[SHA256 hash0].[Type].json 1676 - // Find the position of the first hyphen and the first dot 1677 - $hyphenPosition = strpos( $inbox_file, '-' ); 1678 - $dotPosition = strpos( $inbox_file, '.' ); 1679 - 1680 - if ( $hyphenPosition !== false && $dotPosition !== false ) { 1681 - // Extract the text between the hyphen and the first dot 1682 - $file_id_hash = substr( $inbox_file, $hyphenPosition + 1, $dotPosition - $hyphenPosition - 1); 1683 - } else { 1684 - // Ignore the file and move to the next. 1685 - continue; 1686 - } 1687 - 1688 - // If this has the same hash as the item being undone 1689 - if ( $object_id_hash == $file_id_hash ) { 1690 - // Delete the file 1691 - unlink( $inbox_file ); 1732 + // If this has the same hash as the item being undone 1733 + if ($object_id_hash == $file_id_hash) { 1734 + // Delete the file 1735 + unlink($inbox_file); 1692 1736 1693 - // If this was the undoing of a follow request, remove the external user from followers 😢 1694 - if ( "Follow" == $object_type ) { 1695 - $actor = $object["actor"]; 1696 - $follower_filename = urlencode( $actor ); 1697 - unlink( $directories["followers"] . "/{$follower_filename}.json" ); 1698 - } 1699 - // Stop looping 1700 - break; 1737 + // If this was the undoing of a follow request, remove the external user from followers 😢 1738 + if ("Follow" == $object_type) { 1739 + $actor = $object["actor"]; 1740 + $follower_filename = urlencode($actor); 1741 + unlink($directories["followers"] . "/{$follower_filename}.json"); 1701 1742 } 1743 + // Stop looping 1744 + break; 1702 1745 } 1703 1746 } 1747 + } 1704 1748 1705 1749 // "One to stun, two to kill, three to make sure" 1706 1750 die(); 1707 1751 die(); 1708 1752 die(); 1709 -