this repo has no description
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();