activitypub-single-php-file/index.php

1331 wiersze
45 KiB
PHP
Czysty Zwykły widok Historia

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
// 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! **/
// Internal data
$server = $_SERVER["SERVER_NAME"]; // Do not change this!
2024-03-01 23:26:10 +00:00
// Set up where logs and messages go.
// If you want to, you can change these directories to something more suitable for you.
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
);
foreach ( $directories as $directory ) {
if( !is_dir( $directory ) ) { mkdir( $data ); mkdir( $directory ); }
}
2024-02-15 09:29:21 +00:00
// Logging:
2024-03-01 23:26:10 +00:00
// ActivityPub is a "chatty" protocol.
// This takes all the requests your server receives and saves them as a datestamped text file.
2024-02-15 09:29:21 +00:00
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 );
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-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-26 19:56:55 +00:00
!empty( $_GET["path"] ) ? $path = $_GET["path"] : $path = "/";
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"] ) ) {
2024-02-26 19:56:55 +00:00
// Sanitise before using it in a filename
2024-03-01 23:26:10 +00:00
$type = urlencode( $body["type"] );
2024-02-12 21:21:05 +00:00
} else {
2024-02-26 19:56:55 +00:00
// Sanitise the path requested
2024-03-01 23:26:10 +00:00
$type = urlencode( $path );
2024-02-12 21:21:05 +00:00
}
2024-03-01 23:26:10 +00:00
// Create a timestamp for the filename.
// This format has milliseconds, so should avoid logs being overwritten.
// If you have > 1000 requests per second, please use a different server.
$timestamp = ( new DateTime() )->format( DATE_RFC3339_EXTENDED );
2024-03-01 23:26:10 +00:00
// Alternatively, use a UUID. These retain the date as hex encoded UNIX seconds.
// This means the log files will never clash - but it does make the filenames harder to read.
// $timestamp = uuid();
2024-02-12 21:21:05 +00:00
// Filename for the log
2024-03-01 23:26:10 +00:00
$filename = "{$timestamp}.{$type}.txt";
2024-02-12 21:21:05 +00:00
2024-02-13 13:30:36 +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-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-12 21:21:05 +00:00
switch ($path) {
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-02-18 14:29:06 +00:00
write(); // User interface for writing posts
2024-02-26 19:56:55 +00:00
case "send":
send(); // API for posting content to the Fediverse
2024-02-29 14:46:27 +00:00
case "follow":
follow(); // User interface for following an external user
2024-02-29 14:46:27 +00:00
case "follow_user":
follow_user(); // API for following a user
case "read":
read(); // User interface for reading posts
2024-03-02 21:35:32 +00:00
case ".well-known/nodeinfo":
wk_nodeinfo(); // Optional. Static.
case "nodeinfo/2.1":
nodeinfo(); // Optional. Static.
2024-02-26 19:56:55 +00:00
case "/":
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-02-12 21:21:05 +00:00
"preferredUsername" => rawurldecode($username),
"name" => "{$realName}",
2024-02-13 13:30:36 +00:00
"summary" => "{$summary}",
"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"
],
"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 ) {
$following = json_decode( file_get_contents( $following_file ),true );
$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-02-24 11:21:56 +00:00
// The number of followers is self-reported
// You can set this to any number you like
// 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
// Validate HTTP Message Signature
// This logs whether the signature was validated or not
if ( !verifyHTTPSignature() ) { die(); }
2024-02-12 21:21:05 +00:00
// Get the message and type
$inbox_message = $body;
$inbox_type = $inbox_message["type"];
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,
} ) {
// Save the message in `/data/inbox/`
$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-01 23:26:10 +00:00
$follower_host = parse_url( $follower_actor, PHP_URL_HOST ); // E.g. mastodon.social
$follower_path = parse_url( $follower_actor, PHP_URL_PATH ); // E.g. /users/Edent
// Get the actor's profile as JSON
// Is the actor an https URl?
if(
( filter_var( $follower_actor, FILTER_VALIDATE_URL) == true) &&
( parse_url( $follower_actor, PHP_URL_SCHEME ) == "https" )
) {
// Request the JSON representation of the the user
$ch = curl_init( $follower_actor );
2024-02-26 19:56:55 +00:00
// Generate signed headers for this request
$headers = generate_signed_headers( null, $follower_host, $follower_path, "GET" );
// Set cURL options
curl_setopt( $ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt( $ch, CURLOPT_HTTPHEADER, $headers );
// Execute the cURL session
$inbox_actor_json = curl_exec( $ch );
// Check for errors
if (curl_errno($ch)) {
2024-03-01 23:26:10 +00:00
// TODO: Handle cURL error
die();
}
// Close cURL session
curl_close($ch);
2024-02-29 14:46:27 +00:00
// Save the actor's data in `/data/followers/`
$follower_filename = urlencode( $follower_actor );
2024-02-29 17:26:10 +00:00
file_put_contents( $directories["followers"] . "/{$follower_filename}.json", $inbox_actor_json );
2024-02-12 21:21:05 +00:00
} else {
die();
2024-02-12 21:21:05 +00:00
}
// Get the new follower's Inbox
$follower_actor_details = json_decode( $inbox_actor_json, true );
$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();
// 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",
"id" => $follower_id,
2024-02-12 21:21:05 +00:00
"type" => $inbox_type,
"actor" => $follower_actor,
2024-02-12 21:21:05 +00:00
"object" => "https://{$server}/{$username}",
]
];
// The Accept is POSTed to the inbox on the server of the user who requested the follow
$follower_inbox_path = parse_url( $follower_inbox, PHP_URL_PATH );
2024-03-01 23:26:10 +00:00
// Generate the signed headers
$headers = generate_signed_headers( $message, $follower_host, $follower_inbox_path, "POST" );
2024-02-12 21:21:05 +00:00
// POST the message and header to the requester's inbox
$ch = curl_init( $follower_inbox );
2024-02-12 21:21:05 +00:00
curl_setopt( $ch, CURLOPT_RETURNTRANSFER, true );
curl_setopt( $ch, CURLOPT_CUSTOMREQUEST, "POST" );
2024-02-26 19:56:55 +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 ) ) {
2024-02-29 17:26:10 +00:00
$timestamp = ( new DateTime() )->format( DATE_RFC3339_EXTENDED );
file_put_contents( $directories["logs"] . "/{$timestamp}.Error.txt", curl_error( $ch ) );
2024-02-12 21:21:05 +00:00
}
2024-02-29 17:26:10 +00:00
curl_close( $ch );
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.
function generate_signed_headers( $message, $host, $path, $method ) {
2024-02-14 09:45:04 +00:00
global $server, $username, $key_private;
2024-02-14 09:45:04 +00:00
// Location of the Public Key
$keyId = "https://{$server}/{$username}#main-key";
// Get the Private Key
$signer = openssl_get_privatekey( $key_private );
// 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
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
$headers = array(
2024-02-26 19:56:55 +00:00
"Host: {$host}",
"Date: {$date}",
"Digest: SHA-256={$digest}",
"Signature: {$signature_header}",
"Content-Type: application/activity+json",
2024-02-26 19:56:55 +00:00
"Accept: application/activity+json",
);
} 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.
$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
$headers = array(
"Host: {$host}",
"Date: {$date}",
"Signature: {$signature_header}",
"Accept: application/activity+json",
);
}
2024-02-14 09:45:04 +00:00
return $headers;
}
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.
function home() {
global $username, $server, $realName, $summary, $directories;
2024-02-18 14:29:06 +00:00
echo <<< HTML
<!DOCTYPE html>
<html lang="en-GB">
<head>
<meta charset="UTF-8">
<title>{$realName}</title>
<style>
body { text-align: center; font-family:sans-serif; font-size:1.1em; }
ul { text-align: left; }
img { max-width: 50%; }
2024-02-18 14:29:06 +00:00
</style>
</head>
<body>
<span class="h-card">
<img src="icon.png" alt="icon" class="u-photo " width="140px" />
<h1><span class="p-name">{$realName}</span></h1>
<h2><a class="p-nickname u-url" href="https://{$server}/{$username}">@{$username}@{$server}</a></h2>
<p class="note">{$summary}</p>
</span>
<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>
<ul>
HTML;
2024-02-26 19:56:55 +00:00
// Get all posts, most recent first
$posts = array_reverse( glob( $directories["posts"] . "/*.json") );
2024-02-26 19:56:55 +00:00
// Loop through the posts
foreach ($posts as $post) {
2024-03-01 23:26:10 +00:00
// Get the contents of the file
$postJSON = file_get_contents($post);
$postData = json_decode($postJSON, true);
2024-03-01 23:26:10 +00:00
// Set up some common variables
$postTime = $postData["published"];
$postHTML = $postData["content"];
2024-03-01 23:26:10 +00:00
// If the message has an image attached, display it.
if ( isset($postData["attachment"]) ) {
$postImgUrl = $postData["attachment"]["url"];
$postImgAlt = $postData["attachment"]["name"];
$postImg = "<img src='{$postImgUrl}' alt='$postImgAlt'>";
} else {
$postImg = "";
}
2024-03-01 23:26:10 +00:00
2024-02-26 19:56:55 +00:00
// Display the post
echo "<li><a href='/{$post}'><time datetime='{$postTime}'>$postTime</time></a><br>{$postHTML}<br>{$postImg}</li>";
}
echo <<< HTML
</ul>
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() {
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.1em;}
</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-18 15:48:33 +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>
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.
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.
// 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(); }
// 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-18 15:48:33 +00:00
// 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 );
2024-03-01 23:26:10 +00:00
$image_full_path = $directories["images"] . "/{$sha1}.{$image_ext}";
2024-02-18 15:48:33 +00:00
// Move media to the correct location
2024-02-26 19:56:55 +00:00
move_uploaded_file( $image, $image_full_path );
2024-02-18 15:48:33 +00:00
// Get the alt text
if ( isset( $_POST["alt"] ) ) {
$alt = $_POST["alt"];
} else {
$alt = "";
}
// Construct the attachment value for the post
$attachment = [
"type" => "Image",
"mediaType" => "{$image_mime}",
"url" => "https://{$server}/{$image_full_path}",
"name" => $alt
];
} else {
$attachment = [];
}
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-03-01 23:26:10 +00:00
// `contentMap` is used to prevent unnecessary "translate this post" pop ups
2024-02-13 13:30:36 +00:00
// 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"],
2024-02-18 15:48:33 +00:00
"tag" => $tags,
"attachment" => $attachment
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
];
// Save the permalink
$note_json = json_encode( $note );
2024-02-13 13:30:36 +00:00
// Check for posts/ directory and create it
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
// Read existing followers
2024-03-01 23:26:10 +00:00
$followers = glob( $directories["followers"] . "/*.json" );
// Get all the inboxes
$inboxes = [];
foreach ( $followers as $follower ) {
2024-03-01 23:26:10 +00:00
// Get the data about the follower
$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.
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();
// 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
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
$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
$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-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.
// Tags are also included separately in the note
2024-02-16 16:09:05 +00:00
function process_content( $content ) {
global $server;
// Convert any URls into hyperlinks
2024-03-01 23:26:10 +00:00
$link_pattern = '/\bhttps?:\/\/\S+/iu'; // Sloppy regex
$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 = [];
$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
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
// TODO: Add this to the CC field
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 [
"HTML" => $content,
"TagArray" => $tags
];
2024-02-16 16:09:05 +00:00
}
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() {
global $server, $username, $directories;
2024-02-18 14:44:57 +00:00
// Get all posts
$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 = [];
foreach ($posts as $post) {
$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
function follow() {
echo <<< HTML
<!DOCTYPE html>
<html lang="en-GB">
<head>
<meta charset="UTF-8">
<title>Follow</title>
<style>
*{font-family:sans-serif;font-size:1.1em;}
</style>
</head>
<body>
<form action="/follow_user" method="post" enctype="multipart/form-data">
<label for="user">User to follow</label>
<input name="user" id="user" type="text" size="32" placeholder="@user@example.com" /><br>
<label for="password">Password</label><br>
<input name="password" id="password" type="password" size="32"><br>
2024-02-29 17:26:10 +00:00
<input type="submit" value="Send Follow 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
function follow_user() {
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
$user = $_POST["user"];
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-01 23:26:10 +00:00
// This request does not need to be signed.
2024-02-29 14:46:27 +00:00
$webfingerURl = "https://{$follow_server}/.well-known/webfinger?resource=acct:{$follow_name}@{$follow_server}";
$webfingerJSON = file_get_contents( $webfingerURl );
$webfinger = json_decode( $webfingerJSON, true );
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"];
}
}
if ( !isset( $profileURl ) ) { echo "No profile"; die(); }
// Get the user's details
2024-03-01 23:38:28 +00:00
// This request does not need to be signed normally.
// Some servers will only respond to signed requests.
// It need to specify that it wants a JSON response
$profileURl_host = parse_url( $profileURl, PHP_URL_HOST );
$profileURl_path = parse_url( $profileURl, PHP_URL_PATH );
// Request the JSON representation of the the user
$ch = curl_init( $profileURl );
// Generate signed headers for this request
$headers = generate_signed_headers( null, $profileURl_host, $profileURl_path, "GET" );
// Set cURL options
curl_setopt( $ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt( $ch, CURLOPT_HTTPHEADER, $headers );
// Execute the cURL session
$profileJSON = curl_exec( $ch );
// Check for errors
if (curl_errno($ch)) {
// Handle cURL error
die();
}
// Close cURL session
curl_close($ch);
2024-02-29 14:46:27 +00:00
$profileData = json_decode( $profileJSON, true );
// Get the user's inbox
$profileInbox = $profileData["inbox"];
$inbox_host = parse_url( $profileInbox, PHP_URL_HOST );
$inbox_path = parse_url( $profileInbox, PHP_URL_PATH );
// Create a follow request
$guid = uuid();
$message = [
"@context" => "https://www.w3.org/ns/activitystreams",
"id" => "https://{$server}/{$guid}",
"type" => "Follow",
"actor" => "https://{$server}/{$username}",
"object" => $profileURl
];
2024-03-01 23:38:28 +00:00
// Sign a request to follow
2024-02-29 14:46:27 +00:00
// The Accept is POSTed to the inbox on the server of the user who requested the follow
// Get 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( $profileInbox );
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_exec( $ch );
// Check for errors
if( curl_errno( $ch ) ) {
2024-02-29 17:26:10 +00:00
$timestamp = ( new DateTime() )->format( DATE_RFC3339_EXTENDED );
file_put_contents( $directories["logs"] . "/{$timestamp}.Error.txt", curl_error( $ch ) );
2024-02-29 14:46:27 +00:00
}
curl_close($ch);
// Save the user's details
$following_filename = urlencode( $profileURl );
2024-02-29 17:26:10 +00:00
file_put_contents( $directories["following"] . "/{$following_filename}.json", $profileJSON );
2024-02-29 14:46:27 +00:00
// Render the JSON so the user can see the POST has worked
header( "Location: https://{$server}/data/following/" . urlencode( $following_filename ) . ".json" );
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();
// 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
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
$timeDifference = abs($currentDatetime->getTimestamp() - $headerDatetime->getTimestamp());
if ($timeDifference > 30) {
// 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.
$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
if ( "SHA-256" == $digestAlgorithm ) {
$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
$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);
foreach ($matches[1] as $index => $key) {
$signatureParts[$key] = $matches[2][$index];
}
// Manually reconstruct the header string
$signatureHeaders = explode(" ", $signatureParts["headers"] );
$signatureString = "";
foreach ($signatureHeaders as $signatureHeader) {
if ( "(request-target)" == $signatureHeader ) {
$method = strtolower( $_SERVER["REQUEST_METHOD"] );
$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 {
$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
// This request does not need to be signed normally.
// Some servers will only respond to signed requests.
// It need to specify that it wants a JSON response
$publicKeyURL_host = parse_url( $publicKeyURL, PHP_URL_HOST );
$publicKeyURL_path = parse_url( $publicKeyURL, PHP_URL_PATH );
// Request the JSON representation of the the user
$ch = curl_init( $publicKeyURL );
// Generate signed headers for this request
$headers = generate_signed_headers( null, $publicKeyURL_host, $publicKeyURL_path, "GET" );
// Set cURL options
curl_setopt( $ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt( $ch, CURLOPT_HTTPHEADER, $headers );
// Execute the cURL session
$userJSON = curl_exec( $ch );
// Check for errors
if (curl_errno($ch)) {
// Handle cURL error
die();
}
// Close cURL session
curl_close($ch);
2024-02-25 14:42:50 +00:00
$userData = json_decode( $userJSON, true );
$publicKey = $userData["publicKey"]["publicKeyPem"];
// Get the remaining parts
$signature = base64_decode( $signatureParts["signature"] );
$algorithm = $signatureParts["algorithm"];
// 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
if ($verified === 1) {
$verified = true;
} elseif ($verified === 0) {
$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;
}
// Displays the most recent 200 messages in the inbox
function read() {
global $server, $directories;
// Get all the files in the inbox
$inbox_files = array_reverse( glob( $directories["inbox"] . "/*.json") );
// Keep the most recent 200
$inbox_files = array_slice( $inbox_files, 0, 200 );
// Sometimes messages are received out of order.
// This sorts them by their published time or, if there is none, the received time.
$inbox_ordered = [];
foreach ( $inbox_files as $inbox_file ) {
// Get the contents of the JSON
$inbox_message = json_decode( file_get_contents( $inbox_file ), true );
// Use the timestamp of the message. If there is none, use the date in the filename
if ( isset( $inbox_message["published"] ) ) {
$published = $inbox_message["published"];
} else {
$published_hexstamp = end( explode( "/", explode( "-", $inbox_file)[0] ) ) ;
$published_time = hexdec( $published_hexstamp );
$published = date( "c", $published_time );
}
// Place in an array where the key is the timestamp
$inbox_ordered[$published] = $inbox_message;
}
// Sort with newest on top
krsort( $inbox_ordered );
// Show a basic HTML interface
echo <<< HTML
<!DOCTYPE html>
<html lang="en-GB">
<head>
<meta charset="UTF-8">
<title>Reader</title>
<style>
body { text-align: center; font-family:sans-serif; font-size:1.1em; }
ul { text-align: left; }
img { max-width: 50%; }
</style>
</head>
<body>
<ul>
HTML;
// Print the items in a list
foreach ( $inbox_ordered as $published=>$inbox_message ) {
// Set up the common components
$object = $inbox_message["object"];
$actor = $inbox_message["actor"];
$actorName = end( explode("/", $actor ) );
$actorHTML = "<a href=\"$actor\">@{$actorName}</a>";
$timeHTML = "<time datetime=\"{$published}\">{$published}</time>";
// 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"];
// What type of message is this?
$type = $inbox_message["type"];
// Render the message according to type
2024-03-02 12:18:11 +00:00
if ( "Create" == $type || "Update" == $type ) {
// Get the HTML content and sanitise it.
$content = $object["content"];
$content = strip_tags($content, $allowed_elements);
// 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"] ) ) {
$mediaType = explode( "/", $attachment["mediaType"])[0];
if ( "image" == $mediaType ) {
// Get the alt text
isset( $attachment["name"] ) ? $alt = $attachment["name"] : $alt = "";
$content .= "<img src='" . $attachment["url"] . "' alt='{$alt}'>";
} else if ( "video" == $mediaType ) {
$content .= "<video controls><source src='" . $attachment["url"] . "' type='" . $attachment["mediaType"] . "' /></video>";
}else if ( "audio" == $mediaType ) {
$content .= "<audio controls src='" . $attachment["url"] . "' type='" . $attachment["mediaType"] . "'></audio>";
}
}
}
}
2024-03-02 12:18:11 +00:00
"Create" == $type ? $verb = "wrote" : $verb = "updated";
echo "<li>{$timeHTML} {$actorHTML} {$verb}: <blockquote>{$content}</blockquote></li>";
} else if ( "Like" == $type ) {
$objectHTML = "<a href=\"$object\">{$object}</a>";
echo "<li>{$timeHTML} {$actorHTML} liked {$objectHTML}<br></li>";
} else if ( "Follow" == $type ) {
echo "<li>{$timeHTML} {$actorHTML} followed you<br></li>";
} else if ( "Announce" == $type ) {
$objectHTML = "<a href=\"$object\">{$object}</a>";
echo "<li>{$timeHTML} {$actorHTML} boosted {$objectHTML}<br></li>";
}
}
echo <<< HTML
</ul>
</body>
</html>
HTML;
die();
}
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();