2024-02-12 21:21:05 +00:00
< ? 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 .
2024-02-17 18:17:07 +00:00
* The Server produces an Actor who can be followed .
* The Actor can send messages to followers .
2024-02-17 09:30:50 +00:00
* Those messages can have linkable URls and hashtags .
2024-02-17 18:17:07 +00:00
* 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 /
2024-02-12 21:21:05 +00:00
* " Any appearance of design in the Program is purely coincidental and should not in any way be mistaken for evidence of thoughtful software construction. "
2024-02-17 18:17:07 +00:00
* For more information , please re - read .
2024-02-12 21:21:05 +00:00
*/
2024-02-15 09:29:21 +00:00
// Preamble: Set your details here
// This is where you set up your account's name and bio. You also need to provide a public/private keypair. The posting page is protected with a password that also needs to be set here.
2024-02-12 21:21:05 +00:00
// Set up the Actor's information
$username = rawurlencode ( " example " ); // Encoded as it is often used as part of a URl
$realName = " E. Xample. Jr. " ;
2024-02-13 13:30:36 +00:00
$summary = " Some text about the user. " ;
2024-02-14 09:51:14 +00:00
$server = $_SERVER [ " SERVER_NAME " ]; // Domain name this is hosted on
2024-02-12 21:21:05 +00:00
// Generate locally or from https://cryptotools.net/rsagen
// Newlines must be replaced with "\n"
$key_private = " -----BEGIN RSA PRIVATE KEY----- \n ... \n -----END RSA PRIVATE KEY----- " ;
$key_public = " -----BEGIN PUBLIC KEY----- \n ... \n -----END PUBLIC KEY----- " ;
// Password for sending messages
$password = " P4ssW0rd " ;
2024-02-15 09:29:21 +00:00
// Logging:
// ActivityPub is a "chatty" protocol. This takes all the requests your server receives and saves them in `/logs/` as a datestamped text file.
2024-02-12 21:21:05 +00:00
// Get all headers and requests sent to this server
$headers = print_r ( getallheaders (), true );
2024-02-13 13:30:36 +00:00
$postData = print_r ( $_POST , true );
$getData = print_r ( $_GET , true );
$filesData = print_r ( $_FILES , true );
$body = json_decode ( file_get_contents ( " php://input " ), true );
2024-02-15 09:29:21 +00:00
$bodyData = print_r ( $body , true );
2024-02-12 21:21:05 +00:00
$requestData = print_r ( $_REQUEST , true );
2024-02-13 13:30:36 +00:00
$serverData = print_r ( $_SERVER , true );
2024-02-12 21:21:05 +00:00
2024-02-13 13:30:36 +00:00
// Get the type of request - used in the log filename
2024-02-12 21:21:05 +00:00
if ( isset ( $body [ " type " ] ) ) {
$type = " " . $body [ " type " ];
} else {
$type = " " ;
}
// Create a timestamp in ISO 8601 format for the filename
2024-02-14 09:51:14 +00:00
$timestamp = date ( " c " );
2024-02-12 21:21:05 +00:00
// Filename for the log
$filename = " { $timestamp } { $type } .txt " ;
2024-02-13 13:30:36 +00:00
// Save headers and request data to the timestamped file in the logs directory
if ( ! is_dir ( " logs " ) ) { mkdir ( " logs " ); }
file_put_contents ( " logs/ { $filename } " ,
2024-02-12 21:21:05 +00:00
" Headers: \n $headers \n \n " .
2024-02-13 13:30:36 +00:00
" Body Data: \n $bodyData \n \n " .
2024-02-12 21:21:05 +00:00
" POST Data: \n $postData \n \n " .
" GET Data: \n $getData \n \n " .
" Files Data: \n $filesData \n \n " .
" Request Data: \n $requestData\n\n " .
" Server Data: \n $serverData \n \n "
);
2024-02-15 09:29:21 +00:00
// Routing:
2024-02-12 21:21:05 +00:00
// The .htaccess changes /whatever to /?path=whatever
2024-02-15 09:29:21 +00:00
// This runs the function of the path requested.
2024-02-16 09:00:52 +00:00
! empty ( $_GET [ " path " ] ) ? $path = $_GET [ " path " ] : die ();
2024-02-12 21:21:05 +00:00
switch ( $path ) {
case " .well-known/webfinger " :
webfinger ();
2024-02-13 13:30:36 +00:00
case rawurldecode ( $username ) :
2024-02-12 21:21:05 +00:00
username ();
case " following " :
following ();
case " followers " :
followers ();
case " inbox " :
inbox ();
case " write " :
write ();
case " send " :
send ();
default :
die ();
}
2024-02-15 09:29:21 +00:00
// The [WebFinger Protocol](https://docs.joinmastodon.org/spec/webfinger/) is used to identify accounts.
// 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.
2024-02-12 21:21:05 +00:00
function webfinger () {
global $username , $server ;
$webfinger = array (
" subject " => " acct: { $username } @ { $server } " ,
" links " => array (
array (
" rel " => " self " ,
" type " => " application/activity+json " ,
" href " => " https:// { $server } / { $username } "
)
)
);
2024-02-13 13:30:36 +00:00
header ( " Content-Type: application/json " );
2024-02-12 21:21:05 +00:00
echo json_encode ( $webfinger );
die ();
}
2024-02-15 09:29:21 +00:00
// User:
// Requesting `example.com/username` returns a JSON document with the user's information.
2024-02-12 21:21:05 +00:00
function username () {
2024-02-15 09:29:21 +00:00
global $username , $realName , $summary , $server , $key_public ;
2024-02-12 21:21:05 +00:00
$user = array (
" @context " => [
" https://www.w3.org/ns/activitystreams " ,
" https://w3id.org/security/v1 "
],
" id " => " https:// { $server } / { $username } " ,
" type " => " Person " ,
" following " => " https:// { $server } /following " ,
" followers " => " https:// { $server } /followers " ,
" inbox " => " https:// { $server } /inbox " ,
" preferredUsername " => rawurldecode ( $username ),
" name " => " { $realName } " ,
2024-02-13 13:30:36 +00:00
" summary " => " { $summary } " ,
2024-02-12 21:21:05 +00:00
" url " => " https:// { $server } " ,
" manuallyApprovesFollowers " => true ,
" discoverable " => true ,
" published " => " 2024-02-12T11:51:00Z " ,
" icon " => [
" type " => " Image " ,
" mediaType " => " image/png " ,
" url " => " https:// { $server } /icon.png "
],
" publicKey " => [
" id " => " https:// { $server } / { $username } #main-key " ,
" owner " => " https:// { $server } / { $username } " ,
" publicKeyPem " => $key_public
]
);
2024-02-13 13:30:36 +00:00
header ( " Content-Type: application/activity+json " );
2024-02-12 21:21:05 +00:00
echo json_encode ( $user );
die ();
}
2024-02-15 09:29:21 +00:00
// 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.
2024-02-12 21:21:05 +00:00
function following () {
global $server ;
$following = array (
" @context " => " https://www.w3.org/ns/activitystreams " ,
" id " => " https:// { $server } /following " ,
" type " => " Collection " ,
" totalItems " => 0 ,
" items " => []
);
2024-02-13 13:30:36 +00:00
header ( " Content-Type: application/activity+json " );
2024-02-12 21:21:05 +00:00
echo json_encode ( $following );
die ();
}
function followers () {
global $server ;
$followers = array (
" @context " => " https://www.w3.org/ns/activitystreams " ,
" id " => " https:// { $server } /followers " ,
" type " => " Collection " ,
" totalItems " => 0 ,
" items " => []
);
2024-02-13 13:30:36 +00:00
header ( " Content-Type: application/activity+json " );
2024-02-12 21:21:05 +00:00
echo json_encode ( $followers );
die ();
}
2024-02-15 09:29:21 +00:00
// Inbox:
// The `/inbox` is the main server. It receives all requests.
// This server only responds to "Follow" requests.
// A remote server sends a follow request which is a JSON file saying who they are.
// This code does not cryptographically validate the headers of the received message.
// The name of the remote user's server is saved to a file so that future messages can be delivered to it.
// An accept request is cryptographically signed and POST'd back to the remote server.
2024-02-12 21:21:05 +00:00
function inbox () {
global $body , $server , $username , $key_private ;
// Get the message and type
$inbox_message = $body ;
$inbox_type = $inbox_message [ " type " ];
// This inbox only responds to follow requests
if ( " Follow " != $inbox_type ) { die (); }
// Get the parameters
$inbox_id = $inbox_message [ " id " ];
$inbox_actor = $inbox_message [ " actor " ];
2024-02-13 13:30:36 +00:00
$inbox_host = parse_url ( $inbox_actor , PHP_URL_HOST );
2024-02-12 21:21:05 +00:00
// Does this account have any followers?
if ( file_exists ( " followers.json " ) ) {
$followers_file = file_get_contents ( " followers.json " );
$followers_json = json_decode ( $followers_file , true );
} else {
$followers_json = array ();
}
// Add user to list. Don't care about duplicate users, server is what's important
$followers_json [ $inbox_host ][ " users " ][] = $inbox_actor ;
// Save the new followers file
file_put_contents ( " followers.json " , print_r ( json_encode ( $followers_json ), true ) );
// Response Message ID
// This isn't used for anything important so could just be a random number
$guid = uuid ();
// Create the Accept message
$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 " => $inbox_id ,
" type " => $inbox_type ,
" actor " => $inbox_actor ,
" object " => " https:// { $server } / { $username } " ,
]
];
// The Accept is sent to the server of the user who requested the follow
// TODO: The path doesn't *always* end with/inbox
$host = $inbox_host ;
2024-02-13 13:30:36 +00:00
$path = parse_url ( $inbox_actor , PHP_URL_PATH ) . " /inbox " ;
2024-02-12 21:21:05 +00:00
2024-02-14 09:45:04 +00:00
// Get the signed headers
$headers = generate_signed_headers ( $message , $host , $path );
2024-02-12 21:21:05 +00:00
// Specify the URL of the remote server's inbox
// TODO: The path doesn't *always* end with /inbox
$remoteServerUrl = $inbox_actor . " /inbox " ;
// POST the message and header to the requester's inbox
$ch = curl_init ( $remoteServerUrl );
curl_setopt ( $ch , CURLOPT_RETURNTRANSFER , true );
curl_setopt ( $ch , CURLOPT_CUSTOMREQUEST , " POST " );
2024-02-14 09:45:04 +00:00
curl_setopt ( $ch , CURLOPT_POSTFIELDS , json_encode ( $message ) );
2024-02-12 21:21:05 +00:00
curl_setopt ( $ch , CURLOPT_HTTPHEADER , $headers );
2024-02-16 09:34:09 +00:00
curl_exec ( $ch );
2024-02-12 21:21:05 +00:00
// Check for errors
if ( curl_errno ( $ch ) ) {
file_put_contents ( " error.txt " , curl_error ( $ch ) );
}
curl_close ( $ch );
die ();
}
2024-02-15 09:29:21 +00:00
// Unique ID:
// 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.
2024-02-12 21:21:05 +00:00
function uuid () {
2024-02-14 09:51:14 +00:00
return sprintf ( " %08x-%04x-%04x-%04x-%012x " ,
2024-02-12 21:21:05 +00:00
time (),
mt_rand ( 0 , 0xffff ),
mt_rand ( 0 , 0xffff ),
mt_rand ( 0 , 0x3fff ) | 0x8000 ,
mt_rand ( 0 , 0xffffffffffff )
);
}
2024-02-15 09:29:21 +00:00
// Headers:
// 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.
2024-02-14 09:45:04 +00:00
function generate_signed_headers ( $message , $host , $path ) {
global $server , $username , $key_private ;
2024-02-15 09:29:21 +00:00
// Encode the message object to JSON
2024-02-14 09:45:04 +00:00
$message_json = json_encode ( $message );
// Location of the Public Key
$keyId = " https:// { $server } / { $username } #main-key " ;
// Generate signing variables
2024-02-14 09:51:14 +00:00
$hash = hash ( " sha256 " , $message_json , true );
2024-02-14 09:45:04 +00:00
$digest = base64_encode ( $hash );
2024-02-14 09:51:14 +00:00
$date = date ( " D, d M Y H:i:s \ G \ M \T " );
2024-02-14 09:45:04 +00:00
// Get the Private Key
$signer = openssl_get_privatekey ( $key_private );
// 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 . '"' ;
// Header for POST reply
$headers = array (
" Host: { $host } " ,
" Date: { $date } " ,
" Digest: SHA-256= { $digest } " ,
" Signature: { $signature_header } " ,
" Content-Type: application/activity+json " ,
" Accept: application/activity+json " ,
);
return $headers ;
}
2024-02-15 09:29:21 +00:00
// User Interface for Writing:
// This creates a basic HTML form. Type in your message and your password. It then POSTs the data to the `/send` endpoint.
2024-02-12 21:21:05 +00:00
function write () {
echo <<< HTML
<! DOCTYPE html >
< html lang = " en-GB " >
< head >
< meta charset = " UTF-8 " >
< title > Send Message </ title >
< style >
* { font - family : sans - serif ; font - size : 1.1 em ;}
</ style >
</ head >
< body >
< form action = " /send " method = " post " enctype = " multipart/form-data " >
< label for = " content " > Your message :</ label >< br >
2024-02-16 09:34:09 +00:00
< textarea id = " content " name = " content " rows = " 5 " cols = " 32 " ></ textarea >< br >
2024-02-12 21:21:05 +00:00
< label for = " password " > Password </ label >< br >
< input type = " password " name = " password " id = " password " size = " 32 " >< br >
< input type = " submit " value = " Post Message " >
</ form >
</ body >
</ html >
HTML ;
die ();
}
2024-02-15 09:29:21 +00:00
// Send Endpoint:
// This takes the submitted message and checks the password is correct.
// It reads the `followers.json` file and sends the message to every server that is following this account.
2024-02-12 21:21:05 +00:00
function send () {
global $password , $server , $username , $key_private ;
// Does the posted password match the stored password?
if ( $password != $_POST [ " password " ] ) { die (); }
// Get the posted content
$content = $_POST [ " content " ];
2024-02-16 16:09:05 +00:00
// Process the content into HTML to get hashtags etc
list ( " HTML " => $content , " TagArray " => $tags ) = process_content ( $content );
2024-02-13 13:30:36 +00:00
// Current time - ISO8601
$timestamp = date ( " c " );
2024-02-12 21:21:05 +00:00
// Outgoing Message ID
$guid = uuid ();
// Construct the Note
2024-02-13 13:30:36 +00:00
// contentMap is used to prevent unnecessary "translate this post" pop ups
// hardcoded to English
2024-02-12 21:21:05 +00:00
$note = [
" @context " => array (
" https://www.w3.org/ns/activitystreams "
),
" id " => " https:// { $server } /posts/ { $guid } .json " ,
" type " => " Note " ,
" published " => $timestamp ,
" attributedTo " => " https:// { $server } / { $username } " ,
" content " => $content ,
" contentMap " => [ " en " => $content ],
2024-02-16 16:09:05 +00:00
" to " => [ " https://www.w3.org/ns/activitystreams#Public " ],
" tag " => $tags
2024-02-12 21:21:05 +00:00
];
// Construct the Message
2024-02-16 09:34:09 +00:00
// The audience is public and it is sent to all followers
2024-02-12 21:21:05 +00:00
$message = [
" @context " => " https://www.w3.org/ns/activitystreams " ,
" id " => " https:// { $server } /posts/ { $guid } .json " ,
" type " => " Create " ,
" actor " => " https:// { $server } / { $username } " ,
" to " => [
" https://www.w3.org/ns/activitystreams#Public "
],
" cc " => [
" https:// { $server } /followers "
],
" object " => $note
];
// Create the context for the permalink
2024-02-13 13:30:36 +00:00
$note = [ " @context " => " https://www.w3.org/ns/activitystreams " , ... $note ];
2024-02-12 21:21:05 +00:00
// Save the permalink
$note_json = json_encode ( $note );
2024-02-13 13:30:36 +00:00
// Check for posts/ directory and create it
if ( ! is_dir ( " posts " ) ) { mkdir ( " posts " ); }
2024-02-12 21:21:05 +00:00
file_put_contents ( " posts/ { $guid } .json " , print_r ( $note_json , true ) );
// Read existing users and get their hosts
$followers_file = file_get_contents ( " followers.json " );
$followers_json = json_decode ( $followers_file , true );
$hosts = array_keys ( $followers_json );
// Prepare to use the multiple cURL handle
$mh = curl_multi_init ();
// Loop through all the severs of the followers
// Each server needs its own cURL handle
// Each POST to an inbox needs to be signed separately
foreach ( $hosts as $host ) {
2024-02-16 09:34:09 +00:00
// TODO: Not every host uses /inbox
2024-02-14 09:51:14 +00:00
$path = " /inbox " ;
2024-02-14 09:45:04 +00:00
// Get the signed headers
$headers = generate_signed_headers ( $message , $host , $path );
2024-02-12 21:21:05 +00:00
// Specify the URL of the remote server
$remoteServerUrl = " https:// { $host } { $path } " ;
// POST the message and header to the requester's inbox
2024-02-16 09:34:09 +00:00
$ch = curl_init ( $remoteServerUrl );
2024-02-12 21:21:05 +00:00
curl_setopt ( $ch , CURLOPT_RETURNTRANSFER , true );
curl_setopt ( $ch , CURLOPT_CUSTOMREQUEST , " POST " );
2024-02-14 09:45:04 +00:00
curl_setopt ( $ch , CURLOPT_POSTFIELDS , json_encode ( $message ) );
2024-02-12 21:21:05 +00:00
curl_setopt ( $ch , CURLOPT_HTTPHEADER , $headers );
// Add the handle to the multi-handle
curl_multi_add_handle ( $mh , $ch );
}
// Execute the multi-handle
do {
$status = curl_multi_exec ( $mh , $active );
if ( $active ) {
curl_multi_select ( $mh );
}
} while ( $active && $status == CURLM_OK );
// Close the multi-handle
curl_multi_close ( $mh );
// Render the JSON so the user can see the POST has worked
header ( " Location: https:// { $server } /posts/ { $guid } .json " );
die ();
}
2024-02-16 16:09:05 +00:00
// Content can be plain text. But to add clickable links and hashtags, it needs to be turned into HTML.
2024-02-17 09:30:50 +00:00
// Tags are also included separately in the note
2024-02-16 16:09:05 +00:00
function process_content ( $content ) {
global $server ;
2024-02-17 09:30:50 +00:00
// Convert any URls into hyperlinks
$link_pattern = '/\bhttps?:\/\/\S+/iu' ;
$replacement = function ( $match ) {
$url = htmlspecialchars ( $match [ 0 ], ENT_QUOTES , " UTF-8 " );
return " <a href= \" $url\ " > $url </ a > " ;
};
$content = preg_replace_callback ( $link_pattern , $replacement , $content );
2024-02-16 16:09:05 +00:00
// Get any hashtags
$hashtags = [];
2024-02-17 09:30:50 +00:00
$hashtag_pattern = '/(?:^|\s)\#(\w+)/' ; // Beginning of string, or whitespace, followed by #
2024-02-17 20:48:03 +00:00
preg_match_all ( $hashtag_pattern , $content , $hashtag_matches );
foreach ( $hashtag_matches [ 1 ] as $match ) {
2024-02-16 16:09:05 +00:00
$hashtags [] = $match ;
}
// Construct the tag value for the note object
$tags = [];
foreach ( $hashtags as $hashtag ) {
$tags [] = array (
" type " => " Hashtag " ,
" name " => " # { $hashtag } " ,
);
}
// Add HTML links for hashtags into the text
$content = preg_replace (
2024-02-17 20:48:03 +00:00
$hashtag_pattern ,
2024-02-16 16:09:05 +00:00
" <a href='https:// { $server } /tag/ $ 1'># $ 1</a> " ,
$content
);
2024-02-17 20:48:03 +00:00
// Detect user mentions
$usernames = [];
$usernames_pattern = '/@(\S+)@(\S+)/' ; // This is a *very* sloppy regex
preg_match_all ( $usernames_pattern , $content , $usernames_matches );
foreach ( $usernames_matches [ 0 ] as $match ) {
$usernames [] = $match ;
}
// Construct the mentions value for the note object
// This goes in the generic "tag" property
// TODO: Add this to the CC field
foreach ( $usernames as $username ) {
list ( $null , $user , $domain ) = explode ( " @ " , $username );
$tags [] = array (
" type " => " Mention " ,
" href " => " https:// { $domain } /@ { $user } " ,
" name " => " { $username } "
);
// Add HTML links to usernames
$username_link = " <a href= \" https:// { $domain } /@ { $user } \" > $username </a> " ;
$content = str_replace ( $username , $username_link , $content );
}
2024-02-16 16:09:05 +00:00
// Construct the content
$content = " <p> { $content } </p> " ;
2024-02-17 20:48:03 +00:00
return [
" HTML " => $content ,
" TagArray " => $tags
];
2024-02-16 16:09:05 +00:00
}
2024-02-14 09:45:04 +00:00
// "One to stun, two to kill, three to make sure"
2024-02-13 13:30:36 +00:00
die ();
die ();
die ();