this repo has no description
0
fork

Configure Feed

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

Initial commit.

alice cc81d778

+1726
+13
.env.example
··· 1 + # Type the @ username that you want. Do not include an '@'. 2 + USERNAME="username-goes-here" 3 + # This is the user's 'real' name. 4 + REALNAME="name-goes-here" 5 + # This is the bio of your user. HTML can be used 6 + SUMMARY ="bio-goes-here" 7 + # Password for sending messages 8 + PASSWORD="password-goes-here" 9 + # Generate locally or from https://cryptotools.net/rsagen 10 + # Newlines must be replaced with '\n' 11 + KEY_PRIVATE="private-key-goes-here" 12 + KEY_PUBLIC="public-key-goes-here" 13 +
+4
.gitignore
··· 1 + data 2 + images 3 + posts 4 + .env
icon.png

This is a binary file and will not be displayed.

+1709
index.php
··· 1 + <?php 2 + /* 3 + * "This code is not a code of honour... no highly esteemed code is commemorated here... nothing valued is here." 4 + * "What is here is dangerous and repulsive to us. This message is a warning about danger." 5 + * This is a rudimentary, single-file, low complexity, minimum functionality, ActivityPub server. 6 + * For educational purposes only. 7 + * The Server produces an Actor who can be followed. 8 + * The Actor can send messages to followers. 9 + * The message can have linkable URls, hashtags, and mentions. 10 + * An image and alt text can be attached to the message. 11 + * The Server saves logs about requests it receives and sends. 12 + * This code is NOT suitable for production use. 13 + * SPDX-License-Identifier: AGPL-3.0-or-later 14 + * This code is also "licenced" under CRAPL v0 - https://matt.might.net/articles/crapl/ 15 + * "Any appearance of design in the Program is purely coincidental and should not in any way be mistaken for evidence of thoughtful software construction." 16 + * For more information, please re-read. 17 + */ 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. 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. 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"] ); 35 + 36 + // Password for sending messages 37 + $password = $env["PASSWORD"]; 38 + 39 + /** No need to edit anything below here. But please go exploring! **/ 40 + 41 + // Internal data 42 + $server = $_SERVER["SERVER_NAME"]; // Do not change this! 43 + 44 + // Some requests require a User-Agent string. 45 + define( "USERAGENT", "activitybot-single-php-file/0.0" ); 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 ); } 61 + } 62 + 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 = "/"; 70 + 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 + } 105 + 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; 111 + 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 + ) 120 + ) 121 + ); 122 + header( "Content-Type: application/json" ); 123 + echo json_encode( $webfinger ); 124 + die(); 125 + } 126 + 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 + 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 + } 141 + } 142 + } 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 + } 182 + 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; 188 + 189 + // Get all the files 190 + $following_files = glob( $directories["following"] . "/*.json" ); 191 + // Number of users 192 + $totalItems = count( $following_files ); 193 + 194 + // Sort users by most recent first 195 + usort( $following_files, function( $a, $b ) { 196 + return filemtime($b) - filemtime($a); 197 + }); 198 + 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 + } 205 + 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(); 216 + } 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 ); 226 + 227 + // Sort users by most recent first 228 + usort( $follower_files, function( $a, $b ) { 229 + return filemtime($b) - filemtime($a); 230 + }); 231 + 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 + } 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(); 249 + } 250 + 251 + // Inbox: 252 + // The `/inbox` is the main server. It receives all requests. 253 + function inbox() { 254 + global $body, $server, $username, $key_private, $directories; 255 + 256 + // Get the message, type, and ID 257 + $inbox_message = $body; 258 + $inbox_type = $inbox_message["type"]; 259 + 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 + } 270 + 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 ); 277 + 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"]; 284 + 285 + // Response Message ID 286 + // This isn't used for anything important so could just be a random number 287 + $guid = uuid(); 288 + 289 + // Create the Accept message to the new follower 290 + $message = [ 291 + "@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 + ]; 303 + 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. 310 + 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 + } 322 + 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 + 334 + // Is this from someone following us? 335 + in_array( $inbox_message["actor"], $followers_ids ) ? $from_follower = true: $from_follower = false; 336 + 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 + } 342 + 343 + // Validate HTTP Message Signature 344 + if ( !verifyHTTPSignature() ) { die(); } 345 + 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 + } 354 + } 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 + 361 + die(); 362 + } 363 + 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 382 + 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 + } 389 + 390 + return sprintf( "%08x", time() ) . "-" . hash( "sha256", $id ); 391 + } 392 + } 393 + 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"; 403 + 404 + // Get the Private Key 405 + $signer = openssl_get_privatekey( $key_private ); 406 + 407 + // Timestamp this message was sent 408 + $date = date( "D, d M Y H:i:s \G\M\T" ); 409 + 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 ); 417 + 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 ); 431 + 432 + // Full signature header 433 + $signature_header = 'keyId="' . $keyId . '",algorithm="rsa-sha256",headers="(request-target) host date digest",signature="' . $signature_b64 . '"'; 434 + 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 ); 458 + 459 + // Full signature header 460 + $signature_header = 'keyId="' . $keyId . '",algorithm="rsa-sha256",headers="(request-target) host date",signature="' . $signature_b64 . '"'; 461 + 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 + } 470 + 471 + return $headers; 472 + } 473 + 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 ); 479 + 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 ); 488 + 489 + // Show the HTML page 490 + echo <<< HTML 491 + <!DOCTYPE html> 492 + <html lang="en-GB"> 493 + <head> 494 + <meta charset="UTF-8"> 495 + <meta name="viewport" content="width=device-width, initial-scale=1.0"> 496 + <meta property="og:url" content="https://{$server}"> 497 + <meta property="og:type" content="website"> 498 + <meta property="og:title" content="{$realName}"> 499 + <meta property="og:description" content="{$summary}"> 500 + <meta property="og:image" content="https://{$server}/banner.png"> 501 + <title>{$h1} {$realName}</title> 502 + <style> 503 + * { max-width: 100%; } 504 + body { margin:0; padding: 0; font-family:sans-serif; } 505 + @media screen and (max-width: 800px) { body { width: 100%; }} 506 + @media screen and (min-width: 799px) { body { width: 800px; margin: 0 auto; }} 507 + address { font-style: normal; } 508 + img { max-width: 50%; } 509 + .h-feed { margin:auto; width: 100%; } 510 + .h-feed > header { text-align: center; margin: 0 auto; } 511 + .h-feed .banner { text-align: center; margin:0 auto; max-width: 650px; } 512 + .h-feed > h1, .h-feed > h2 { margin-top: 10px; margin-bottom: 0; } 513 + .h-feed > header > h1:has(span.p-author), h2:has(a.p-nickname) { word-wrap: break-word; max-width: 90%; padding-left:20px; } 514 + .h-feed .u-feature:first-child { margin-top: 10px; margin-bottom: -150px; max-width: 100%;} 515 + .h-feed .u-photo { max-height: 8vw; max-width:100%; min-height: 120px; } 516 + .h-feed .about { font-size: smaller; background-color: #F5F5F5; padding: 10px; border-top: dotted 1px #808080; border-bottom: dotted 1px #808080; } 517 + .h-feed > ul { padding-left: 0; list-style-type: none; } 518 + .h-feed > ul > li { padding: 10px; border-bottom: dotted 1px #808080; } 519 + .h-entry { padding-right: 10px; } 520 + .h-entry time { font-weight: bold; } 521 + .h-entry .e-content a { word-wrap: break-word; } 522 + </style> 523 + </head> 524 + <body> 525 + <main class="h-feed"> 526 + <header> 527 + <div class="banner"> 528 + <img src="banner.png" alt="" class="u-feature"><br> 529 + <img src="icon.png" alt="icon" class="u-photo"> 530 + </div> 531 + <address> 532 + <h1 class="p-name p-author">{$realName}</h1> 533 + <h2><a class="p-nickname u-url" rel="author" href="https://{$server}/{$username}">@{$rawUsername}@{$server}</a></h2> 534 + </address> 535 + <p class="p-summary">{$summary}</p> 536 + <p>Following: {$totalFollowing} | Followers: {$totalFollowers}</p> 537 + <div class="about"> 538 + <p><a href="https://gitlab.com/edent/activity-bot/">This software is licenced under AGPL 3.0</a>.</p> 539 + <p>This site is a basic <a href="https://www.w3.org/TR/activitypub/">ActivityPub</a> server designed to be <a href="https://shkspr.mobi/blog/2024/02/activitypub-server-in-a-single-file/">a lightweight educational tool</a>.</p> 540 + </div> 541 + </header> 542 + <ul> 543 + HTML; 544 + // Get all the files in the directory 545 + $message_files = array_reverse( glob( "posts" . "/*.json") ); 546 + 547 + // There are lots of messages. The UI will only show 200. 548 + $message_files = array_slice( $message_files, 0, 1000 ); 549 + 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]; 557 + 558 + // Get the contents of the JSON 559 + $message = json_decode( file_get_contents( $message_file ), true ); 560 + 561 + $published = $message["published"]; 562 + 563 + // Place in an array where the key is the timestamp 564 + $messages_ordered[$published] = $message; 565 + } 566 + 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"]; 578 + 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>"; 584 + 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"]; 600 + 601 + // Get the HTML content 602 + $content = $message["content"]; 603 + 604 + // Sanitise the HTML 605 + $content = strip_tags( $content, $allowed_elements ); 606 + 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 + } 614 + 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]; 625 + 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 + } 635 + } 636 + } 637 + } 638 + 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 646 + </ul> 647 + </main> 648 + </body> 649 + </html> 650 + HTML; 651 + die(); 652 + } 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 + 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"]; 671 + 672 + // Is this a reply? 673 + if ( isset( $_POST["inReplyTo"] ) && filter_var( $_POST["inReplyTo"], FILTER_VALIDATE_URL ) ) { 674 + $inReplyTo = $_POST["inReplyTo"]; 675 + } else { 676 + $inReplyTo = null; 677 + } 678 + 679 + // Process the content into HTML to get hashtags etc 680 + list( "HTML" => $content, "TagArray" => $tags ) = process_content( $content ); 681 + 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"]; 689 + 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}"; 694 + 695 + // Move media to the correct location 696 + move_uploaded_file( $image, $image_full_path ); 697 + 698 + // Get the alt text 699 + if ( isset( $_POST["alt"] ) ) { 700 + $alt = $_POST["alt"]; 701 + } else { 702 + $alt = ""; 703 + } 704 + 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 + 716 + // Current time - ISO8601 717 + $timestamp = date( "c" ); 718 + 719 + // Outgoing Message ID 720 + $guid = uuid(); 721 + 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 + ]; 740 + 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 + 757 + 758 + // Save the permalink 759 + $note_json = json_encode( $note ); 760 + file_put_contents( $directories["posts"] . "/{$guid}.json", print_r( $note_json, true ) ); 761 + 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 + } 774 + } 775 + 776 + function follow() { 777 + global $password, $server, $username, $directories; 778 + 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 + } 786 + 787 + // Check password 788 + if($password != $_POST["password"]) { 789 + header("HTTP/1.1 401 Unauthorized"); 790 + echo "Wrong password."; 791 + die(); 792 + } 793 + 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]; 829 + 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 + } 836 + 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 + } 877 + 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 + } 889 + 890 + if(!$actor_url) { 891 + header("HTTP/1.1 404 Not Found"); 892 + echo "Could not find ActivityPub account"; 893 + die(); 894 + } 895 + 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 + } 915 + 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 + } 931 + 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 + ]; 941 + 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 + } 952 + 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 + } 967 + 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 + } 977 + 978 + // Return success 979 + header("Location: https://{$server}/following"); 980 + die(); 981 + } 982 + 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 + } 1022 + 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 + } 1032 + 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 + ]; 1070 + 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(); 1082 + } 1083 + 1084 + 1085 + // POST a signed message to a single inbox 1086 + function sendMessageToSingle( $inbox, $message ) { 1087 + global $directories; 1088 + 1089 + $inbox_host = parse_url( $inbox, PHP_URL_HOST ); 1090 + $inbox_path = parse_url( $inbox, PHP_URL_PATH ); 1091 + 1092 + // Generate the signed headers 1093 + $headers = generate_signed_headers( $message, $inbox_host, $inbox_path, "POST" ); 1094 + 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 ); 1103 + 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; 1110 + } 1111 + curl_close( $ch ); 1112 + return true; 1113 + } 1114 + 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 ); 1126 + 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 + } 1142 + 1143 + // Prepare to use the multiple cURL handle 1144 + // This makes it more efficient to send many simultaneous messages 1145 + $mh = curl_multi_init(); 1146 + 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 ); 1165 + 1166 + // Add the handle to the multi-handle 1167 + curl_multi_add_handle( $mh, $ch ); 1168 + } 1169 + 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 ); 1177 + 1178 + // Close the multi-handle 1179 + curl_multi_close( $mh ); 1180 + 1181 + return true; 1182 + } 1183 + 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 ); 1196 + 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 + } 1204 + 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 + } 1213 + 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 + 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 + } 1229 + 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 + ); 1240 + 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 + } 1245 + 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>"; 1252 + 1253 + return [ 1254 + "HTML" => $content, 1255 + "TagArray" => $tags 1256 + ]; 1257 + } 1258 + 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 ); 1264 + 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"]; 1275 + } else { 1276 + // If not, use the individual inbox 1277 + $inbox = $profileData["inbox"]; 1278 + } 1279 + 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; 1285 + } 1286 + } 1287 + 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(); } 1295 + 1296 + // Split the URL 1297 + $url_host = parse_url( $url, PHP_URL_HOST ); 1298 + $url_path = parse_url( $url, PHP_URL_PATH ); 1299 + 1300 + // Generate signed headers for this request 1301 + $headers = generate_signed_headers( null, $url_host, $url_path, "GET" ); 1302 + 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 ); 1309 + 1310 + // Execute the cURL session 1311 + $urlJSON = curl_exec( $ch ); 1312 + 1313 + $status_code = curl_getinfo( $ch, CURLINFO_RESPONSE_CODE ); 1314 + 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 + } 1323 + 1324 + // Close cURL session 1325 + curl_close( $ch ); 1326 + 1327 + return json_decode( $urlJSON, true ); 1328 + } 1329 + 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; 1334 + 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 + } 1349 + 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 ); 1363 + die(); 1364 + } 1365 + 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; 1371 + 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 ); 1376 + 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 ); 1381 + 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"; 1388 + 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(); 1399 + 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"; 1408 + 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 + } 1418 + 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"; 1429 + 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 + } 1439 + 1440 + // Validate the Digest 1441 + // It is the hash of the raw input string, in binary, encoded as base64. 1442 + $digestString = $headers["digest"]; 1443 + 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]; 1449 + 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 + } 1458 + 1459 + // Manually calculate the digest based on the data sent 1460 + $digestCalculated = base64_encode( hash( $digestAlgorithm, $input, true ) ); 1461 + 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"; 1466 + 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 + } 1475 + 1476 + // Examine the signature 1477 + $signatureHeader = $headers["signature"]; 1478 + 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 + } 1487 + 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 + } 1503 + 1504 + // Remove trailing newline 1505 + $signatureString = trim( $signatureString ); 1506 + 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"]; 1510 + 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"]; 1516 + 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"]; 1521 + 1522 + if ( $publicKey != $actorPublicKey ) { 1523 + // Filename for the log 1524 + $filename = "{$timestamp}.{$type}.Signature.Mismatch_Failure.txt"; 1525 + 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"]; 1541 + 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 + } 1548 + 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 1556 + ); 1557 + 1558 + // Convert to boolean 1559 + if ( $verified === 1 ) { 1560 + $verified = true; 1561 + } elseif ( $verified === 0 ) { 1562 + $verified = false; 1563 + } else { 1564 + $verified = null; 1565 + } 1566 + 1567 + // Filename for the log 1568 + $filename = "{$timestamp}.{$type}.Signature.". json_encode( $verified ) . ".txt"; 1569 + 1570 + // 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" 1580 + ); 1581 + 1582 + return $verified; 1583 + } 1584 + 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; 1590 + 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(); 1603 + } 1604 + 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; 1610 + 1611 + // Get all posts 1612 + $posts = glob( $directories["posts"] . "/*.json") ; 1613 + // Number of posts 1614 + $totalItems = count( $posts ); 1615 + 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 1634 + ), 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(); 1644 + } 1645 + 1646 + // Perform the Undo action requested 1647 + function undo( $message ) { 1648 + global $server, $directories; 1649 + 1650 + // Get some basic data 1651 + $type = $message["type"]; 1652 + $id = $message["id"]; 1653 + // The thing being undone 1654 + $object = $message["object"]; 1655 + 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 + } 1662 + 1663 + if ( isset( $object["type"] ) ) { 1664 + $object_type = $object["type"]; 1665 + } else { 1666 + $object_type = $type; 1667 + } 1668 + 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 ); 1692 + 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; 1701 + } 1702 + } 1703 + } 1704 + 1705 + // "One to stun, two to kill, three to make sure" 1706 + die(); 1707 + die(); 1708 + die(); 1709 +