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-18 15:48:33 +00:00
* The message can have linkable URls , hashtags , and mentions .
* An image and alt text can be attached to the message .
2024-03-01 23:26:10 +00:00
* The Actor can follow remote accounts .
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
2024-02-26 19:56:55 +00:00
// 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-15 09:29:21 +00:00
2024-02-12 21:21:05 +00:00
// Set up the Actor's information
2024-02-18 20:58:11 +00:00
// Edit these:
$username = rawurlencode ( " example " ); // Type the @ username that you want. Do not include an "@".
$realName = " E. Xample. Jr. " ; // This is the user's "real" name.
$summary = " Some text about the user. " ; // This is the bio of your user.
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-03-01 23:26:10 +00:00
/** No need to edit anything below here. But please go exploring! **/
2024-02-18 20:58:11 +00:00
// Internal data
$server = $_SERVER [ " SERVER_NAME " ]; // Do not change this!
2024-03-04 13:16:39 +00:00
// Some requests require a User-Agent string.
define ( " USERAGENT " , " activitypub-single-php-file/0.0 " );
2024-03-01 23:26:10 +00:00
// Set up where logs and messages go.
2024-03-03 21:57:19 +00:00
// You can change these directories to something more suitable if you like.
2024-02-29 17:26:10 +00:00
$data = " data " ;
$directories = array (
2024-03-01 23:26:10 +00:00
" inbox " => " { $data } /inbox " ,
" followers " => " { $data } /followers " ,
" following " => " { $data } /following " ,
" logs " => " { $data } /logs " ,
" posts " => " posts " ,
" images " => " images " ,
2024-02-29 17:26:10 +00:00
);
2024-03-03 21:57:19 +00:00
// Create the directories if they don't already exist.
2024-02-29 17:26:10 +00:00
foreach ( $directories as $directory ) {
if ( ! is_dir ( $directory ) ) { mkdir ( $data ); mkdir ( $directory ); }
}
2024-03-05 19:42:02 +00:00
// Get the information sent to this server
2024-02-25 14:42:50 +00:00
$input = file_get_contents ( " php://input " );
2024-02-26 19:56:55 +00:00
$body = json_decode ( $input , true );
2024-02-15 09:29:21 +00:00
$bodyData = print_r ( $body , true );
2024-03-05 19:42:02 +00:00
2024-03-03 21:57:19 +00:00
// If the root has been requested, manually set the path to `/`
! empty ( $_GET [ " path " ] ) ? $path = $_GET [ " path " ] : $path = " / " ;
2024-02-12 21:21:05 +00:00
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-03-04 11:49:13 +00:00
switch ( $path ) {
2024-02-12 21:21:05 +00:00
case " .well-known/webfinger " :
2024-02-18 14:29:06 +00:00
webfinger (); // Mandatory. Static.
2024-02-13 13:30:36 +00:00
case rawurldecode ( $username ) :
2024-03-02 21:35:32 +00:00
case " @ " . rawurldecode ( $username ) : // Some software assumes usernames start with an `@`
2024-02-18 14:29:06 +00:00
username (); // Mandatory. Static
2024-02-12 21:21:05 +00:00
case " following " :
2024-03-01 23:26:10 +00:00
following (); // Mandatory. Can be static or dynamic.
2024-02-12 21:21:05 +00:00
case " followers " :
2024-03-01 23:26:10 +00:00
followers (); // Mandatory. Can be static or dynamic.
2024-02-12 21:21:05 +00:00
case " inbox " :
2024-03-01 23:26:10 +00:00
inbox (); // Mandatory.
2024-03-02 21:35:32 +00:00
case " outbox " :
outbox (); // Optional. Dynamic.
2024-02-12 21:21:05 +00:00
case " write " :
2024-03-03 21:57:19 +00:00
write (); // User interface for writing posts.
2024-03-05 20:41:32 +00:00
case " action/send " :
2024-03-03 21:57:19 +00:00
send (); // API for posting content to the Fediverse.
2024-03-05 20:41:32 +00:00
case " users " :
users (); // User interface for (un)following & (un)blocking an external user.
case " action/users " :
action_users (); // API for following a user.
2024-03-01 10:38:18 +00:00
case " read " :
2024-03-03 21:57:19 +00:00
view ( " read " ); // User interface for reading posts.
2024-03-02 21:35:32 +00:00
case " .well-known/nodeinfo " :
2024-03-03 21:57:19 +00:00
wk_nodeinfo (); // Optional. Static.
2024-03-02 21:35:32 +00:00
case " nodeinfo/2.1 " :
2024-03-03 21:57:19 +00:00
nodeinfo (); // Optional. Static.
2024-02-26 19:56:55 +00:00
case " / " :
2024-03-03 21:57:19 +00:00
view ( " home " ); // Optional. Can be dynamic
2024-02-12 21:21:05 +00:00
default :
die ();
}
2024-02-26 19:56:55 +00:00
// The WebFinger Protocol is used to identify accounts.
2024-02-15 09:29:21 +00:00
// 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 " ,
2024-02-18 14:44:57 +00:00
" outbox " => " https:// { $server } /outbox " ,
2024-03-04 11:49:13 +00:00
" preferredUsername " => rawurldecode ( $username ),
2024-02-12 21:21:05 +00:00
" name " => " { $realName } " ,
2024-02-13 13:30:36 +00:00
" summary " => " { $summary } " ,
2024-02-18 20:58:11 +00:00
" url " => " https:// { $server } / { $username } " ,
2024-03-01 22:40:39 +00:00
" manuallyApprovesFollowers " => false ,
2024-02-12 21:21:05 +00:00
" discoverable " => true ,
2024-03-01 22:40:39 +00:00
" published " => " 2024-02-29T12:34:56Z " ,
2024-02-12 21:21:05 +00:00
" icon " => [
" type " => " Image " ,
" mediaType " => " image/png " ,
" url " => " https:// { $server } /icon.png "
],
2024-03-02 22:49:10 +00:00
" image " => [
" type " => " Image " ,
" mediaType " => " image/png " ,
" url " => " https:// { $server } /banner.png "
],
2024-02-12 21:21:05 +00:00
" 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 () {
2024-02-29 20:53:50 +00:00
global $server , $directories ;
// Get all the files
$following_files = glob ( $directories [ " following " ] . " /*.json " );
2024-03-01 23:26:10 +00:00
// Number of users
2024-02-29 20:53:50 +00:00
$totalItems = count ( $following_files );
2024-03-01 23:26:10 +00:00
// Create a list of all the followers
2024-02-29 20:53:50 +00:00
$items = array ();
foreach ( $following_files as $following_file ) {
2024-03-03 21:57:19 +00:00
$following = json_decode ( file_get_contents ( $following_file ), true );
2024-02-29 20:53:50 +00:00
$items [] = $following [ " id " ];
}
2024-02-12 21:21:05 +00:00
$following = array (
" @context " => " https://www.w3.org/ns/activitystreams " ,
" id " => " https:// { $server } /following " ,
" type " => " Collection " ,
2024-02-29 20:53:50 +00:00
" totalItems " => $totalItems ,
" items " => $items
2024-02-12 21:21:05 +00:00
);
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 () {
2024-02-29 20:53:50 +00:00
global $server , $directories ;
2024-03-03 21:57:19 +00:00
// The number of followers is self-reported.
// You can set this to any number you like.
2024-02-24 11:21:56 +00:00
// Get all the files
2024-02-29 20:53:50 +00:00
$follower_files = glob ( $directories [ " followers " ] . " /*.json " );
2024-03-01 23:26:10 +00:00
// Number of users
2024-02-24 11:21:56 +00:00
$totalItems = count ( $follower_files );
2024-03-01 23:26:10 +00:00
// Create a list of everyone being followed
2024-02-29 20:53:50 +00:00
$items = array ();
foreach ( $follower_files as $follower_file ) {
$following = json_decode ( file_get_contents ( $follower_file ), true );
$items [] = $following [ " id " ];
}
2024-02-12 21:21:05 +00:00
$followers = array (
" @context " => " https://www.w3.org/ns/activitystreams " ,
" id " => " https:// { $server } /followers " ,
" type " => " Collection " ,
2024-02-24 11:21:56 +00:00
" totalItems " => $totalItems ,
2024-02-29 20:53:50 +00:00
" items " => $items
2024-02-12 21:21:05 +00:00
);
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.
2024-02-12 21:21:05 +00:00
function inbox () {
2024-02-29 17:26:10 +00:00
global $body , $server , $username , $key_private , $directories ;
2024-02-12 21:21:05 +00:00
// Get the message and type
$inbox_message = $body ;
$inbox_type = $inbox_message [ " type " ];
2024-03-05 19:42:02 +00:00
// Messages to ignore.
// Some servers are very chatty. They send lots of irrelevant messages.
// Before even bothering to validate them, we can delete them.
// Lemmy sends lots of announce messages. The object contains details of what the message is.
if ( is_array ( $inbox_message [ " object " ] ) ) {
if ( match ( $inbox_message [ " object " ][ " type " ] ) {
" Follow " , " Undo " , " Dislike " , " Like " => true ,
default => false ,
} ) {
// Discard it, no further processing.
die ();
}
}
2024-03-02 12:18:11 +00:00
// Save any Follow, Create, Update, Announce, Like messages
2024-02-29 17:26:10 +00:00
// This ignores Delete, Undo, and anything else
if ( match ( $inbox_type ) {
2024-03-02 12:18:11 +00:00
" Follow " , " Create " , " Update " , " Announce " , " Like " => true ,
2024-02-29 17:26:10 +00:00
default => false ,
} ) {
2024-03-05 19:42:02 +00:00
// Validate HTTP Message Signature
// This logs whether the signature was validated or not
if ( ! verifyHTTPSignature () ) { die (); }
// If the message is valid, save the message in `/data/inbox/`
2024-03-01 22:54:22 +00:00
$uuid = uuid ();
$inbox_filename = $uuid . " . " . urlencode ( $inbox_type ) . " .json " ;
2024-02-29 17:26:10 +00:00
file_put_contents ( $directories [ " inbox " ] . " / { $inbox_filename } " , json_encode ( $inbox_message ) );
}
2024-03-01 23:26:10 +00:00
// This inbox only responds to follow requests.
// A remote server sends the inbox 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.
2024-02-12 21:21:05 +00:00
if ( " Follow " != $inbox_type ) { die (); }
// Get the parameters
2024-02-26 19:56:55 +00:00
$follower_id = $inbox_message [ " id " ]; // E.g. https://mastodon.social/(unique id)
$follower_actor = $inbox_message [ " actor " ]; // E.g. https://mastodon.social/users/Edent
2024-03-04 13:16:39 +00:00
2024-02-23 15:08:25 +00:00
// Get the actor's profile as JSON
2024-03-04 13:16:39 +00:00
$follower_actor_details = getDataFromURl ( $follower_actor );
2024-02-12 21:21:05 +00:00
2024-03-04 13:16:39 +00:00
// 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 ) );
2024-02-23 15:08:25 +00:00
// Get the new follower's Inbox
$follower_inbox = $follower_actor_details [ " inbox " ];
2024-02-12 21:21:05 +00:00
// Response Message ID
// This isn't used for anything important so could just be a random number
$guid = uuid ();
2024-02-23 15:08:25 +00:00
// Create the Accept message to the new follower
2024-02-12 21:21:05 +00:00
$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 " ,
2024-02-23 15:08:25 +00:00
" id " => $follower_id ,
2024-02-12 21:21:05 +00:00
" type " => $inbox_type ,
2024-02-23 15:08:25 +00:00
" actor " => $follower_actor ,
2024-02-12 21:21:05 +00:00
" object " => " https:// { $server } / { $username } " ,
]
];
2024-02-23 15:08:25 +00:00
// The Accept is POSTed to the inbox on the server of the user who requested the follow
2024-03-03 21:57:19 +00:00
sentMessageToSingle ( $follower_inbox , $message );
2024-02-12 21:21:05 +00:00
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.
2024-02-26 19:56:55 +00:00
// 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-23 15:08:25 +00:00
function generate_signed_headers ( $message , $host , $path , $method ) {
2024-02-14 09:45:04 +00:00
global $server , $username , $key_private ;
2024-02-23 15:08:25 +00:00
2024-02-14 09:45:04 +00:00
// Location of the Public Key
2024-03-03 21:57:19 +00:00
$keyId = " https:// { $server } / { $username } #main-key " ;
2024-02-14 09:45:04 +00:00
// Get the Private Key
$signer = openssl_get_privatekey ( $key_private );
2024-02-23 15:08:25 +00:00
// Timestamp this message was sent
$date = date ( " D, d M Y H:i:s \ G \ M \T " );
2024-03-03 21:57:19 +00:00
// There are subtly different signing requirements for POST and GET.
2024-02-23 15:08:25 +00:00
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 . '"' ;
2024-02-26 19:56:55 +00:00
// Header for POST request
2024-02-23 15:08:25 +00:00
$headers = array (
2024-02-26 19:56:55 +00:00
" Host: { $host } " ,
" Date: { $date } " ,
" Digest: SHA-256= { $digest } " ,
" Signature: { $signature_header } " ,
2024-02-23 15:08:25 +00:00
" Content-Type: application/activity+json " ,
2024-02-26 19:56:55 +00:00
" Accept: application/activity+json " ,
2024-02-23 15:08:25 +00:00
);
} else if ( " GET " == $method ) {
2024-03-01 23:26:10 +00:00
// Sign the path, host, date - NO DIGEST because there's no message sent.
2024-02-23 15:08:25 +00:00
$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 . '"' ;
2024-02-26 19:56:55 +00:00
// Header for GET request
2024-02-23 15:08:25 +00:00
$headers = array (
" Host: { $host } " ,
" Date: { $date } " ,
" Signature: { $signature_header } " ,
2024-03-04 11:46:41 +00:00
" Accept: application/activity+json, application/json " ,
2024-02-23 15:08:25 +00:00
);
}
2024-02-14 09:45:04 +00:00
return $headers ;
}
2024-03-03 14:01:01 +00:00
2024-03-01 23:26:10 +00:00
// User Interface for Homepage.
2024-02-18 14:10:01 +00:00
// This creates a basic HTML page. This content appears when someone visits the root of your site.
2024-03-03 14:01:01 +00:00
function view ( $style ) {
2024-03-01 10:38:18 +00:00
global $username , $server , $realName , $summary , $directories ;
2024-03-02 23:48:11 +00:00
$rawUsername = rawurldecode ( $username );
2024-03-03 14:01:01 +00:00
2024-03-03 14:39:12 +00:00
// What sort of viewable page is this?
2024-03-03 14:01:01 +00:00
switch ( $style ) {
case " home " :
$h1 = " HomePage " ;
$directory = " posts " ;
break ;
case " read " :
$h1 = " InBox " ;
$directory = " inbox " ;
break ;
}
2024-03-03 14:39:12 +00:00
// Counters for followers, following, and posts
2024-03-03 21:57:19 +00:00
$follower_files = glob ( $directories [ " followers " ] . " /*.json " );
2024-03-03 14:39:12 +00:00
$totalFollowers = count ( $follower_files );
2024-03-03 21:57:19 +00:00
$following_files = glob ( $directories [ " following " ] . " /*.json " );
2024-03-03 14:39:12 +00:00
$totalFollowing = count ( $following_files );
2024-03-03 14:01:01 +00:00
2024-03-03 15:00:01 +00:00
// Show the HTML page
2024-02-18 14:29:06 +00:00
echo <<< HTML
2024-03-03 21:57:19 +00:00
<! 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 >
body { margin : 0 ; padding : 0 ; font - family : sans - serif ; }
@ media screen and ( max - width : 800 px ) { body { width : 100 % ; }}
@ media screen and ( min - width : 799 px ) { body { width : 800 px ; 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 : 650 px ; }
. h - feed > h1 , . h - feed > h2 { margin - top : 10 px ; 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 : 20 px ; }
. h - feed . u - feature : first - child { margin - top : 10 px ; margin - bottom : - 150 px ; max - width : 100 % ;}
. h - feed . u - photo { max - height : 8 vw ; max - width : 100 % ; min - height : 120 px ; }
. 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 : 10 px ; border - bottom : dotted 1 px #808080; }
. h - entry { padding - right : 10 px ; }
. 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/activitypub-single-php-file/ " > 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 >
2024-03-02 23:33:59 +00:00
HTML ;
2024-03-03 14:01:01 +00:00
// Get all the files in the directory
$message_files = array_reverse ( glob ( $directories [ $directory ] . " /*.json " ) );
// Keep the most recent 200
$message_files = array_slice ( $message_files , 0 , 200 );
2024-03-03 15:00:01 +00:00
// Loop through the messages:
// Remove any which have been updated.
// Ensure messages are in the right order.
2024-03-03 14:01:01 +00:00
$messages_ordered = [];
2024-03-03 15:00:01 +00:00
$messages_ids = [];
2024-03-03 14:01:01 +00:00
foreach ( $message_files as $message_file ) {
// Get the contents of the JSON
$message = json_decode ( file_get_contents ( $message_file ), true );
2024-03-03 15:00:01 +00:00
// Get the ID
if ( isset ( $message [ " object " ][ " id " ] ) ) {
$id = $message [ " object " ][ " id " ];
} else if ( isset ( $message [ " id " ] ) ) {
$id = $message [ " id " ];
}
// Has this message ID already been seen?
// If so, it's an older update. Skip displaying it
if ( ! in_array ( $id , $messages_ids ) ) {
$messages_ids [] = $id ;
} else {
continue ;
}
// Sometimes messages are received out of order.
// This sorts them by their published time or, if there is none, the received time.
2024-03-03 14:01:01 +00:00
// Use the timestamp of the message. If there is none, use the date in the filename
if ( isset ( $message [ " published " ] ) ) {
$published = $message [ " published " ];
} else {
2024-03-04 11:49:13 +00:00
$segments = explode ( " / " , explode ( " - " , $message_file ? ? " " )[ 0 ]);
$published_hexstamp = end ( $segments );
2024-03-03 14:01:01 +00:00
$published_time = hexdec ( $published_hexstamp );
$published = date ( " c " , $published_time );
}
// Place in an array where the key is the timestamp
$messages_ordered [ $published ] = $message ;
}
// Sort with newest on top
krsort ( $messages_ordered );
// 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 $published => $message ) {
2024-03-04 15:43:21 +00:00
// Received messages have an `object` which contains their data.
// Sent messages *are* their data
if ( isset ( $message [ " object " ] ) ) {
$object = $message [ " object " ];
} else {
$object = $message ;
}
2024-03-03 21:57:19 +00:00
2024-03-04 15:43:21 +00:00
if ( isset ( $object [ " id " ] ) ) {
$id = $object [ " id " ];
2024-03-03 21:57:19 +00:00
$publishedHTML = " <a href= \" { $id } \" > { $published } </a> " ;
2024-03-03 14:01:01 +00:00
} else {
$id = " " ;
2024-03-03 21:57:19 +00:00
$publishedHTML = $published ;
2024-03-03 14:01:01 +00:00
}
2024-03-03 21:57:19 +00:00
// For displaying the post's information
$timeHTML = " <time datetime= \" { $published } \" class= \" u-url \" rel= \" bookmark \" > { $publishedHTML } </time> " ;
2024-03-03 14:39:12 +00:00
// Get the actor who authored the message
2024-03-04 15:43:21 +00:00
if ( isset ( $object [ " attributedTo " ] ) ) {
$actor = $object [ " attributedTo " ];
} else if ( isset ( $message [ " actor " ] ) ) {
// Usually found in Like and Announce messages
2024-03-03 14:39:12 +00:00
$actor = $message [ " actor " ];
} else {
// Should never happen!
$actor = " https://example.com/anonymous " ;
}
// Assume that what comes after the final `/` in the URl is the name
2024-03-04 15:43:21 +00:00
$actorArray = explode ( " / " , $actor );
$actorName = end ( $actorArray );
2024-03-03 18:14:08 +00:00
$actorServer = parse_url ( $actor , PHP_URL_HOST );
2024-03-04 09:00:09 +00:00
$actorUsername = " @ { $actorName } @ { $actorServer } " ;
2024-03-03 14:39:12 +00:00
// Make i18n usernames readable and safe.
$actorName = htmlspecialchars ( rawurldecode ( $actorName ) );
2024-03-03 14:01:01 +00:00
$actorHTML = " <a href= \" $actor\ " >@ { $actorName } </ a > " ;
2024-03-03 18:14:08 +00:00
// Buttons to interact with a message.
// By default, just shows a "Follow User" button.
if ( " read " == $style ) {
2024-03-05 20:41:32 +00:00
$interactHTML = " <a href= \" /users?account= $actorUsername\ " > ➕ </ a > " ;
2024-03-03 18:14:08 +00:00
} else {
$interactHTML = " " ;
}
2024-03-03 14:01:01 +00:00
// What type of message is this?
$type = $message [ " type " ];
// Render the message according to type
if ( " Create " == $type || " Update " == $type || " Note " == $type ) {
2024-03-03 21:57:19 +00:00
// Get the HTML content
2024-03-03 14:01:01 +00:00
// There is a slight difference between the formatting of sent and received messages
" Note " == $type ? $content = $message [ " content " ] : $content = $object [ " content " ];
2024-03-03 21:57:19 +00:00
// Sanitise the HTML
$content = strip_tags ( $content , $allowed_elements );
2024-03-03 14:01:01 +00:00
// Is this a reply to something?
if ( isset ( $object [ " inReplyTo " ] ) ) {
$replyToURl = $object [ " inReplyTo " ];
2024-03-03 18:14:08 +00:00
$replyTo = " in reply to <a href= \" { $replyToURl } \" > $replyToURl </a> " ;
2024-03-02 22:49:10 +00:00
} else {
2024-03-03 14:01:01 +00:00
$replyTo = " " ;
}
// Has the user has been specifically CC'd?
if ( isset ( $object [ " cc " ] ) ) {
$reply = in_array ( " https:// { $server } / { $username } " , $object [ " cc " ] );
} else {
$reply = false ;
}
// Is there is a Content Warning?
if ( isset ( $object [ " summary " ] ) ) {
$summary = $object [ " summary " ];
$summary = strip_tags ( $summary , $allowed_elements );
2024-03-03 21:57:19 +00:00
// Hide the content until the user interacts with it.
2024-03-03 14:01:01 +00:00
$content = " <details><summary> { $summary } </summary> { $content } </details> " ;
}
// Is there a poll?
// ActivityPub specification - https://www.w3.org/TR/activitystreams-vocabulary/#questions
// Mastodon documentation - https://docs.joinmastodon.org/spec/activitypub/#Question
if ( isset ( $object [ " oneOf " ] ) ) {
2024-03-03 15:53:58 +00:00
$content .= " <h3>Poll Results:</h3> " ;
2024-03-03 14:01:01 +00:00
foreach ( $object [ " oneOf " ] as $pollOption ) {
$pollOptionName = htmlspecialchars ( $pollOption [ " name " ] );
$pollOptionValue = htmlspecialchars ( $pollOption [ " replies " ][ " totalItems " ] );
2024-03-03 15:53:58 +00:00
$content .= " { $pollOptionName } : $pollOptionValue <br> " ;
2024-03-03 14:01:01 +00:00
}
}
if ( isset ( $object [ " anyOf " ] ) ) {
$content .= " <h3>Poll Results</h3> " ;
foreach ( $object [ " anyOf " ] as $pollOption ) {
$pollOptionName = htmlspecialchars ( $pollOption [ " name " ] );
$pollOptionValue = htmlspecialchars ( $pollOption [ " replies " ][ " totalItems " ] );
2024-03-03 15:53:58 +00:00
$content .= " { $pollOptionName } : $pollOptionValue <br> " ;
2024-03-03 14:01:01 +00:00
}
2024-03-02 22:49:10 +00:00
}
2024-03-03 14:01:01 +00:00
// Add any images
if ( isset ( $object [ " attachment " ] ) ) {
2024-03-04 15:43:21 +00:00
foreach ( $object [ " attachment " ] as $attachment ) {
2024-03-03 14:01:01 +00:00
// Only use things which have a MIME Type set
if ( isset ( $attachment [ " mediaType " ] ) ) {
2024-03-03 21:57:19 +00:00
$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 ];
2024-03-03 14:01:01 +00:00
if ( " image " == $mediaType ) {
// Get the alt text
isset ( $attachment [ " name " ] ) ? $alt = $attachment [ " name " ] : $alt = " " ;
2024-03-03 21:57:19 +00:00
$content .= " <img src=' { $mediaURl } ' alt=' { $alt } '> " ;
2024-03-03 14:01:01 +00:00
} else if ( " video " == $mediaType ) {
2024-03-03 21:57:19 +00:00
$content .= " <video controls><source src=' { $mediaURl } ' type=' { $mime } '></video> " ;
2024-03-03 14:01:01 +00:00
} else if ( " audio " == $mediaType ) {
2024-03-03 21:57:19 +00:00
$content .= " <audio controls src=' { $mediaURl } ' type=' { $mime } '></audio> " ;
2024-03-03 14:01:01 +00:00
}
}
}
}
// What sort of message is this?
switch ( $type ) {
case " Create " :
case " Note " :
$verb = " wrote " ;
break ;
case " Update " :
$verb = " updated " ;
break ;
default :
$verb = " said " ;
break ;
}
2024-03-03 18:14:08 +00:00
// Add some interaction buttons
$interactHTML .= " <a href= \" /write?reply= $id\ " > ↩️ </ a > " .
" <a href= \" /write?announce= $id\ " > 🔁 </ a > " .
" <a href= \" /write?like= $id\ " > ⚝ </ a > " ;
2024-03-03 14:01:01 +00:00
if ( $reply ) {
// Highlight that this is a reply
2024-03-03 18:14:08 +00:00
$messageHTML = " <mark> { $timeHTML } { $actorHTML } { $verb } { $replyTo } :</mark> <blockquote class= \" e-content \" > { $content } </blockquote> " ;
2024-03-03 14:01:01 +00:00
} else {
2024-03-03 18:14:08 +00:00
$messageHTML = " { $timeHTML } { $actorHTML } { $verb } { $replyTo } : <blockquote class= \" e-content \" > { $content } </blockquote> " ;
2024-03-03 14:01:01 +00:00
}
2024-03-04 15:43:21 +00:00
} else if ( " Follow " == $type ) {
$messageHTML = " <mark> { $timeHTML } { $actorHTML } followed you</mark> " ;
2024-03-03 14:01:01 +00:00
} else if ( " Like " == $type ) {
2024-03-04 15:43:21 +00:00
$object = $message [ " object " ];
2024-03-03 14:01:01 +00:00
$objectHTML = " <a href= \" $object\ " > { $object } </ a > " ;
2024-03-03 18:14:08 +00:00
$messageHTML = " { $timeHTML } { $actorHTML } liked { $objectHTML } " ;
2024-03-03 14:01:01 +00:00
} else if ( " Announce " == $type ) {
2024-03-04 15:43:21 +00:00
$object = $message [ " object " ];
2024-03-03 14:01:01 +00:00
$objectHTML = " <a href= \" $object\ " > { $object } </ a > " ;
2024-03-03 18:14:08 +00:00
$messageHTML = " { $timeHTML } { $actorHTML } boosted { $objectHTML } " ;
2024-02-26 14:48:51 +00:00
}
2024-03-03 18:14:08 +00:00
echo " <li><article class= \" h-entry \" > { $messageHTML } <br> { $interactHTML } </article></li> " ;
2024-03-03 14:01:01 +00:00
}
2024-02-26 14:48:51 +00:00
echo <<< HTML
2024-03-02 22:49:10 +00:00
</ ul >
2024-03-02 23:33:59 +00:00
</ main >
2024-02-18 14:29:06 +00:00
</ body >
</ html >
HTML ;
die ();
2024-02-18 14:10:01 +00:00
}
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 () {
2024-03-03 15:53:58 +00:00
if ( isset ( $_GET [ " announce " ] ) && filter_var ( $_GET [ " announce " ], FILTER_VALIDATE_URL ) ) {
$announceURl = $_GET [ " announce " ];
} else {
$announceURl = " " ;
}
if ( isset ( $_GET [ " like " ] ) && filter_var ( $_GET [ " like " ], FILTER_VALIDATE_URL ) ) {
$likeURl = $_GET [ " like " ];
} else {
$likeURl = " " ;
}
2024-03-03 18:14:08 +00:00
if ( isset ( $_GET [ " reply " ] ) && filter_var ( $_GET [ " reply " ], FILTER_VALIDATE_URL ) ) {
$replyURl = $_GET [ " reply " ];
} else {
$replyURl = " " ;
}
2024-02-12 21:21:05 +00:00
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 >
2024-03-03 11:27:52 +00:00
< fieldset >
< legend > Send a message </ legend >
2024-03-05 20:41:32 +00:00
< form action = " /action/send " method = " post " enctype = " multipart/form-data " >
2024-03-03 14:01:01 +00:00
< input type = " hidden " id = " type " name = " type " value = " Create " >
2024-03-03 11:27:52 +00:00
< label for = " content " > Your message :</ label >< br >
< textarea id = " content " name = " content " rows = " 5 " cols = " 32 " ></ textarea >< br >
< label for = " inReplyTo " > Reply to URl :</ label >
2024-03-03 18:14:08 +00:00
< input type = " url " name = " inReplyTo " id = " inReplyTo " size = " 32 " value = " { $replyURl } " >< br >
2024-03-03 11:27:52 +00:00
< label for = " image " > Attach an image </ label >< br >
< input type = " file " name = " image " id = " image " accept = " image/* " >< br >
< label for = " alt " > Alt Text </ label >
< input type = " text " name = " alt " id = " alt " size = " 32 " />< br >
< label for = " password " > Password </ label >< br >
< input type = " password " name = " password " id = " password " size = " 32 " >< br >< br >
< input type = " submit " value = " Post Message " >
</ form >
</ fieldset >
2024-03-03 14:01:01 +00:00
< fieldset >
< legend > Like a post </ legend >
< form action = " /send " method = " post " enctype = " multipart/form-data " >
< input type = " hidden " id = " type " name = " type " value = " Like " >
< label for = " postURl " > URl of post to like :</ label >
2024-03-03 15:53:58 +00:00
< input type = " url " name = " postURl " id = " postURl " size = " 32 " value = " { $likeURl } " >< br >
2024-03-03 14:01:01 +00:00
< label for = " password " > Password </ label >< br >
< input type = " password " name = " password " id = " password " size = " 32 " >< br >< br >
< input type = " submit " value = " Like the message " >
</ form >
</ fieldset >
< fieldset >
< legend > Boost a post </ legend >
< form action = " /send " method = " post " enctype = " multipart/form-data " >
< input type = " hidden " id = " type " name = " type " value = " Announce " >
< label for = " postURl " > URl of post to boost :</ label >
2024-03-03 15:53:58 +00:00
< input type = " url " name = " postURl " id = " postURl " size = " 32 " value = " { $announceURl } " >< br >
2024-03-03 14:01:01 +00:00
< label for = " password " > Password </ label >< br >
< input type = " password " name = " password " id = " password " size = " 32 " >< br >< br >
< input type = " submit " value = " Boost the Message " >
</ form >
</ fieldset >
2024-02-12 21:21:05 +00:00
</ body >
</ html >
HTML ;
die ();
2024-03-02 22:49:10 +00:00
}
2024-02-12 21:21:05 +00:00
2024-02-15 09:29:21 +00:00
// Send Endpoint:
// This takes the submitted message and checks the password is correct.
2024-03-01 23:26:10 +00:00
// It reads all the followers' data in `data/followers`.
// It constructs a list of shared inboxes and unique inboxes.
2024-02-23 15:08:25 +00:00
// It sends the message to every server that is following this account.
2024-02-12 21:21:05 +00:00
function send () {
2024-02-29 17:26:10 +00:00
global $password , $server , $username , $key_private , $directories ;
2024-02-12 21:21:05 +00:00
// Does the posted password match the stored password?
if ( $password != $_POST [ " password " ] ) { die (); }
2024-03-03 14:01:01 +00:00
// What sort of message is being sent?
$type = $_POST [ " type " ];
2024-02-12 21:21:05 +00:00
2024-03-03 14:01:01 +00:00
// Likes and Announces have an identical message structure
2024-03-03 21:57:19 +00:00
if ( " Like " == $type || " Announce " == $type ) {
2024-03-03 14:01:01 +00:00
// Was a URl sent?
if ( isset ( $_POST [ " postURl " ] ) && filter_var ( $_POST [ " postURl " ], FILTER_VALIDATE_URL ) ) {
$postURl = $_POST [ " postURl " ];
} else {
echo " No valid URl sent. " ;
die ();
}
2024-03-03 11:27:52 +00:00
2024-03-03 21:57:19 +00:00
if ( " Like " == $type ) {
// The message will need to be sent to the inbox of the author of the message
$inbox_single = getInboxFromMessageURl ( $postURl );
}
2024-03-03 14:01:01 +00:00
// Outgoing Message ID
$guid = uuid ();
2024-02-16 16:09:05 +00:00
2024-03-03 14:01:01 +00:00
// Construct the Message
$message = [
" @context " => " https://www.w3.org/ns/activitystreams " ,
" id " => " https:// { $server } /posts/ { $guid } .json " ,
" type " => $type ,
" actor " => " https:// { $server } / { $username } " ,
" published " => date ( " c " ),
" object " => $postURl
];
2024-03-03 21:57:19 +00:00
// Announces are sent to an audience
2024-03-03 18:14:08 +00:00
// The audience is public and it is sent to all followers
2024-03-03 21:57:19 +00:00
// TODO: Let the original poster know we boosted them
if ( $type == " Announce " ) {
2024-03-03 18:14:08 +00:00
$message = array_merge ( $message ,
2024-03-03 21:57:19 +00:00
array (
" to " => [ " https://www.w3.org/ns/activitystreams#Public " ],
" cc " => [ " https:// { $server } /followers " ])
2024-03-03 18:14:08 +00:00
);
}
2024-03-03 14:01:01 +00:00
// Construct the Note
// This is for saving in the logs
$note = $message ;
} else if ( $type == " Create " ) {
// Get the posted content
$content = $_POST [ " content " ];
// Is this a reply?
if ( isset ( $_POST [ " inReplyTo " ] ) && filter_var ( $_POST [ " inReplyTo " ], FILTER_VALIDATE_URL ) ) {
$inReplyTo = $_POST [ " inReplyTo " ];
} else {
$inReplyTo = null ;
}
2024-02-18 15:48:33 +00:00
2024-03-03 14:01:01 +00:00
// Process the content into HTML to get hashtags etc
list ( " HTML " => $content , " TagArray " => $tags ) = process_content ( $content );
2024-02-18 15:48:33 +00:00
2024-03-03 14:01:01 +00:00
// Is there an image attached?
2024-03-03 21:57:19 +00:00
if ( isset ( $_FILES [ 'image' ][ 'tmp_name' ] ) && ( " " != $_FILES [ 'image' ][ 'tmp_name' ] ) ) {
2024-03-03 14:01:01 +00:00
// 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 " ];
2024-02-18 15:48:33 +00:00
2024-03-03 14:01:01 +00:00
// Files are stored according to their hash
// A hash of "abc123" is stored in "/images/abc123.jpg"
$sha1 = sha1_file ( $image );
$image_full_path = $directories [ " images " ] . " / { $sha1 } . { $image_ext } " ;
2024-02-18 15:48:33 +00:00
2024-03-03 14:01:01 +00:00
// Move media to the correct location
move_uploaded_file ( $image , $image_full_path );
2024-02-18 15:48:33 +00:00
2024-03-03 14:01:01 +00:00
// Get the alt text
if ( isset ( $_POST [ " alt " ] ) ) {
$alt = $_POST [ " alt " ];
} else {
$alt = " " ;
}
2024-02-18 15:48:33 +00:00
2024-03-03 14:01:01 +00:00
// Construct the attachment value for the post
2024-03-04 15:43:21 +00:00
$attachment = array ( [
2024-03-03 14:01:01 +00:00
" type " => " Image " ,
" mediaType " => " { $image_mime } " ,
" url " => " https:// { $server } / { $image_full_path } " ,
" name " => $alt
2024-03-04 15:43:21 +00:00
] );
2024-03-03 14:01:01 +00:00
} else {
$attachment = [];
}
2024-02-12 21:21:05 +00:00
2024-03-03 14:01:01 +00:00
// 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
// hardcoded to English
$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 ,
" contentMap " => [ " en " => $content ],
" to " => [ " https://www.w3.org/ns/activitystreams#Public " ],
" 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 } " ,
" to " => [
" https://www.w3.org/ns/activitystreams#Public "
],
" cc " => [
" https:// { $server } /followers "
],
" object " => $note
];
}
2024-02-12 21:21:05 +00:00
// Save the permalink
$note_json = json_encode ( $note );
2024-02-29 17:26:10 +00:00
file_put_contents ( $directories [ " posts " ] . " / { $guid } .json " , print_r ( $note_json , true ) );
2024-02-12 21:21:05 +00:00
2024-03-03 21:57:19 +00:00
// Is this message going to one user? (Usually a Like)
if ( isset ( $inbox_single ) ) {
$messageSent = sentMessageToSingle ( $inbox_single , $message );
} else { // Send to all the user's followers
$messageSent = sendMessageToFollowers ( $message );
}
// Render the JSON so the user can see the POST has worked
if ( $messageSent ) {
header ( " Location: https:// { $server } /posts/ { $guid } .json " );
die ();
} else {
echo " ERROR! " ;
die ();
}
}
// POST a signed message to a single inbox
function sentMessageToSingle ( $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 );
2024-03-04 13:16:39 +00:00
curl_setopt ( $ch , CURLOPT_USERAGENT , USERAGENT );
2024-03-03 21:57:19 +00:00
curl_exec ( $ch );
// Check for errors
if ( curl_errno ( $ch ) ) {
$timestamp = ( new DateTime () ) -> format ( DATE_RFC3339_EXTENDED );
file_put_contents ( $directories [ " logs " ] . " / { $timestamp } .Error.txt " , curl_error ( $ch ) );
return false ;
}
2024-03-04 11:49:13 +00:00
curl_close ( $ch );
2024-03-03 21:57:19 +00:00
return true ;
}
// POST a signed message to the inboxes of all followers
function sendMessageToFollowers ( $message ) {
global $directories ;
2024-02-23 15:08:25 +00:00
// Read existing followers
2024-03-01 23:26:10 +00:00
$followers = glob ( $directories [ " followers " ] . " /*.json " );
2024-02-23 15:08:25 +00:00
// Get all the inboxes
$inboxes = [];
foreach ( $followers as $follower ) {
2024-03-01 23:26:10 +00:00
// Get the data about the follower
2024-02-23 15:08:25 +00:00
$follower_info = json_decode ( file_get_contents ( $follower ), true );
2024-02-26 19:56:55 +00:00
// Some servers have "Shared inboxes"
2024-03-01 23:26:10 +00:00
// If you have lots of followers on a single server, you only need to send the message once.
2024-02-23 15:08:25 +00:00
if ( isset ( $follower_info [ " endpoints " ][ " sharedInbox " ] ) ) {
$sharedInbox = $follower_info [ " endpoints " ][ " sharedInbox " ];
if ( ! in_array ( $sharedInbox , $inboxes ) ) {
$inboxes [] = $sharedInbox ;
}
} else {
// If not, use the individual inbox
$inbox = $follower_info [ " inbox " ];
if ( ! in_array ( $inbox , $inboxes ) ) {
$inboxes [] = $inbox ;
}
}
}
2024-02-12 21:21:05 +00:00
// Prepare to use the multiple cURL handle
2024-02-26 19:56:55 +00:00
// This makes it more efficient to send many simultaneous messages
2024-02-12 21:21:05 +00:00
$mh = curl_multi_init ();
2024-02-23 15:08:25 +00:00
// Loop through all the inboxes of the followers
2024-02-12 21:21:05 +00:00
// Each server needs its own cURL handle
// Each POST to an inbox needs to be signed separately
2024-02-23 15:08:25 +00:00
foreach ( $inboxes as $inbox ) {
$inbox_host = parse_url ( $inbox , PHP_URL_HOST );
$inbox_path = parse_url ( $inbox , PHP_URL_PATH );
2024-03-01 23:26:10 +00:00
// Generate the signed headers
2024-02-23 15:08:25 +00:00
$headers = generate_signed_headers ( $message , $inbox_host , $inbox_path , " POST " );
2024-02-12 21:21:05 +00:00
// POST the message and header to the requester's inbox
2024-02-23 15:08:25 +00:00
$ch = curl_init ( $inbox );
2024-02-12 21:21:05 +00:00
curl_setopt ( $ch , CURLOPT_RETURNTRANSFER , true );
curl_setopt ( $ch , CURLOPT_CUSTOMREQUEST , " POST " );
2024-03-04 11:49:13 +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-03-04 13:16:39 +00:00
curl_setopt ( $ch , CURLOPT_USERAGENT , USERAGENT );
2024-02-12 21:21:05 +00:00
// 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 );
2024-03-03 21:57:19 +00:00
return true ;
2024-02-12 21:21:05 +00:00
}
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
2024-03-01 23:26:10 +00:00
$link_pattern = '/\bhttps?:\/\/\S+/iu' ; // Sloppy regex
2024-02-17 09:30:50 +00:00
$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 );
2024-03-04 11:49:13 +00:00
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
2024-03-01 23:26:10 +00:00
// Todo: Make these links do something.
2024-02-16 16:09:05 +00:00
$content = preg_replace (
2024-02-17 20:48:03 +00:00
$hashtag_pattern ,
2024-02-17 21:00:36 +00:00
" <a href='https:// { $server } /tag/ $ 1'># $ 1</a> " ,
2024-02-16 16:09:05 +00:00
$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
2024-03-03 21:57:19 +00:00
// TODO: Add this to the CC field & appropriate inbox
2024-02-17 20:48:03 +00:00
foreach ( $usernames as $username ) {
2024-02-29 14:46:27 +00:00
list ( , $user , $domain ) = explode ( " @ " , $username );
2024-02-17 20:48:03 +00:00
$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-20 12:25:13 +00:00
// Construct HTML breaks from carriage returns and line breaks
2024-03-01 23:26:10 +00:00
$linebreak_patterns = array ( " \r \n " , " \r " , " \n " ); // Variations of line breaks found in raw text
$content = str_replace ( $linebreak_patterns , " <br/> " , $content );
2024-02-20 12:25:13 +00:00
2024-02-16 16:09:05 +00:00
// Construct the content
$content = " <p> { $content } </p> " ;
2024-02-17 20:48:03 +00:00
return [
2024-03-03 21:57:19 +00:00
" HTML " => $content ,
2024-02-17 20:48:03 +00:00
" TagArray " => $tags
];
2024-02-16 16:09:05 +00:00
}
2024-03-03 21:57:19 +00:00
// When given the URl of a post, this looks up the post, finds the user, then returns their inbox or shared inbox
function getInboxFromMessageURl ( $url ) {
// Get details about the message
$messageData = getDataFromURl ( $url );
// The author is the user who the message is attributed to
if ( isset ( $messageData [ " attributedTo " ] ) && filter_var ( $messageData [ " attributedTo " ], FILTER_VALIDATE_URL ) ) {
$profileData = getDataFromURl ( $messageData [ " attributedTo " ] );
} else {
return null ;
}
// Get the shared inbox or personal inbox
if ( isset ( $profileData [ " endpoints " ][ " sharedInbox " ] ) ) {
$inbox = $profileData [ " endpoints " ][ " sharedInbox " ];
} else {
// If not, use the individual inbox
$inbox = $profileData [ " inbox " ];
}
// Return the destination inbox if it is valid
if ( filter_var ( $inbox , FILTER_VALIDATE_URL ) ) {
return $inbox ;
} else {
return null ;
}
}
// GET a request to a URl and returns structured data
function getDataFromURl ( $url ) {
2024-03-04 13:16:39 +00:00
// Check this is a valid https address
if (
( filter_var ( $url , FILTER_VALIDATE_URL ) != true ) ||
( parse_url ( $url , PHP_URL_SCHEME ) != " https " )
) { die (); }
2024-03-04 15:43:21 +00:00
2024-03-03 21:57:19 +00:00
// Split the URL
$url_host = parse_url ( $url , PHP_URL_HOST );
$url_path = parse_url ( $url , PHP_URL_PATH );
// Generate signed headers for this request
$headers = generate_signed_headers ( null , $url_host , $url_path , " GET " );
// Set cURL options
$ch = curl_init ( $url );
2024-03-04 09:31:52 +00:00
curl_setopt ( $ch , CURLOPT_RETURNTRANSFER , true );
2024-03-03 21:57:19 +00:00
curl_setopt ( $ch , CURLOPT_HTTPHEADER , $headers );
2024-03-04 13:16:39 +00:00
curl_setopt ( $ch , CURLOPT_USERAGENT , USERAGENT );
2024-03-03 21:57:19 +00:00
// Execute the cURL session
$urlJSON = curl_exec ( $ch );
// Check for errors
if ( curl_errno ( $ch )) {
// Handle cURL error
die ();
}
// Close cURL session
curl_close ( $ch );
return json_decode ( $urlJSON , true );
}
2024-02-18 14:44:57 +00:00
// The Outbox contains a date-ordered list (newest first) of all the user's posts
// This is optional.
function outbox () {
2024-03-01 10:38:18 +00:00
global $server , $username , $directories ;
2024-02-18 14:44:57 +00:00
// Get all posts
2024-03-01 10:38:18 +00:00
$posts = array_reverse ( glob ( $directories [ " posts " ] . " /*.json " ) );
2024-02-18 14:44:57 +00:00
// Number of posts
$totalItems = count ( $posts );
// Create an ordered list
$orderedItems = [];
2024-03-04 11:49:13 +00:00
foreach ( $posts as $post ) {
2024-02-18 14:44:57 +00:00
$orderedItems [] = array (
" type " => " Create " ,
" actor " => " https:// { $server } / { $username } " ,
" object " => " https:// { $server } / { $post } "
);
}
// Create User's outbox
$outbox = array (
" @context " => " https://www.w3.org/ns/activitystreams " ,
" id " => " https:// { $server } /outbox " ,
" type " => " OrderedCollection " ,
" totalItems " => $totalItems ,
" summary " => " All the user's posts " ,
" orderedItems " => $orderedItems
);
// Render the page
header ( " Content-Type: application/activity+json " );
echo json_encode ( $outbox );
die ();
}
2024-02-29 14:46:27 +00:00
// This creates a UI for the user to follow another user
2024-03-05 20:41:32 +00:00
function users () {
2024-03-03 18:14:08 +00:00
if ( isset ( $_GET [ " account " ] ) ) {
$accountURl = htmlspecialchars ( $_GET [ " account " ] );
} else {
2024-03-05 20:41:32 +00:00
$accountURl = " " ;
2024-03-03 18:14:08 +00:00
}
2024-02-29 14:46:27 +00:00
echo <<< HTML
<! DOCTYPE html >
< html lang = " en-GB " >
< head >
< meta charset = " UTF-8 " >
< title > Follow </ title >
< style >
* { font - family : sans - serif ; font - size : 1.1 em ;}
</ style >
</ head >
< body >
2024-03-05 20:41:32 +00:00
< form action = " /action/users " method = " post " enctype = " multipart/form-data " >
< label for = " user " > User :</ label >
2024-03-03 18:14:08 +00:00
< input name = " user " id = " user " type = " text " size = " 32 " placeholder = " @user@example.com " value = " { $accountURl } " >< br >
2024-03-05 20:41:32 +00:00
< label for = " action " > action </ label >< br >
< select name = " action " id = " action " >
< option value = " " >-- Please choose an option --</ option >
< option value = " Follow " > Follow </ option >
< option value = " Unfollow " > Unfollow </ option >
< option value = " Block " > Block </ option >
< option value = " Unblock " > Unblock </ option >
</ select >< br >
2024-02-29 14:46:27 +00:00
< label for = " password " > Password </ label >< br >
< input name = " password " id = " password " type = " password " size = " 32 " >< br >
2024-03-05 20:41:32 +00:00
< input type = " submit " value = " Send User Request " >
2024-02-29 14:46:27 +00:00
</ form >
</ body >
</ html >
HTML ;
die ();
}
// This receives a request to follow an external user
// It looks up the external user's details
// Then it sends a follow request
// If the request is accepted, it saves the details in `data/following/` as a JSON file
2024-03-05 20:41:32 +00:00
function action_users () {
2024-02-29 17:26:10 +00:00
global $password , $server , $username , $key_private , $directories ;
2024-02-29 14:46:27 +00:00
// Does the posted password match the stored password?
if ( $password != $_POST [ " password " ] ) { echo " Wrong Password! " ; die (); }
// Get the posted content
2024-03-05 20:41:32 +00:00
$user = $_POST [ " user " ];
$action = $_POST [ " action " ];
// Is this a valid action?
if ( match ( $action ) {
" Follow " , " Unfollow " , " Block " , " Unblock " => false ,
default => true ,
} ) {
// Discard it, no further processing.
echo " { $action } not supported " ;
die ();
}
2024-02-29 14:46:27 +00:00
2024-03-01 23:26:10 +00:00
// Split the user (@user@example.com) into username and server
2024-02-29 14:46:27 +00:00
list ( , $follow_name , $follow_server ) = explode ( " @ " , $user );
// Get the Webfinger
2024-03-04 11:46:41 +00:00
// This request does not always need to be signed, but safest to do so anyway.
2024-02-29 14:46:27 +00:00
$webfingerURl = " https:// { $follow_server } /.well-known/webfinger?resource=acct: { $follow_name } @ { $follow_server } " ;
2024-03-04 11:46:41 +00:00
$webfinger = getDataFromURl ( $webfingerURl );
2024-02-29 14:46:27 +00:00
2024-03-01 23:26:10 +00:00
// Get the link to the user
2024-02-29 14:46:27 +00:00
foreach ( $webfinger [ " links " ] as $link ) {
if ( " self " == $link [ " rel " ] ) {
$profileURl = $link [ " href " ];
}
}
2024-03-04 11:46:41 +00:00
if ( ! isset ( $profileURl ) ) { echo " No profile " . print_r ( $webfinger , true ); die (); }
2024-02-29 14:46:27 +00:00
// Get the user's details
2024-03-03 21:57:19 +00:00
$profileData = getDataFromURl ( $profileURl );
2024-02-29 14:46:27 +00:00
// Get the user's inbox
$profileInbox = $profileData [ " inbox " ];
2024-03-05 20:41:32 +00:00
// Create a user request
2024-02-29 14:46:27 +00:00
$guid = uuid ();
2024-03-05 20:41:32 +00:00
// Different user actions have subtly different messages to send.
if ( " Follow " == $action ) {
$message = [
" @context " => " https://www.w3.org/ns/activitystreams " ,
" id " => " https:// { $server } / { $guid } " ,
" type " => " Follow " ,
" actor " => " https:// { $server } / { $username } " ,
" object " => $profileURl
];
} else if ( " Unfollow " == $action ) {
$message = [
" @context " => " https://www.w3.org/ns/activitystreams " ,
" id " => " https:// { $server } / { $guid } " ,
" type " => " Undo " ,
" actor " => " https:// { $server } / { $username } " ,
" object " => array (
//"id" => null, // Should be the original ID if possible, but not necessary https://www.w3.org/wiki/ActivityPub/Primer/Referring_to_activities
" type " => " Follow " ,
" actor " => " https:// { $server } / { $username } " ,
" object " => $profileURl
)
];
} else if ( " Block " == $action ) {
$message = [
" @context " => " https://www.w3.org/ns/activitystreams " ,
" id " => " https:// { $server } / { $guid } " ,
" type " => " Block " ,
" actor " => " https:// { $server } / { $username } " ,
" object " => $profileURl ,
" to " => $profileURl
];
} else if ( " Unblock " == $action ) {
$message = [
" @context " => " https://www.w3.org/ns/activitystreams " ,
" id " => " https:// { $server } / { $guid } " ,
" type " => " Undo " ,
" actor " => " https:// { $server } / { $username } " ,
" object " => array (
//"id" => null, // Should be the original ID if possible, but not necessary https://www.w3.org/wiki/ActivityPub/Primer/Referring_to_activities
" type " => " Block " ,
" actor " => " https:// { $server } / { $username } " ,
" object " => $profileURl
)
];
}
2024-02-29 14:46:27 +00:00
2024-03-05 20:41:32 +00:00
// Sign & send the request
sentMessageToSingle ( $profileInbox , $message );
2024-02-29 14:46:27 +00:00
2024-03-05 20:41:32 +00:00
if ( " Follow " == $action ) {
// Save the user's details
$following_filename = urlencode ( $profileURl );
file_put_contents ( $directories [ " following " ] . " / { $following_filename } .json " , json_encode ( $profileData ) );
// Render the JSON so the user can see the POST has worked
header ( " Location: https:// { $server } /data/following/ " . urlencode ( $following_filename ) . " .json " );
} else if ( " Block " == $action || " Unfollow " == $action ) {
// Delete the user if they exist in the following directory.
$following_filename = urlencode ( $profileURl );
unlink ( $directories [ " following " ] . " / { $following_filename } .json " );
// Let the user know it worked
echo " { $user } { $action } ed! " ;
} else if ( " Unblock " == $action ) {
// Let the user know it worked
echo " { $user } { $action } ed! " ;
}
2024-02-29 14:46:27 +00:00
die ();
}
2024-02-25 14:42:50 +00:00
// Verify the signature sent with the message.
// This is optional
// It is very confusing
function verifyHTTPSignature () {
2024-02-29 17:26:10 +00:00
global $input , $body , $server , $directories ;
2024-02-25 14:42:50 +00:00
// Get the headers send with the request
$headers = getallheaders ();
2024-02-26 16:15:04 +00:00
// Ensure the header keys match the format expected by the signature
$headers = array_change_key_case ( $headers , CASE_LOWER );
2024-02-25 14:42:50 +00:00
// Validate the timestamp is within ±30 seconds
2024-02-26 16:15:04 +00:00
if ( ! isset ( $headers [ " date " ] ) ) { return null ; } // No date set
$dateHeader = $headers [ " date " ];
2024-02-25 14:42:50 +00:00
$headerDatetime = DateTime :: createFromFormat ( 'D, d M Y H:i:s T' , $dateHeader );
$currentDatetime = new DateTime ();
// Calculate the time difference in seconds
2024-03-04 11:49:13 +00:00
$timeDifference = abs ( $currentDatetime -> getTimestamp () - $headerDatetime -> getTimestamp () );
if ( $timeDifference > 30 ) {
2024-02-25 14:42:50 +00:00
// Write a log detailing the error
$timestamp = ( new DateTime () ) -> format ( DATE_RFC3339_EXTENDED );
// Filename for the log
2024-02-29 17:26:10 +00:00
$filename = " { $timestamp } .Signature.Time_Failure.txt " ;
2024-02-25 14:42:50 +00:00
// Save headers and request data to the timestamped file in the logs directory
2024-02-29 17:26:10 +00:00
file_put_contents ( $directories [ " logs " ] . " / { $filename } " ,
2024-02-25 14:42:50 +00:00
" Original Date: \n " . print_r ( $dateHeader , true ) . " \n " .
" Local Date: \n " . print_r ( $currentDatetime -> format ( 'D, d M Y H:i:s T' ), true ) . " \n "
);
return false ;
}
// Validate the Digest
2024-03-01 23:26:10 +00:00
// It is the hash of the raw input string, in binary, encoded as base64.
2024-02-26 16:15:04 +00:00
$digestString = $headers [ " digest " ];
2024-03-01 23:26:10 +00:00
2024-02-25 14:42:50 +00:00
// Usually in the form `SHA-256=Ofv56Jm9rlowLR9zTkfeMGLUG1JYQZj0up3aRPZgT0c=`
// The Base64 encoding may have multiple `=` at the end. So split this at the first `=`
$digestData = explode ( " = " , $digestString , 2 );
$digestAlgorithm = $digestData [ 0 ];
$digestHash = $digestData [ 1 ];
// There might be many different hashing algorithms
// TODO: Find a way to transform these automatically
2024-03-04 11:46:41 +00:00
// See https://github.com/superseriousbusiness/gotosocial/issues/1186#issuecomment-1976166659 and https://github.com/snarfed/bridgy-fed/issues/430 for hs2019
if ( " SHA-256 " == $digestAlgorithm || " hs2019 " == $digestAlgorithm ) {
2024-02-25 14:42:50 +00:00
$digestAlgorithm = " sha256 " ;
} else if ( " SHA-512 " == $digestAlgorithm ) {
$digestAlgorithm = " sha512 " ;
}
// Manually calculate the digest based on the data sent
$digestCalculated = base64_encode ( hash ( $digestAlgorithm , $input , true ) );
// Does our calculation match what was sent?
if ( ! ( $digestCalculated == $digestHash ) ) {
// Write a log detailing the error
$timestamp = ( new DateTime () ) -> format ( DATE_RFC3339_EXTENDED );
// Filename for the log
2024-02-29 17:26:10 +00:00
$filename = " { $timestamp } .Signature.Digest_Failure.txt " ;
2024-02-25 14:42:50 +00:00
// Save headers and request data to the timestamped file in the logs directory
2024-02-29 17:26:10 +00:00
file_put_contents ( $directories [ " logs " ] . " / { $filename } " ,
2024-02-25 14:42:50 +00:00
" Original Input: \n " . print_r ( $input , true ) . " \n " .
" Original Digest: \n " . print_r ( $digestString , true ) . " \n " .
" Calculated Digest: \n " . print_r ( $digestCalculated , true ) . " \n "
);
return false ;
}
// Examine the signature
2024-02-26 16:15:04 +00:00
$signatureHeader = $headers [ " signature " ];
2024-02-25 14:42:50 +00:00
// Extract key information from the Signature header
$signatureParts = [];
// Converts 'a=b,c=d e f' into ["a"=>"b", "c"=>"d e f"]
2024-02-26 19:56:55 +00:00
// word="text"
2024-02-25 14:42:50 +00:00
preg_match_all ( '/(\w+)="([^"]+)"/' , $signatureHeader , $matches );
2024-03-04 11:49:13 +00:00
foreach ( $matches [ 1 ] as $index => $key ) {
2024-02-25 14:42:50 +00:00
$signatureParts [ $key ] = $matches [ 2 ][ $index ];
}
// Manually reconstruct the header string
$signatureHeaders = explode ( " " , $signatureParts [ " headers " ] );
$signatureString = " " ;
2024-03-04 11:49:13 +00:00
foreach ( $signatureHeaders as $signatureHeader ) {
2024-02-25 14:42:50 +00:00
if ( " (request-target) " == $signatureHeader ) {
$method = strtolower ( $_SERVER [ " REQUEST_METHOD " ] );
2024-02-26 16:15:04 +00:00
$target = $_SERVER [ " REQUEST_URI " ];
2024-02-25 14:42:50 +00:00
$signatureString .= " (request-target): { $method } { $target } \n " ;
} else if ( " host " == $signatureHeader ) {
$host = strtolower ( $_SERVER [ " HTTP_HOST " ] );
$signatureString .= " host: { $host } \n " ;
} else {
2024-02-26 16:15:04 +00:00
$signatureString .= " { $signatureHeader } : " . $headers [ $signatureHeader ] . " \n " ;
2024-02-25 14:42:50 +00:00
}
}
// Remove trailing newline
$signatureString = trim ( $signatureString );
// Get the Public Key
2024-02-26 19:56:55 +00:00
// The link to the key might be sent with the body, but is always sent in the Signature header.
2024-02-25 14:42:50 +00:00
$publicKeyURL = $signatureParts [ " keyId " ];
// This is usually in the form `https://example.com/user/username#main-key`
// This is to differentiate if the user has multiple keys
// TODO: Check the actual key
2024-03-01 22:40:39 +00:00
2024-03-03 21:57:19 +00:00
$userData = getDataFromURl ( $publicKeyURL );
2024-02-25 14:42:50 +00:00
$publicKey = $userData [ " publicKey " ][ " publicKeyPem " ];
// Get the remaining parts
$signature = base64_decode ( $signatureParts [ " signature " ] );
$algorithm = $signatureParts [ " algorithm " ];
2024-03-05 17:14:34 +00:00
// There might be many different signing algorithms
// TODO: Find a way to transform these automatically
// See https://github.com/superseriousbusiness/gotosocial/issues/1186#issuecomment-1976166659 and https://github.com/snarfed/bridgy-fed/issues/430 for hs2019
if ( " hs2019 " == $algorithm ) {
$algorithm = " sha256 " ;
}
2024-02-25 14:42:50 +00:00
// Finally! Calculate whether the signature is valid
// Returns 1 if verified, 0 if not, false or -1 if an error occurred
$verified = openssl_verify (
$signatureString ,
$signature ,
$publicKey ,
$algorithm
);
// Convert to boolean
2024-03-04 11:49:13 +00:00
if ( $verified === 1 ) {
2024-02-25 14:42:50 +00:00
$verified = true ;
2024-03-04 11:49:13 +00:00
} elseif ( $verified === 0 ) {
2024-02-25 14:42:50 +00:00
$verified = false ;
} else {
$verified = null ;
}
// Write a log detailing the signature verification process
$timestamp = ( new DateTime () ) -> format ( DATE_RFC3339_EXTENDED );
// Filename for the log
2024-02-29 17:26:10 +00:00
$filename = " { $timestamp } .Signature. " . json_encode ( $verified ) . " .txt " ;
2024-02-25 14:42:50 +00:00
// Save headers and request data to the timestamped file in the logs directory
2024-02-29 17:26:10 +00:00
file_put_contents ( $directories [ " logs " ] . " / { $filename } " ,
2024-02-26 19:56:55 +00:00
" Original Body: \n " . print_r ( $body , true ) . " \n \n " .
" Original Headers: \n " . print_r ( $headers , true ) . " \n \n " .
2024-02-25 14:42:50 +00:00
" Signature Headers: \n " . print_r ( $signatureHeaders , true ) . " \n \n " .
2024-02-26 19:56:55 +00:00
" Calculated signatureString: \n " . print_r ( $signatureString , true ) . " \n \n " .
" Calculated algorithm: \n " . print_r ( $algorithm , true ) . " \n \n " .
" publicKeyURL: \n " . print_r ( $publicKeyURL , true ) . " \n \n " .
" publicKey: \n " . print_r ( $publicKey , true ) . " \n "
2024-02-25 14:42:50 +00:00
);
return $verified ;
}
2024-03-02 21:35:32 +00:00
// The NodeInfo Protocol is used to identify servers.
// It is looked up with `example.com/.well-known/nodeinfo`
// See https://nodeinfo.diaspora.software/
function wk_nodeinfo () {
global $server ;
$nodeinfo = array (
" links " => array (
array (
" rel " => " self " ,
" type " => " http://nodeinfo.diaspora.software/ns/schema/2.1 " ,
" href " => " https:// { $server } /nodeinfo/2.1 "
)
)
);
header ( " Content-Type: application/json " );
echo json_encode ( $nodeinfo );
die ();
}
// The NodeInfo Protocol is used to identify servers.
// It is looked up with `example.com/.well-known/nodeinfo` which points to this resource
// See http://nodeinfo.diaspora.software/docson/index.html#/ns/schema/2.0#$$expand
function nodeinfo () {
global $server , $directories ;
// Get all posts
$posts = glob ( $directories [ " posts " ] . " /*.json " ) ;
// Number of posts
$totalItems = count ( $posts );
$nodeinfo = array (
" version " => " 2.1 " , // Version of the schema, not the software
" software " => array (
" name " => " Single File ActivityPub Server in PHP " ,
" version " => " 0.000000001 " ,
" repository " => " https://gitlab.com/edent/activitypub-single-php-file/ "
),
" protocols " => array ( " activitypub " ),
" services " => array (
" inbound " => array (),
" outbound " => array ()
),
" openRegistrations " => false ,
" usage " => array (
" users " => array (
" total " => 1
),
" localPosts " => $totalItems
),
" metadata " => array (
" nodeName " => " activitypub-single-php-file " ,
" nodeDescription " => " This is a single PHP file which acts as an extremely basic ActivityPub server. " ,
" spdx " => " AGPL-3.0-or-later "
)
);
header ( " Content-Type: application/json " );
echo json_encode ( $nodeinfo );
die ();
}
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 ();