Skip to content
Snippets Groups Projects
index.php 62.6 KiB
Newer Older
Terence Eden's avatar
Terence Eden committed
<?php

	/*
	*	"This code is not a code of honour... no highly esteemed code is commemorated here... nothing valued is here."
	*	"What is here is dangerous and repulsive to us. This message is a warning about danger."
	*	This is a rudimentary, single-file, low complexity, minimum functionality, ActivityPub server.
	*	For educational purposes only.
Terence Eden's avatar
Terence Eden committed
	*	The Server produces an Actor who can be followed.
	*	The Actor can send messages to followers.
Terence Eden's avatar
Terence Eden committed
	*	The message can have linkable URls, hashtags, and mentions.
	*	An image and alt text can be attached to the message.
Terence Eden's avatar
Terence Eden committed
	*	The Server saves logs about requests it receives and sends.
	*	This code is NOT suitable for production use.
	*	SPDX-License-Identifier: AGPL-3.0-or-later
	*	This code is also "licenced" under CRAPL v0 - https://matt.might.net/articles/crapl/
Terence Eden's avatar
Terence Eden committed
	*	"Any appearance of design in the Program is purely coincidental and should not in any way be mistaken for evidence of thoughtful software construction."
Terence Eden's avatar
Terence Eden committed
	*	For more information, please re-read.
Terence Eden's avatar
Terence Eden committed
	*/

Terence Eden's avatar
Terence Eden committed
	//	Preamble: Set your details here
Terence Eden's avatar
Terence Eden committed
	//	This is where you set up your account's name and bio.
	//	You also need to provide a public/private keypair.
Terence Eden's avatar
Terence Eden committed
	//	The posting endpoint is protected with a password that also needs to be set here.
Terence Eden's avatar
Terence Eden committed

	//	Set up the Actor's information here, or in the .env file
	$env = parse_ini_file('.env');
	$username = rawurlencode( $env["USERNAME"] );	//	Type the @ username that you want. Do not include an "@". 
	$realName = $env["REALNAME"];	//	The bot's "real" name.
	$summary  = $env["SUMMARY"];	//	The bio of your bot.
	$birthday = $env["BIRTHDAY"];   //	The "joined" date of your bot.
	                                //	Must be in RFC3339 format. 2024-02-29T12:34:56Z
	$language = $env["LANGUAGE"];	//	This is the primary human language used by your bot.
Terence Eden's avatar
Terence Eden committed
	                                //	Must be a 2 letter ISO 639-1 code
	$oldName  = $env["OLD_NAME"];	//	To transfer followers from an old account, enter the full account URl.
	                                //	For example 'https://example.social/users/username'  
Terence Eden's avatar
Terence Eden committed
	$atproto  = $env["DID"];        //	To set a BSky handle. For example 'did:plc:abc123'
	                                //	See https://atproto.com/specs/handle#handle-resolution  
Terence Eden's avatar
Terence Eden committed

	//	Generate locally or from https://cryptotools.net/rsagen
	//	Newlines must be replaced with "\n"
	$key_private = str_replace('\n', "\n", $env["KEY_PRIVATE"] );
	$key_public  = str_replace('\n', "\n", $env["KEY_PUBLIC"]  );
Terence Eden's avatar
Terence Eden committed

	//	Password for sending messages
	$password = $env["PASSWORD"];
Terence Eden's avatar
Terence Eden committed

Terence Eden's avatar
Terence Eden committed
	/** No need to edit anything below here. But please go exploring! **/
	//	Uncomment these headers if you want to run conformance tests using
	//	https://socialweb.coop/activitypub/actor/tester/
	// header('Access-Control-Allow-Origin: *');
	// header("Access-Control-Allow-Headers: accept, content-type");
	// header("Vary: Origin");

	//	Internal data
	$server   = $_SERVER["SERVER_NAME"];	//	Do not change this!

	//	Some requests require a User-Agent string.
Terence Eden's avatar
Terence Eden committed
	define( "USERAGENT", "activitybot-single-php-file/0.0" );
Terence Eden's avatar
Terence Eden committed
	//	Set up where to save logs, posts, and images.
	//	You can change these directories to something more suitable if you like.
Terence Eden's avatar
Terence Eden committed
	$data = "data";
	$directories = array(
Terence Eden's avatar
Terence Eden committed
		"inbox"      => "{$data}/inbox",
		"followers"  => "{$data}/followers",
		"following"  => "{$data}/following",
		"logs"       => "{$data}/logs",
		"posts"      => "posts",
		"images"     => "images",
Terence Eden's avatar
Terence Eden committed
	);
	//	Create the directories if they don't already exist.
Terence Eden's avatar
Terence Eden committed
	foreach ( $directories as $directory ) {
		if( !is_dir( $directory ) ) { mkdir( $data ); mkdir( $directory ); }
	}

Terence Eden's avatar
Terence Eden committed
	// Get the information sent to this server
	$input       = file_get_contents( "php://input" );
	$body        = json_decode( $input, true );
	$bodyData    = print_r( $body,      true );
Terence Eden's avatar
Terence Eden committed
	
	//	If the root has been requested, manually set the path to `/`
	!empty( $_GET["path"] ) ? $path = $_GET["path"] : $path = "/";
Terence Eden's avatar
Terence Eden committed

Terence Eden's avatar
Terence Eden committed
	//	Routing:
Terence Eden's avatar
Terence Eden committed
	//	The .htaccess changes /whatever to /?path=whatever
Terence Eden's avatar
Terence Eden committed
	//	This runs the function of the path requested.
Terence Eden's avatar
Terence Eden committed
	switch ( $path ) {
Terence Eden's avatar
Terence Eden committed
		case ".well-known/webfinger":
Terence Eden's avatar
Terence Eden committed
			webfinger();   //	Mandatory. Static.
Terence Eden's avatar
Terence Eden committed
		case ".well-known/nodeinfo":
			wk_nodeinfo(); //	Optional. Static.	
		case ".well-known/host-meta":
			meta(); //	Optional? Static.
Terence Eden's avatar
Terence Eden committed
		case ".well-known/atproto-did":
			atproto(); //	Optional https://atproto.com/specs/handle#handle-resolution.
Terence Eden's avatar
Terence Eden committed
		case "nodeinfo/2.1":
			nodeinfo();    //	Optional. Static.
Terence Eden's avatar
Terence Eden committed
		case rawurldecode( $username ):
Terence Eden's avatar
Terence Eden committed
		case "@" . rawurldecode( $username ):	//	Some software assumes usernames start with an `@`
Terence Eden's avatar
Terence Eden committed
			username();    //	Mandatory. Static
Terence Eden's avatar
Terence Eden committed
		case "following":
Terence Eden's avatar
Terence Eden committed
			following();          //	Mandatory. Can be static or dynamic.
Terence Eden's avatar
Terence Eden committed
		case "followers":
Terence Eden's avatar
Terence Eden committed
			followers( $path );   //	Mandatory. Can be static or dynamic.
		case "followers_synchronization" :	//	Optional. Used for showing followers
			followers_synchronization();
Terence Eden's avatar
Terence Eden committed
		case "inbox":
Terence Eden's avatar
Terence Eden committed
			inbox();       //	Mandatory.
Terence Eden's avatar
Terence Eden committed
		case "outbox":    
			outbox();      //	Optional. Dynamic.
			send();        //	API for posting content to the Fediverse.
Terence Eden's avatar
Terence Eden committed
		case "action/users":      
			action_users();      //	API for interacting with Fediverse users.
Terence Eden's avatar
Terence Eden committed
		case "/":         
Terence Eden's avatar
Terence Eden committed
			view( "home" );// User interface for seeing what the user has posted.
Terence Eden's avatar
Terence Eden committed
		default:
Terence Eden's avatar
Terence Eden committed
			header("HTTP/1.1 404 Not Found");
Terence Eden's avatar
Terence Eden committed
			die();
	}

Terence Eden's avatar
Terence Eden committed
	//	The WebFinger Protocol is used to identify accounts.
Terence Eden's avatar
Terence Eden committed
	//	It is requested with `example.com/.well-known/webfinger?resource=acct:username@example.com`
	//	This server only has one user, so it ignores the query string and always returns the same details.
Terence Eden's avatar
Terence Eden committed
	function webfinger() {
		global $username, $server;

		$webfinger = array(
			"subject" => "acct:{$username}@{$server}",
 			  "links" => array(
				array(
					 "rel" => "self",
					"type" => "application/activity+json",
					"href" => "https://{$server}/{$username}"
				)
			)
		);
Terence Eden's avatar
Terence Eden committed
		header( "Content-Type: application/json" );
Terence Eden's avatar
Terence Eden committed
		echo json_encode( $webfinger );
		die();
	}

Terence Eden's avatar
Terence Eden committed
	//	User:
	//	Requesting `example.com/username` returns a JSON document with the user's information.
Terence Eden's avatar
Terence Eden committed
	function username() {
		global $username, $realName, $summary, $birthday, $oldName, $server, $key_public;
Terence Eden's avatar
Terence Eden committed

Terence Eden's avatar
Terence Eden committed
		//	Was HTML requested?
		//	If so, probably a browser. Redirect to homepage.
		foreach (getallheaders() as $name => $value) {
			if ("Accept" ==	$name) {
Terence Eden's avatar
Terence Eden committed
				$accepts = explode( ",", $value );
				if ( "text/html" == $accepts[0] ) {
					header( "Location: https://{$server}/" );
					die();	
				}

		if ( "" != $oldName ) {
			$aka = [ "{$oldName}" ];
		} else {
			$aka = [];
		}
Terence Eden's avatar
Terence Eden committed
		$user = array(
			"@context" => [
				"https://www.w3.org/ns/activitystreams",
				"https://w3id.org/security/v1"
			],
			                       "id" => "https://{$server}/{$username}",
			                     "type" => "Application",
Terence Eden's avatar
Terence Eden committed
			                "following" => "https://{$server}/following",
			                "followers" => "https://{$server}/followers",
			                    "inbox" => "https://{$server}/inbox",
Terence Eden's avatar
Terence Eden committed
			                   "outbox" => "https://{$server}/outbox",
Terence Eden's avatar
Terence Eden committed
			        "preferredUsername" =>  rawurldecode( $username ),
Terence Eden's avatar
Terence Eden committed
			                     "name" => "{$realName}",
Terence Eden's avatar
Terence Eden committed
			                  "summary" => "{$summary}",
			                      "url" => "https://{$server}/{$username}",
			"manuallyApprovesFollowers" =>  false,
Terence Eden's avatar
Terence Eden committed
			             "discoverable" =>  true,
						    "indexable" =>  true,
			                "published" => "{$birthday}",
			              "alsoKnownAs" => $aka,
Terence Eden's avatar
Terence Eden committed
			"icon" => [
				     "type" => "Image",
				"mediaType" => "image/png",
				      "url" => "https://{$server}/icon.png"
			],
			"image" => [
				     "type" => "Image",
				"mediaType" => "image/png",
				      "url" => "https://{$server}/banner.png"
			],
Terence Eden's avatar
Terence Eden committed
			"publicKey" => [
				"id"           => "https://{$server}/{$username}#main-key",
				"owner"        => "https://{$server}/{$username}",
				"publicKeyPem" => $key_public
			]
		);
Terence Eden's avatar
Terence Eden committed
		header( "Content-Type: application/activity+json" );
Terence Eden's avatar
Terence Eden committed
		echo json_encode( $user );
		die();
	}

Terence Eden's avatar
Terence Eden committed
	//	Follower / Following:
	// These JSON documents show how many users are following / followers-of this account.
	// The information here is self-attested. So you can lie and use any number you want.
Terence Eden's avatar
Terence Eden committed
	function following() {
Terence Eden's avatar
Terence Eden committed
		global $server, $directories;

		//	Get all the files 
Terence Eden's avatar
Terence Eden committed
		$following_files = glob( $directories["following"] . "/*.json" );
Terence Eden's avatar
Terence Eden committed
		//	Number of users
Terence Eden's avatar
Terence Eden committed
		$totalItems = count( $following_files );

		//	Sort users by most recent first
		usort( $following_files, function( $a, $b ) {
			return filemtime($b) - filemtime($a);
		});

		//	Create a list of all accounts being followed
Terence Eden's avatar
Terence Eden committed
		$items = array();
		foreach ( $following_files as $following_file ) {
			$following = json_decode( file_get_contents( $following_file ), true );
Terence Eden's avatar
Terence Eden committed
			$items[] = $following["id"];
		}
Terence Eden's avatar
Terence Eden committed

		$following = array(
			  "@context" => "https://www.w3.org/ns/activitystreams",
			        "id" => "https://{$server}/following",
Terence Eden's avatar
Terence Eden committed
			      "type" => "OrderedCollection",
Terence Eden's avatar
Terence Eden committed
			"totalItems" => $totalItems,
			     "items" => $items
Terence Eden's avatar
Terence Eden committed
		);
Terence Eden's avatar
Terence Eden committed
		header( "Content-Type: application/activity+json" );
Terence Eden's avatar
Terence Eden committed
		echo json_encode( $following );
		die();
	}
	function followers() {
		global $server, $directories, $username;
		//	The number of followers is self-reported.
		//	You can set this to any number you like.
Terence Eden's avatar
Terence Eden committed
		
		//	Get all the files 
Terence Eden's avatar
Terence Eden committed
		$follower_files = glob( $directories["followers"] . "/*.json" );
Terence Eden's avatar
Terence Eden committed
		//	Number of users
Terence Eden's avatar
Terence Eden committed
		$totalItems = count( $follower_files );

		//	Sort users by most recent first
		usort( $follower_files, function( $a, $b ) {
			return filemtime($b) - filemtime($a);
		});

Terence Eden's avatar
Terence Eden committed
		//	Create a list of everyone being followed
Terence Eden's avatar
Terence Eden committed
		$items = array();
		foreach ( $follower_files as $follower_file ) {
Terence Eden's avatar
Terence Eden committed
			$following = json_decode( file_get_contents( $follower_file ), true );
Terence Eden's avatar
Terence Eden committed
			$items[] = $following["id"];
		}

Terence Eden's avatar
Terence Eden committed
		//	Which page, if any, has been requested
		if ( !isset($_GET["page"]) ) {
			// //	No page, so show the link to the pagination
			// $followers = array(
			// 	"@context" => ["https://www.w3.org/ns/activitystreams"],
			// 	      "id" => "https://{$server}/followers",
			// 	   "actor" => "https://{$server}/{$username}",
			// 	    "type" => "OrderedCollection",
			//   "totalItems" => $totalItems,
			// 	   "first" => "https://{$server}/followers?page=1"
			// );
			// header( "Content-Type: application/activity+json" );
			// echo json_encode( $followers );
			// die();
			$page = 1;
			$id = "https://{$server}/followers";
Terence Eden's avatar
Terence Eden committed
		} else {
			$page = (int)$_GET["page"];
			$id = "https://{$server}/followers?page={$page}";
Terence Eden's avatar
Terence Eden committed
		}

		//	Pagination 
Terence Eden's avatar
Terence Eden committed
		$batches = array_chunk( $items, $batchSize );
		$followers_batch = $batches[$page - 1];

Terence Eden's avatar
Terence Eden committed
		$followers = array(
			"@context" => ["https://www.w3.org/ns/activitystreams"],
			      "id" => $id,
			   "actor" => "https://{$server}/{$username}",
Terence Eden's avatar
Terence Eden committed
			    "type" => "OrderedCollectionPage",
		  "totalItems" => $totalItems,
		      "partOf" => "https://{$server}/followers",
		"orderedItems" => $followers_batch,
		       "first" => "https://{$server}/followers?page=1"
Terence Eden's avatar
Terence Eden committed
		);
Terence Eden's avatar
Terence Eden committed

		//	Next and Prev pagination links if necessary
		if ( $page < sizeof( $batches ) ) {
			$followers["next"] =  "https://{$server}/followers?page=" . $page + 1;
		}

		if ( $page > 1 ) {
			$followers["prev"] =  "https://{$server}/followers?page=" . $page - 1;
		}

Terence Eden's avatar
Terence Eden committed
		header( "Content-Type: application/activity+json" );
Terence Eden's avatar
Terence Eden committed
		echo json_encode( $followers );
		die();
	}

	function followers_synchronization() {
		//	This is a Follower Synchronisation request
		//	https://docs.joinmastodon.org/spec/activitypub/#follower-synchronization-mechanism
		global $directories;

		//	Host that made the request can be found in the signature
		//	Get the headers send with the request
		$headers = getallheaders();
		//	Ensure the header keys match the format expected by the signature 
		$headers = array_change_key_case( $headers, CASE_LOWER );
		//	Examine the signature
		$signatureHeader = $headers["signature"];
		// Extract key information from the Signature header
		$signatureParts = [];
		//	Converts 'a=b,c=d e f' into ["a"=>"b", "c"=>"d e f"]
		               // word="text"
		preg_match_all('/(\w+)="([^"]+)"/', $signatureHeader, $matches);
		foreach ( $matches[1] as $index => $key ) {
			$signatureParts[$key] = $matches[2][$index];
		}

		//	Get the Public Key
		//	The link to the key might be sent with the body, but is always sent in the Signature header.
		//	This is usually in the form `https://example.com/user/username#main-key`
		$publicKeyURL = $signatureParts["keyId"];

		//	Finally, get the host
		$host = parse_url( $publicKeyURL, PHP_URL_HOST );

		//	Is the domain host name valid?
		$host = filter_var( $host, FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME );
		if ( $host == false )	{
			//	Something dodgy is going on!
			header( "HTTP/1.1 401 Unauthorized" );
			echo "Bad domain value";
			die();
		}
		
		//	Get all the follower files
		$follower_files = glob( $directories["followers"] . "/*.json" );
		
		//	Return only the users from this domain
		$items_filtered = [];

		foreach ( $follower_files as $follower_file ) {
			$following = json_decode( file_get_contents( $follower_file ), true );
			$following_id = $following["id"];
			$following_host = parse_url( $following_id )["host"];
			if ( $host === $following_host ) {
				$items_filtered[] = $following_id;
			}
		}

		$followers = array(
			"@context" => "https://www.w3.org/ns/activitystreams",
					"id" => "https://{$server}/followers?domain={$host}",
				"type" => "OrderedCollection",
		"orderedItems" => $items_filtered

		);
		header( "Content-Type: application/activity+json" );
		echo json_encode( $followers );
		die();
	}

Terence Eden's avatar
Terence Eden committed
	//	Inbox:
	//	The `/inbox` is the main server. It receives all requests. 
Terence Eden's avatar
Terence Eden committed
	function inbox() {
Terence Eden's avatar
Terence Eden committed
		global $body, $server, $username, $key_private, $directories;
Terence Eden's avatar
Terence Eden committed

		//	Get the message, type, and ID
Terence Eden's avatar
Terence Eden committed
		$inbox_message = $body;
		$inbox_type = $inbox_message["type"];

Terence Eden's avatar
Terence Eden committed
		//	This inbox only sends responses to follow / unfollow requests.
		//	A remote server sends the inbox a follow request which is a JSON file saying who they are.
		//	The details of the remote user's server is saved to a file so that future messages can be delivered to the follower.
		//	An accept request is cryptographically signed and POST'd back to the remote server.
		if ( "Follow" == $inbox_type ) { 
			//	Validate HTTP Message Signature
Terence Eden's avatar
Terence Eden committed
			if ( !verifyHTTPSignature() ) { 
				header("HTTP/1.1 401 Unauthorized");
				die(); 
			}

			//	Get the parameters
			$follower_id    = $inbox_message["id"];    //	E.g. https://mastodon.social/(unique id)
			$follower_actor = $inbox_message["actor"]; //	E.g. https://mastodon.social/users/Edent
			
			//	Get the actor's profile as JSON
			$follower_actor_details = getDataFromURl( $follower_actor );

			//	Save the actor's data in `/data/followers/`
			$follower_filename = urlencode( $follower_actor );
			file_put_contents( $directories["followers"] . "/{$follower_filename}.json", json_encode( $follower_actor_details ) );
			
			//	Get the new follower's Inbox
			$follower_inbox = $follower_actor_details["inbox"];

			//	Response Message ID
			//	This isn't used for anything important so could just be a random number
			$guid = uuid();

			//	Create the Accept message to the new follower
			$message = [
				"@context" => "https://www.w3.org/ns/activitystreams",
				"id"       => "https://{$server}/{$guid}",
				"type"     => "Accept",
				"actor"    => "https://{$server}/{$username}",
				"object"   => [
					"@context" => "https://www.w3.org/ns/activitystreams",
					"id"       =>  $follower_id,
					"type"     =>  $inbox_type,
					"actor"    =>  $follower_actor,
					"object"   => "https://{$server}/{$username}",
				]
			];

			//	The Accept is POSTed to the inbox on the server of the user who requested the follow
			sendMessageToSingle( $follower_inbox, $message );
		} else {
			//	Messages to ignore.
			//	Some servers are very chatty. They send lots of irrelevant messages.
			//	Before even bothering to validate them, we can delete them.

			//	This server doesn't handle Add, Remove, Reject, Favourite, Replies, Repost
			//	See https://www.w3.org/wiki/ActivityPub/Primer
			if ( "Add"      == $inbox_type || 
			     "Remove"   == $inbox_type || 
Terence Eden's avatar
Terence Eden committed
			     "Reject"   == $inbox_type || 
			     "Like"     == $inbox_type || 
			     "Create"   == $inbox_type ||
			     "Announce" == $inbox_type ) { 
				//	TODO: Better HTTP header
			//	Get a list of every account following us
			//	Get all the files 
Terence Eden's avatar
Terence Eden committed
			$followers_files = glob( $directories["followers"] . "/*.json" );
			
			//	Create a list of all accounts being followed
			$followers_ids = array();
			foreach ( $followers_files as $follower_file ) {
				$follower = json_decode( file_get_contents( $follower_file ), true );
				$followers_ids[] = $follower["id"];
			}

			//	Is this from someone following us?
			$from_follower = in_array( $inbox_message["actor"], $followers_ids );
			//	As long as one of these is true, the server will process it
			if ( !$from_follower ) {
				//	Don't bother processing it at all.
				die();
			}

			//	Validate HTTP Message Signature
			if ( !verifyHTTPSignature() ) { die(); }

			//	If this is an Undo (Unfollow) try to process it
			if ( "Undo" == $inbox_type ) { 
				undo( $inbox_message ); 
			} else {
				die();
Terence Eden's avatar
Terence Eden committed
		//	If the message is valid, save the message in `/data/inbox/`
		$uuid = uuid( $inbox_message );
Terence Eden's avatar
Terence Eden committed
		$inbox_filename = $uuid . "." . urlencode( $inbox_type ) . ".json";
		file_put_contents( $directories["inbox"] . "/{$inbox_filename}", json_encode( $inbox_message ) );
Terence Eden's avatar
Terence Eden committed

Terence Eden's avatar
Terence Eden committed
		die();
	}

Terence Eden's avatar
Terence Eden committed
	//	Unique ID:
Terence Eden's avatar
Terence Eden committed
	//	Every message sent should have a unique ID. 
	//	This can be anything you like. Some servers use a random number.
	//	I prefer a date-sortable string.
	function uuid( $message = null) {
		//	UUIDs that this server *sends* will be [timestamp]-[random]
		//	65e99ab4-5d43-f074-b43e-463f9c5cf05c
		if ( is_null( $message ) ) {
			return sprintf( "%08x-%04x-%04x-%04x-%012x",
				time(),
				mt_rand(0, 0xffff),
				mt_rand(0, 0xffff),
				mt_rand(0, 0x3fff) | 0x8000,
				mt_rand(0, 0xffffffffffff)
			);
		} else {
			//	UUIDs that this server *saves* will be [timestamp]-[hash of message ID]
			//	65eadace-8f434346648f6b96df89dda901c5176b10a6d83961dd3c1ac88b59b2dc327aa4

			//	The message might have its own object
			if ( isset( $message["object"]["id"] ) ) {
				$id = $message["object"]["id"];
			} else {
				$id = $message["id"];
			}

			return sprintf( "%08x", time() ) . "-" . hash( "sha256", $id );
		}
Terence Eden's avatar
Terence Eden committed
	}

Terence Eden's avatar
Terence Eden committed
	//	Headers:
Terence Eden's avatar
Terence Eden committed
	//	Every message that your server sends needs to be cryptographically signed with your Private Key.
	//	This is a complicated process.
	//	Please read https://blog.joinmastodon.org/2018/07/how-to-make-friends-and-verify-requests/ for more information.
Terence Eden's avatar
Terence Eden committed
	function generate_signed_headers( $message, $host, $path, $method ) {
		global $server, $username, $key_private;
Terence Eden's avatar
Terence Eden committed
	
		//	Location of the Public Key
		$keyId  = "https://{$server}/{$username}#main-key";

		//	Get the Private Key
		$signer = openssl_get_privatekey( $key_private );

Terence Eden's avatar
Terence Eden committed
		//	Timestamp this message was sent
		$date   = date( "D, d M Y H:i:s \G\M\T" );

		//	There are subtly different signing requirements for POST and GET.
Terence Eden's avatar
Terence Eden committed
		if ( "POST" == $method ) {
			//	Encode the message object to JSON
			$message_json = json_encode( $message );
			//	Generate signing variables
			$hash   = hash( "sha256", $message_json, true );
			$digest = base64_encode( $hash );

			//	Sign the path, host, date, and digest
			$stringToSign = "(request-target): post $path\nhost: $host\ndate: $date\ndigest: SHA-256=$digest";
			
			//	The signing function returns the variable $signature
			//	https://www.php.net/manual/en/function.openssl-sign.php
			openssl_sign(
				$stringToSign, 
				$signature, 
				$signer, 
				OPENSSL_ALGO_SHA256
			);
			//	Encode the signature
			$signature_b64 = base64_encode( $signature );

			//	Full signature header
			$signature_header = 'keyId="' . $keyId . '",algorithm="rsa-sha256",headers="(request-target) host date digest",signature="' . $signature_b64 . '"';

			//	Add Follower Synchronization Header
			//	https://docs.joinmastodon.org/spec/activitypub/#follower-synchronization-mechanism
			$sync = "collectionId='https://{$server}/followers," . 
			        "url='https://{$server}/{$username}/followers_synchronization'," .
					"digest='" . followers_digest( $host ) . "'";

Terence Eden's avatar
Terence Eden committed
			//	Header for POST request
Terence Eden's avatar
Terence Eden committed
			$headers = array(
Terence Eden's avatar
Terence Eden committed
				        "Host: {$host}",
				        "Date: {$date}",
				      "Digest: SHA-256={$digest}",
				   "Signature: {$signature_header}",
Terence Eden's avatar
Terence Eden committed
				"Content-Type: application/activity+json",
Terence Eden's avatar
Terence Eden committed
				      "Accept: application/activity+json",
  "Collection-Synchronization: {$sync}", 
Terence Eden's avatar
Terence Eden committed
			);
		} else if ( "GET" == $method ) {	
Terence Eden's avatar
Terence Eden committed
			//	Sign the path, host, date - NO DIGEST because there's no message sent.
Terence Eden's avatar
Terence Eden committed
			$stringToSign = "(request-target): get $path\nhost: $host\ndate: $date";
			
			//	The signing function returns the variable $signature
			//	https://www.php.net/manual/en/function.openssl-sign.php
			openssl_sign(
				$stringToSign, 
				$signature, 
				$signer, 
				OPENSSL_ALGO_SHA256
			);
			//	Encode the signature
			$signature_b64 = base64_encode( $signature );

			//	Full signature header
			$signature_header = 'keyId="' . $keyId . '",algorithm="rsa-sha256",headers="(request-target) host date",signature="' . $signature_b64 . '"';

Terence Eden's avatar
Terence Eden committed
			//	Header for GET request
Terence Eden's avatar
Terence Eden committed
			$headers = array(
				        "Host: {$host}",
				        "Date: {$date}",
				   "Signature: {$signature_header}",
Terence Eden's avatar
Terence Eden committed
				      "Accept: application/activity+json, application/json",
Terence Eden's avatar
Terence Eden committed
			);
		}
	function followers_digest( $host ) {
		global $server, $directories;

		//	Get all the current followers 
		$follower_files = glob( $directories["followers"] . "/*.json" );

		//	Return only the users from this domain
		$items_filtered = [];

		foreach ( $follower_files as $follower_file ) {
			$following = json_decode( file_get_contents( $follower_file ), true );
			$following_id = $following["id"];
			$following_domain = parse_url( $following_id )["host"];
			if ( $host === $following_domain) {
				$items_filtered[] = $following_id;
			}
		}

		//	Get the XOR of the SHA256 hashes
		$xor_result = array_fill( 0, 32, 0 );	//	SHA256 hashes are 32 bytes long

		foreach ( $items_filtered as $item ) {
			$hash = hash( "sha256", $item, true );
			for ( $i = 0; $i < 32; $i++ ) {
				$xor_result[$i] ^= ord($hash[$i]);	//	XOR each byte
			}
		}

		//	Return the hex representation
	    return bin2hex( implode( "", array_map( "chr", $xor_result ) ) );	//	Convert to hex
	}

	// Mastodon .well-known/host-meta
	function meta() {
		global $server;

		header('Content-Type: application/xrd+xml');

echo <<< XML
<?xml version="1.0" encoding="UTF-8"?>
<XRD xmlns="http://docs.oasis-open.org/ns/xri/xrd-1.0">
  <Link rel="lrdd" template="https://{$server}/.well-known/webfinger?resource={uri}"/>
</XRD>
XML;
		die();	
	}

Terence Eden's avatar
Terence Eden committed
	// User Interface for Homepage.
	// This creates a basic HTML page. This content appears when someone visits the root of your site.
	function view( $style ) {
		global $username, $server, $realName, $summary, $directories;
		$rawUsername = rawurldecode( $username );
		$h1 = "HomePage";
		$directory = "posts";
		
		//	Counters for followers, following, and posts
		$follower_files  = glob( $directories["followers"] . "/*.json" );
		$totalFollowers  = count( $follower_files );
		$following_files = glob( $directories["following"] . "/*.json" );
		$totalFollowing  = count( $following_files );
		//	Show the HTML page
Terence Eden's avatar
Terence Eden committed
echo <<< HTML
<!DOCTYPE html>
<html lang="en-GB">
	<head>
		<meta charset="UTF-8">
		<meta name="viewport" content="width=device-width, initial-scale=1.0">
		<meta property="og:url" content="https://{$server}">
		<meta property="og:type" content="website">
		<meta property="og:title" content="{$realName}">
		<meta property="og:description" content="{$summary}">
		<meta property="og:image" content="https://{$server}/banner.png">
		<title>{$h1} {$realName}</title>
		<style>
Terence Eden's avatar
Terence Eden committed
			* { max-width: 100%; }
			body { margin:0; padding: 0; font-family:sans-serif; }
			@media screen and (max-width: 800px) { body { width: 100%; }}
			@media screen and (min-width: 799px) { body { width: 800px; margin: 0 auto; }}
			address { font-style: normal; }
			img  { max-width: 50%; }
			.h-feed { margin:auto; width: 100%; }
			.h-feed > header { text-align: center; margin: 0 auto; }
			.h-feed .banner { text-align: center; margin:0 auto; max-width: 650px; }
			.h-feed > h1, .h-feed > h2 { margin-top: 10px; margin-bottom: 0;  }
			.h-feed > header > h1:has(span.p-author), h2:has(a.p-nickname) { word-wrap: break-word; max-width: 90%; padding-left:20px; }
			.h-feed .u-feature:first-child { margin-top: 10px; margin-bottom: -150px; max-width: 100%;}
			.h-feed .u-photo { max-height: 8vw; max-width:100%; min-height: 120px;  }
			.h-feed .about { font-size: smaller; background-color: #F5F5F5; padding: 10px; border-top: dotted 1px #808080; border-bottom: dotted 1px #808080; }
			.h-feed > ul {  padding-left: 0; list-style-type: none; }
			.h-feed > ul > li { padding: 10px; border-bottom: dotted 1px #808080; }
			.h-entry { padding-right: 10px; }
			.h-entry time { font-weight: bold; }
			.h-entry .e-content a { word-wrap: break-word; }
		</style>
	</head>
	<body>
		<main class="h-feed">
			<header>
				<div class="banner">
					<img src="banner.png" alt="" class="u-feature"><br>
					<img src="icon.png" alt="icon" class="u-photo">
				</div>
				<address>
					<h1 class="p-name p-author">{$realName}</h1>
					<h2><a class="p-nickname u-url" rel="author" href="https://{$server}/{$username}">@{$rawUsername}@{$server}</a></h2>
				</address>
				<p class="p-summary">{$summary}</p>
				<p>Following: {$totalFollowing} | Followers: {$totalFollowers}</p>
				<div class="about">
					<p><a href="https://gitlab.com/edent/activity-bot/">This software is licenced under AGPL 3.0</a>.</p>
					<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>
				</div>
			</header>
			<ul>
Terence Eden's avatar
Terence Eden committed
HTML;
		//	Get all the files in the directory
		$message_files = array_reverse( glob( "posts" . "/*.json") );

		//	There are lots of messages. The UI will only show 200.
		$message_files = array_slice( $message_files, 0, 1000 ); 
		//	Loop through the messages, get their conent:
		//	Ensure messages are in the right order.
		$messages_ordered = [];
		foreach ( $message_files as $message_file ) {
Terence Eden's avatar
Terence Eden committed
			$file_parts = explode( ".", $message_file );
			//	Get the contents of the JSON 
			$message = json_decode( file_get_contents( $message_file ), true );
			$published = $message["published"];

			//	Place in an array where the key is the timestamp
			$messages_ordered[$published] = $message;
		}

		//	HTML is *probably* sanitised by the sender. But let's not risk it, eh?
		//	Using the allow-list from https://docs.joinmastodon.org/spec/activitypub/#sanitization
		$allowed_elements = ["p", "span", "br", "a", "del", "pre", "code", "em", "strong", "b", "i", "u", "ul", "ol", "li", "blockquote"];
		//	Print the items in a list
		foreach ( $messages_ordered as $message ) {	
			//	The object of this *is* the message
			$object = $message;
Terence Eden's avatar
Terence Eden committed

			//	Is this a Direct Messsage?
			$to = $object["to"];
			if ( $to[0] != "https://www.w3.org/ns/activitystreams#Public" ) {
				//	Don't display it to the public
				continue;
			}
			//	Get basic details
			$id = $object["id"];
			$published = $object["published"];
			//	HTML for who wrote this
			$publishedHTML  = "<a href=\"{$id}\">{$published}</a>";
		
			//	For displaying the post's information
			$timeHTML  = "<time datetime=\"{$published}\" class=\"u-url\" rel=\"bookmark\">{$publishedHTML}</time>";

			//	Get the actor who authored the message
			$actor = $object["attributedTo"];
			
			//	Assume that what comes after the final `/` in the URl is the name
			$actorArray    = explode( "/", $actor );
			$actorName     = end( $actorArray );
Terence Eden's avatar
Terence Eden committed
			$actorServer   = parse_url( $actor, PHP_URL_HOST );
Terence Eden's avatar
Terence Eden committed
			$actorUsername = "@{$actorName}@{$actorServer}";
			//	Make i18n usernames readable and safe.
			$actorName = htmlspecialchars( rawurldecode( $actorName ) );
			$actorHTML = "<a href=\"$actor\">@{$actorName}</a>";
			//	What type of message is this?
			$type = $message["type"];

			//	Get the HTML content
			$content = $message["content"];
			//	Sanitise the HTML
			$content = strip_tags( $content, $allowed_elements );
			//	Is there is a Content Warning?
			if ( isset( $object["summary"] ) ) {
				$summary = $object["summary"];
				$summary = strip_tags( $summary, $allowed_elements );
				//	Hide the content until the user interacts with it.
				$content = "<details><summary>{$summary}</summary>{$content}</details>"; 
			}
			//	Add any images
			if ( isset( $object["attachment"] ) ) {
				foreach ( $object["attachment"] as $attachment ) {						
					//	Only use things which have a MIME Type set
					if ( isset( $attachment["mediaType"] ) ) {
						$mediaURl = $attachment["url"];
						$mime = $attachment["mediaType"];
						//	Use the first half of the MIME Type.
						//	For example `image/png` or `video/mp4`
						$mediaType = explode( "/", $mime )[0];

						if ( "image" == $mediaType ) {
							//	Get the alt text
							isset( $attachment["name"] ) ? $alt = htmlspecialchars( $attachment["name"] ) : $alt = "";
							$content .= "<img src='{$mediaURl}' alt='{$alt}'>";
						} else if ( "video" == $mediaType ) {
							$content .= "<video controls><source src='{$mediaURl}' type='{$mime}'></video>";
						}else if ( "audio" == $mediaType ) {
							$content .= "<audio controls src='{$mediaURl}' type='{$mime}'></audio>";
			$verb = "posted";
	
			$messageHTML = "{$timeHTML} {$actorHTML} {$verb}: <blockquote class=\"e-content\">{$content}</blockquote>";
Terence Eden's avatar
Terence Eden committed
			//	Display the message
			echo "<li><article class=\"h-entry\">{$messageHTML}<br></article></li>";
Terence Eden's avatar
Terence Eden committed
		</main>
Terence Eden's avatar
Terence Eden committed
	</body>
</html>
HTML;
Terence Eden's avatar
Terence Eden committed
		die();
Terence Eden's avatar
Terence Eden committed
	//	Send Endpoint:
	//	This takes the submitted message and checks the password is correct.
Terence Eden's avatar
Terence Eden committed
	//	It reads all the followers' data in `data/followers`.
	//	It constructs a list of shared inboxes and unique inboxes.
Terence Eden's avatar
Terence Eden committed
	//	It sends the message to every server that is following this account.
Terence Eden's avatar
Terence Eden committed
	function send() {
Terence Eden's avatar
Terence Eden committed
		global $password, $server, $username, $key_private, $directories, $language;
Terence Eden's avatar
Terence Eden committed

		//	Does the posted password match the stored password?
Terence Eden's avatar
Terence Eden committed
		if( $password != $_POST["password"] ) { 
			header("HTTP/1.1 401 Unauthorized");
			echo "Wrong password.";
			die(); 
		}
		
		//	Get the posted content
		$content = $_POST["content"];
Terence Eden's avatar
Terence Eden committed
		//	Process the content into HTML to get hashtags etc
		list( "HTML" => $content, "TagArray" => $tags ) = process_content( $content );
Terence Eden's avatar
Terence Eden committed

		//	Is this a reply?
		if ( isset( $_POST["inReplyTo"] ) && filter_var( $_POST["inReplyTo"], FILTER_VALIDATE_URL ) ) {
			$inReplyTo = $_POST["inReplyTo"];
		} else {
			$inReplyTo = null;
		}
Terence Eden's avatar
Terence Eden committed
		//	Is this a Direct Message?
		//	https://seb.jambor.dev/posts/understanding-activitypub/#direct-messages
		if ( isset( $_POST["DM"] ) ) {
			$dm_name = $_POST["DM"];
			$details = getUserDetails( $dm_name );
			$profileInbox = $details["inbox"];
			$to = $details["profile"];
			$cc = null;
			$tags[] = array( "type" => "Mention", "href" => $to, "name" => $dm_name );
Terence Eden's avatar
Terence Eden committed
		} else {
			//	This is a public message
			$to = "https://www.w3.org/ns/activitystreams#Public";
			$cc = "https://{$server}/followers";
		}

		//	Is there an image attached?
		if ( isset( $_FILES['image']['tmp_name'] ) && ( "" != $_FILES['image']['tmp_name'] ) ) {
			//	Get information about the image
			$image      = $_FILES['image']['tmp_name'];
			$image_info = getimagesize( $image );
			$image_ext  = image_type_to_extension( $image_info[2] );
			$image_mime = $image_info["mime"];
			//	Files are stored according to their hash
			//	A hash of "abc123" is stored in "/images/abc123.jpg"
			$sha1 = sha1_file( $image );
Terence Eden's avatar
Terence Eden committed
			$image_full_path = $directories["images"] . "/{$sha1}{$image_ext}";
			//	Move media to the correct location
			move_uploaded_file( $image, $image_full_path );
			//	Get the alt text
			if ( isset( $_POST["alt"] ) ) {
				$alt = $_POST["alt"];
			//	Construct the attachment value for the post
			$attachment = array( [
				"type"      => "Image",
				"mediaType" => "{$image_mime}",
				"url"       => "https://{$server}/{$image_full_path}",
				"name"      => $alt
			] );
		} else {
			$attachment = [];
		}
Terence Eden's avatar
Terence Eden committed

		//	Current time - ISO8601
		$timestamp = date( "c" );
		//	Outgoing Message ID
		$guid = uuid();
		//	Construct the Note
		//	`contentMap` is used to prevent unnecessary "translate this post" pop ups
		$note = [
			"@context"     => array(
				"https://www.w3.org/ns/activitystreams"
			),
			"id"           => "https://{$server}/posts/{$guid}.json",
			"type"         => "Note",
			"published"    => $timestamp,
			"attributedTo" => "https://{$server}/{$username}",
			"inReplyTo"    => $inReplyTo,
			"content"      => $content,
Terence Eden's avatar
Terence Eden committed
			"contentMap"   => ["{$language}" => $content],
Terence Eden's avatar
Terence Eden committed
			"to"           => [$to],
Terence Eden's avatar
Terence Eden committed
			"cc"           => [$cc],
			"tag"          => $tags,
			"attachment"   => $attachment
		];
		//	Construct the Message
		//	The audience is public and it is sent to all followers
		$message = [
			"@context" => "https://www.w3.org/ns/activitystreams",
			"id"       => "https://{$server}/posts/{$guid}.json",
			"type"     => "Create",
			"actor"    => "https://{$server}/{$username}",
Terence Eden's avatar
Terence Eden committed
			"to"       => [$to],
			"cc"       => [$cc],
			"object"   => $note
		];
		
Terence Eden's avatar
Terence Eden committed

		//	Save the permalink
		$note_json = json_encode( $note );
Terence Eden's avatar
Terence Eden committed
		file_put_contents( $directories["posts"] . "/{$guid}.json", print_r( $note_json, true ) );
Terence Eden's avatar
Terence Eden committed

Terence Eden's avatar
Terence Eden committed
		//	Send the message either publicly or privately
		if ( $cc == null ) {
			$messageSent = sendMessageToSingle( $profileInbox, $message );
		} else {
			//	Send to all the user's followers
			$messageSent = sendMessageToFollowers( $message );
		}
	
		//	Return the JSON so the user can see the POST has worked
		if ( $messageSent ) {
			header( "Location: https://{$server}/posts/{$guid}.json" );
			die();	
		} else {
Terence Eden's avatar
Terence Eden committed
			header("HTTP/1.1 500 Internal Server Error");
	//	POST a signed message to a single inbox
	//	Used for sending Accept messages to follow requests
Terence Eden's avatar
Terence Eden committed
	function sendMessageToSingle( $inbox, $message ) {
		global $directories;

		$inbox_host  = parse_url( $inbox, PHP_URL_HOST );
		$inbox_path  = parse_url( $inbox, PHP_URL_PATH );

		//	Generate the signed headers
		$headers = generate_signed_headers( $message, $inbox_host, $inbox_path, "POST" );

		//	POST the message and header to the requester's inbox
		$ch = curl_init( $inbox );
		curl_setopt( $ch, CURLOPT_RETURNTRANSFER, true );
		curl_setopt( $ch, CURLOPT_CUSTOMREQUEST, "POST" );
		curl_setopt( $ch, CURLOPT_POSTFIELDS,     json_encode( $message ) );
		curl_setopt( $ch, CURLOPT_HTTPHEADER,     $headers );
		curl_setopt( $ch, CURLOPT_USERAGENT,      USERAGENT );
Terence Eden's avatar
Terence Eden committed
		$response = curl_exec( $ch );