this repo has no description
0
fork

Configure Feed

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

at main 1752 lines 56 kB view raw
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. 45define("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. 59foreach ($directories as $directory) { 60 if (!is_dir($directory)) { 61 mkdir($data); 62 mkdir($directory); 63 } 64} 65 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 = "/"; 73 74// Routing: 75// The .htaccess changes /whatever to /?path=whatever 76// This runs the function of the path requested. 77switch ($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} 108 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. 112function webfinger() 113{ 114 global $username, $server; 115 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}" 123 ) 124 ) 125 ); 126 header("Content-Type: application/json"); 127 echo json_encode($webfinger); 128 die(); 129} 130 131// User: 132// Requesting `example.com/username` returns a JSON document with the user's information. 133function username() 134{ 135 global $username, $realName, $summary, $server, $key_public; 136 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(); 145 } 146 } 147 } 148 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} 187 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. 191function following() 192{ 193 global $server, $directories; 194 195 // Get all the files 196 $following_files = glob($directories["following"] . "/*.json"); 197 // Number of users 198 $totalItems = count($following_files); 199 200 // Sort users by most recent first 201 usort($following_files, function ($a, $b) { 202 return filemtime($b) - filemtime($a); 203 }); 204 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"]; 210 } 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} 223function followers() 224{ 225 global $server, $directories; 226 // The number of followers is self-reported. 227 // You can set this to any number you like. 228 229 // Get all the files 230 $follower_files = glob($directories["followers"] . "/*.json"); 231 // Number of users 232 $totalItems = count($follower_files); 233 234 // Sort users by most recent first 235 usort($follower_files, function ($a, $b) { 236 return filemtime($b) - filemtime($a); 237 }); 238 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"]; 244 } 245 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} 257 258// Inbox: 259// The `/inbox` is the main server. It receives all requests. 260function inbox() 261{ 262 global $body, $server, $username, $key_private, $directories; 263 264 // Get the message, type, and ID 265 $inbox_message = $body; 266 $inbox_type = $inbox_message["type"]; 267 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 } 278 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"]; 292 293 // Response Message ID 294 // This isn't used for anything important so could just be a random number 295 $guid = uuid(); 296 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" => [ 304 "@context" => "https://www.w3.org/ns/activitystreams", 305 "id" => $follower_id, 306 "type" => $inbox_type, 307 "actor" => $follower_actor, 308 "object" => "https://{$server}/{$username}", 309 ] 310 ]; 311 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. 318 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 } 332 333 // Get a list of every account following us 334 // Get all the files 335 $followers_files = glob($directories["followers"] . "/*.json"); 336 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 } 343 344 // Is this from someone following us? 345 in_array($inbox_message["actor"], $followers_ids) ? $from_follower = true : $from_follower = false; 346 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 } 352 353 // Validate HTTP Message Signature 354 if (!verifyHTTPSignature()) { 355 die(); 356 } 357 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 } 366 } 367 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)); 372 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. 380function 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 396 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"]; 402 } 403 404 return sprintf("%08x", time()) . "-" . hash("sha256", $id); 405 } 406} 407 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. 412function generate_signed_headers($message, $host, $path, $method) 413{ 414 global $server, $username, $key_private; 415 416 // Location of the Public Key 417 $keyId = "https://{$server}/{$username}#main-key"; 418 419 // Get the Private Key 420 $signer = openssl_get_privatekey($key_private); 421 422 // Timestamp this message was sent 423 $date = date("D, d M Y H:i:s \G\M\T"); 424 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); 432 433 // Sign the path, host, date, and digest 434 $stringToSign = "(request-target): post $path\nhost: $host\ndate: $date\ndigest: SHA-256=$digest"; 435 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); 446 447 // Full signature header 448 $signature_header = 'keyId="' . $keyId . '",algorithm="rsa-sha256",headers="(request-target) host date digest",signature="' . $signature_b64 . '"'; 449 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 . '"'; 476 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 ); 484 } 485 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. 491function view($style) 492{ 493 global $username, $server, $realName, $summary, $directories; 494 $rawUsername = rawurldecode($username); 495 496 $h1 = "HomePage"; 497 $directory = "posts"; 498 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); 504 505 // Show the HTML page 506 echo <<< HTML 507<!DOCTYPE html> 508<html lang="en-GB"> 509 <head> 510 <meta charset="UTF-8"> 511 <meta name="viewport" content="width=device-width, initial-scale=1.0"> 512 <meta property="og:url" content="https://{$server}"> 513 <meta property="og:type" content="website"> 514 <meta property="og:title" content="{$realName}"> 515 <meta property="og:description" content="{$summary}"> 516 <meta property="og:image" content="https://{$server}/banner.png"> 517 <title>{$h1} {$realName}</title> 518 <style> 519 * { max-width: 100%; } 520 body { margin:0; padding: 0; font-family:sans-serif; } 521 @media screen and (max-width: 800px) { body { width: 100%; }} 522 @media screen and (min-width: 799px) { body { width: 800px; margin: 0 auto; }} 523 address { font-style: normal; } 524 img { max-width: 50%; } 525 .h-feed { margin:auto; width: 100%; } 526 .h-feed > header { text-align: center; margin: 0 auto; } 527 .h-feed .banner { text-align: center; margin:0 auto; max-width: 650px; } 528 .h-feed > h1, .h-feed > h2 { margin-top: 10px; margin-bottom: 0; } 529 .h-feed > header > h1:has(span.p-author), h2:has(a.p-nickname) { word-wrap: break-word; max-width: 90%; padding-left:20px; } 530 .h-feed .u-feature:first-child { margin-top: 10px; margin-bottom: -150px; max-width: 100%;} 531 .h-feed .u-photo { max-height: 8vw; max-width:100%; min-height: 120px; } 532 .h-feed .about { font-size: smaller; background-color: #F5F5F5; padding: 10px; border-top: dotted 1px #808080; border-bottom: dotted 1px #808080; } 533 .h-feed > ul { padding-left: 0; list-style-type: none; } 534 .h-feed > ul > li { padding: 10px; border-bottom: dotted 1px #808080; } 535 .h-entry { padding-right: 10px; } 536 .h-entry time { font-weight: bold; } 537 .h-entry .e-content a { word-wrap: break-word; } 538 </style> 539 </head> 540 <body> 541 <main class="h-feed"> 542 <header> 543 <div class="banner"> 544 <img src="banner.png" alt="" class="u-feature"><br> 545 <img src="icon.png" alt="icon" class="u-photo"> 546 </div> 547 <address> 548 <h1 class="p-name p-author">{$realName}</h1> 549 <h2><a class="p-nickname u-url" rel="author" href="https://{$server}/{$username}">@{$rawUsername}@{$server}</a></h2> 550 </address> 551 <p class="p-summary">{$summary}</p> 552 <p>Following: {$totalFollowing} | Followers: {$totalFollowers}</p> 553 <div class="about"> 554 <p><a href="https://gitlab.com/edent/activity-bot/">This software is licenced under AGPL 3.0</a>.</p> 555 <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> 556 </div> 557 </header> 558 <ul> 559HTML; 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); 576 577 $published = $message["published"]; 578 579 // Place in an array where the key is the timestamp 580 $messages_ordered[$published] = $message; 581 } 582 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; 590 591 // Get basic details 592 $id = $object["id"]; 593 $published = $object["published"]; 594 595 // HTML for who wrote this 596 $publishedHTML = "<a href=\"{$id}\">{$published}</a>"; 597 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}"; 609 610 // Make i18n usernames readable and safe. 611 $actorName = htmlspecialchars(rawurldecode($actorName)); 612 $actorHTML = "<a href=\"$actor\">@{$actorName}</a>"; 613 614 // What type of message is this? 615 $type = $message["type"]; 616 617 // Get the HTML content 618 $content = $message["content"]; 619 620 // Sanitise the HTML 621 $content = strip_tags($content, $allowed_elements); 622 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 } 630 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]; 641 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>"; 650 } 651 } 652 } 653 } 654 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 662 </ul> 663 </main> 664 </body> 665</html> 666HTML; 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. 675function 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; 694 } 695 696 // Process the content into HTML to get hashtags etc 697 list("HTML" => $content, "TagArray" => $tags) = process_content($content); 698 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"]; 718 } else { 719 $alt = ""; 720 } 721 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 } 732 733 // Current time - ISO8601 734 $timestamp = date("c"); 735 736 // Outgoing Message ID 737 $guid = uuid(); 738 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 ]; 757 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 ]; 773 774 775 // Save the permalink 776 $note_json = json_encode($note); 777 file_put_contents($directories["posts"] . "/{$guid}.json", print_r($note_json, true)); 778 779 // Send to all the user's followers 780 $messageSent = sendMessageToFollowers($message); 781 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} 792 793function follow() 794{ 795 global $password, $server, $username, $directories; 796 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 } 804 805 // Check password 806 if ($password != $_POST["password"]) { 807 header("HTTP/1.1 401 Unauthorized"); 808 echo "Wrong password."; 809 die(); 810 } 811 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 } 818 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 } 825 826 $account = trim(filter_var($_POST["account"], FILTER_SANITIZE_STRING)); 827 828 // If it starts with @, remove it 829 if (str_starts_with($account, '@')) { 830 $account = substr($account, 1); 831 } 832 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 } 846 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 } 897 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 } 917 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 } 927 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 } 939 940 // Check follow state 941 $following_file = "{$directories['following']}/" . urlencode($actor_url) . ".json"; 942 $pending_file = "{$directories['following']}/.pending/" . urlencode($actor_url) . ".json"; 943 944 if (file_exists($following_file)) { 945 header("HTTP/1.1 409 Conflict"); 946 echo "Already following this account"; 947 die(); 948 } 949 950 if (file_exists($pending_file)) { 951 header("HTTP/1.1 409 Conflict"); 952 echo "Follow request already pending"; 953 die(); 954 } 955 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 ]; 965 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 } 976 977 // Save pending follow request first 978 $pending_data = [ 979 'guid' => $guid, 980 'timestamp' => time(), 981 'actor_data' => $actor_data, 982 'message' => $message 983 ]; 984 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 } 991 992 // Send follow request 993 $success = sendMessageToSingle($actor_data['inbox'], $message); 994 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 } 1001 1002 // Return success 1003 header("Location: https://{$server}/following"); 1004 die(); 1005} 1006 1007// Handle when remote server accepts/rejects the follow 1008function processFollowResponse($activity) 1009{ 1010 global $directories; 1011 1012 if (!isset($activity['object']['id'])) { 1013 return false; 1014 } 1015 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"; 1021 1022 // Verify pending follow exists 1023 if (!file_exists($pending_file)) { 1024 return false; 1025 } 1026 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 } 1031 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; 1038 } 1039 } else if ($activity['type'] === 'Reject') { 1040 // Just remove pending 1041 unlink($pending_file); 1042 return true; 1043 } 1044 1045 return false; 1046} 1047 1048function unfollow() 1049{ 1050 global $password, $server, $username, $directories; 1051 1052 // Check password 1053 if ($password != $_POST["password"]) { 1054 header("HTTP/1.1 401 Unauthorized"); 1055 echo "Wrong password."; 1056 die(); 1057 } 1058 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 } 1065 1066 $actor_url = $_POST["account"]; 1067 $following_file = "{$directories['following']}/" . urlencode($actor_url) . ".json"; 1068 1069 if (!file_exists($following_file)) { 1070 header("HTTP/1.1 404 Not Found"); 1071 echo "Not following this account"; 1072 die(); 1073 } 1074 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(); 1081 } 1082 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 ]; 1096 1097 // Send unfollow request 1098 $success = sendMessageToSingle($actor_data['inbox'], $message); 1099 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} 1109 1110 1111// POST a signed message to a single inbox 1112function sendMessageToSingle($inbox, $message) 1113{ 1114 global $directories; 1115 1116 $inbox_host = parse_url($inbox, PHP_URL_HOST); 1117 $inbox_path = parse_url($inbox, PHP_URL_PATH); 1118 1119 // Generate the signed headers 1120 $headers = generate_signed_headers($message, $inbox_host, $inbox_path, "POST"); 1121 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); 1130 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; 1137 } 1138 curl_close($ch); 1139 return true; 1140} 1141 1142// POST a signed message to the inboxes of all followers 1143function sendMessageToFollowers($message) 1144{ 1145 global $directories; 1146 // Read existing followers 1147 $followers = glob($directories["followers"] . "/*.json"); 1148 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 } 1162 } else { 1163 // If not, use the individual inbox 1164 $inbox = $follower_info["inbox"]; 1165 if (!in_array($inbox, $inboxes)) { 1166 $inboxes[] = $inbox; 1167 } 1168 } 1169 } 1170 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); 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 1214function 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; 1232 } 1233 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 ); 1250 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 ); 1269 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 } 1274 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); 1278 1279 // Construct the content 1280 $content = "<p>{$content}</p>"; 1281 1282 return [ 1283 "HTML" => $content, 1284 "TagArray" => $tags 1285 ]; 1286} 1287 1288// When given the URl of a post, this looks up the post, finds the user, then returns their inbox or shared inbox 1289function getInboxFromMessageURl($url) 1290{ 1291 1292 // Get details about the message 1293 $messageData = getDataFromURl($url); 1294 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; 1300 } 1301 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 } 1309 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} 1317 1318// GET a request to a URl and returns structured data 1319function 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 ) { 1326 die(); 1327 } 1328 1329 // Split the URL 1330 $url_host = parse_url($url, PHP_URL_HOST); 1331 $url_path = parse_url($url, PHP_URL_PATH); 1332 1333 // Generate signed headers for this request 1334 $headers = generate_signed_headers(null, $url_host, $url_path, "GET"); 1335 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); 1342 1343 // Execute the cURL session 1344 $urlJSON = curl_exec($ch); 1345 1346 $status_code = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); 1347 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 } 1356 1357 // Close cURL session 1358 curl_close($ch); 1359 1360 return json_decode($urlJSON, true); 1361} 1362 1363// The Outbox contains a date-ordered list (newest first) of all the user's posts 1364// This is optional. 1365function outbox() 1366{ 1367 global $server, $username, $directories; 1368 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 } 1383 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 ); 1393 1394 // Render the page 1395 header("Content-Type: application/activity+json"); 1396 echo json_encode($outbox); 1397 die(); 1398} 1399 1400// Verify the signature sent with the message. 1401// This is optional 1402// It is very confusing 1403function verifyHTTPSignature() 1404{ 1405 global $input, $body, $server, $directories; 1406 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); 1411 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); 1416 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"; 1423 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(); 1435 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"; 1444 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 } 1455 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"; 1466 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 } 1477 1478 // Validate the Digest 1479 // It is the hash of the raw input string, in binary, encoded as base64. 1480 $digestString = $headers["digest"]; 1481 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]; 1487 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 } 1496 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"; 1504 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" 1511 ); 1512 return false; 1513 } 1514 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"; 1538 } else { 1539 $signatureString .= "{$signatureHeader}: " . $headers[$signatureHeader] . "\n"; 1540 } 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) { 1562 // Filename for the log 1563 $filename = "{$timestamp}.{$type}.Signature.Mismatch_Failure.txt"; 1564 1565 // Save headers and request data to the timestamped file in the logs directory 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" 1574 ); 1575 return false; 1576 } 1577 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"; 1587 } 1588 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 ); 1597 1598 // Convert to boolean 1599 if ($verified === 1) { 1600 $verified = true; 1601 } elseif ($verified === 0) { 1602 $verified = false; 1603 } else { 1604 $verified = null; 1605 } 1606 1607 // Filename for the log 1608 $filename = "{$timestamp}.{$type}.Signature." . json_encode($verified) . ".txt"; 1609 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 ); 1622 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/ 1629function 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 1650function 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 1675 ), 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 1690function 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; 1705 } 1706 1707 if (isset($object["type"])) { 1708 $object_type = $object["type"]; 1709 } else { 1710 $object_type = $type; 1711 } 1712 1713 // Inbox items are stored as the hash of the original ID 1714 $object_id_hash = hash("sha256", $object_id); 1715 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, '.'); 1723 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); 1727 } else { 1728 // Ignore the file and move to the next. 1729 continue; 1730 } 1731 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); 1736 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"); 1742 } 1743 // Stop looping 1744 break; 1745 } 1746 } 1747} 1748 1749// "One to stun, two to kill, three to make sure" 1750die(); 1751die(); 1752die();