activitypub-single-php-file/index.php

436 wiersze
13 KiB
PHP

<?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.
* It produces an Actor who can be followed.
* It can send messages to followers.
* It saves logs about requests it receives and sends.
* It is NOT suitable for production use.
* This code is "licenced" under CRAPL v0 - https://matt.might.net/articles/crapl/
* "Any appearance of design in the Program is purely coincidental and should not in any way be mistaken for evidence of thoughtful software construction."
*/
// Set up the Actor's information
$username = rawurlencode("example"); // Encoded as it is often used as part of a URl
$realName = "E. Xample. Jr.";
$server = $_SERVER['SERVER_NAME']; // Domain name this is hosted on
// 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";
// Get all headers and requests sent to this server
$headers = print_r( getallheaders(), true );
$postData = print_r( $_POST, true );
$getData = print_r( $_GET, true );
$filesData = print_r( $_FILES, true );
$body = json_decode( file_get_contents( "php://input"), true );
$bodyData = print_r( $input, true );
$requestData = print_r( $_REQUEST, true );
$serverData = print_r( $_SERVER, true );
// Get the type of request
if ( isset( $body["type"] ) ) {
$type = " " . $body["type"];
} else {
$type = "";
}
// Create a timestamp in ISO 8601 format for the filename
$timestamp = date('c');
// Filename for the log
$filename = "{$timestamp}{$type}.txt";
// Save headers and request data to the timestamped file
file_put_contents( $filename,
"Headers: \n$headers \n\n" .
"Body Data: \n$bodyData \n\n" .
"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"
);
// The .htaccess changes /whatever to /?path=whatever
// What path was requested?
$path = $_GET["path"];
switch ($path) {
case "":
echo "Silence";
case ".well-known/webfinger":
webfinger();
case "{$username}":
username();
case "following":
following();
case "followers":
followers();
case "inbox":
inbox();
case "write":
write();
case "send":
send();
default:
die();
}
function webfinger() {
// Display the WebFinger JSON
global $username, $server;
$webfinger = array(
"subject" => "acct:{$username}@{$server}",
"links" => array(
array(
"rel" => "self",
"type" => "application/activity+json",
"href" => "https://{$server}/{$username}"
)
)
);
header("Content-Type: application/json");
echo json_encode( $webfinger );
die();
}
function username() {
// Display the username JSON
global $username, $realName, $server, $key_public;
$user = array(
"@context" => [
"https://www.w3.org/ns/activitystreams",
"https://w3id.org/security/v1"
],
"id" => "https://{$server}/{$username}",
"type" => "Person",
"following" => "https://{$server}/following",
"followers" => "https://{$server}/followers",
"inbox" => "https://{$server}/inbox",
"preferredUsername" => rawurldecode($username),
"name" => "{$realName}",
"summary" => "A single file ActivityPub server.",
"url" => "https://{$server}",
"manuallyApprovesFollowers" => true,
"discoverable" => true,
"published" => "2024-02-12T11:51:00Z",
"icon" => [
"type" => "Image",
"mediaType" => "image/png",
"url" => "https://{$server}/icon.png"
],
"publicKey" => [
"id" => "https://{$server}/{$username}#main-key",
"owner" => "https://{$server}/{$username}",
"publicKeyPem" => $key_public
]
);
header("Content-Type: application/activity+json");
echo json_encode( $user );
die();
}
function following() {
// Display the following JSON
global $server;
$following = array(
"@context" => "https://www.w3.org/ns/activitystreams",
"id" => "https://{$server}/following",
"type" => "Collection",
"totalItems" => 0,
"items" => []
);
header("Content-Type: application/activity+json");
echo json_encode( $following );
die();
}
function followers() {
// Display the followers JSON
global $server;
$followers = array(
"@context" => "https://www.w3.org/ns/activitystreams",
"id" => "https://{$server}/followers",
"type" => "Collection",
"totalItems" => 0,
"items" => []
);
header("Content-Type: application/activity+json");
echo json_encode( $followers );
die();
}
function inbox() {
// Respond to InBox requests
global $body, $server, $username, $key_private;
// Get the message and type
$inbox_message = $body;
$inbox_type = $inbox_message["type"];
// This inbox only responds to follow requests
if ( "Follow" != $inbox_type ) { die(); }
// Get the parameters
$inbox_id = $inbox_message["id"];
$inbox_actor = $inbox_message["actor"];
$inbox_url = parse_url($inbox_actor, PHP_URL_SCHEME) . "://" . parse_url($inbox_actor, PHP_URL_HOST);
$inbox_host = parse_url($inbox_actor, PHP_URL_HOST);
// Does this account have any followers?
if( file_exists( "followers.json" ) ) {
$followers_file = file_get_contents( "followers.json" );
$followers_json = json_decode( $followers_file, true );
} else {
$followers_json = array();
}
// Add user to list. Don't care about duplicate users, server is what's important
$followers_json[$inbox_host]["users"][] = $inbox_actor;
// Save the new followers file
file_put_contents( "followers.json", print_r( json_encode( $followers_json ), true ) );
// Response Message ID
// This isn't used for anything important so could just be a random number
$guid = uuid();
// Create the Accept message
$message = [
"@context" => "https://www.w3.org/ns/activitystreams",
"id" => "https://{$server}/{$guid}",
"type" => "Accept",
"actor" => "https://{$server}/{$username}",
"object" => [
"@context" => "https://www.w3.org/ns/activitystreams",
"id" => $inbox_id,
"type" => $inbox_type,
"actor" => $inbox_actor,
"object" => "https://{$server}/{$username}",
]
];
$message_json = json_encode( $message );
// The Accept is sent to the server of the user who requested the follow
// TODO: The path doesn't *always* end with/inbox
$host = $inbox_host;
$path = parse_url($inbox_actor, PHP_URL_PATH) . "/inbox";
// Set up signing
$keyId = "https://{$server}/{$username}#main-key";
// Generate signing variables
$hash = hash('sha256', $message_json, true);
$digest = base64_encode($hash);
$date = date('D, d M Y H:i:s \G\M\T');
$signer = openssl_get_privatekey($key_private);
$stringToSign = "(request-target): post $path\nhost: $host\ndate: $date\ndigest: SHA-256=$digest";
openssl_sign($stringToSign, $signature, $signer, OPENSSL_ALGO_SHA256);
$signature_b64 = base64_encode($signature);
$header = 'keyId="' . $keyId . '",algorithm="rsa-sha256",headers="(request-target) host date digest",signature="' . $signature_b64 . '"';
// Header for POST reply
$headers = array(
"Host: {$host}",
"Date: {$date}",
"Digest: SHA-256={$digest}",
"Signature: {$header}",
"Content-Type: application/activity+json",
"Accept: application/activity+json",
);
// Specify the URL of the remote server's inbox
// TODO: The path doesn't *always* end with /inbox
$remoteServerUrl = $inbox_actor . "/inbox";
// POST the message and header to the requester's inbox
$ch = curl_init( $remoteServerUrl );
curl_setopt( $ch, CURLOPT_RETURNTRANSFER, true );
curl_setopt( $ch, CURLOPT_CUSTOMREQUEST, "POST" );
curl_setopt( $ch, CURLOPT_POSTFIELDS, $message_json );
curl_setopt( $ch, CURLOPT_HTTPHEADER, $headers );
$response = curl_exec( $ch );
// Check for errors
if( curl_errno( $ch ) ) {
file_put_contents( "error.txt", curl_error( $ch ) );
}
curl_close($ch);
die();
}
function uuid() {
// Date sortable UUID
return sprintf('%08x-%04x-%04x-%04x-%012x',
time(),
mt_rand(0, 0xffff),
mt_rand(0, 0xffff),
mt_rand(0, 0x3fff) | 0x8000,
mt_rand(0, 0xffffffffffff)
);
}
function write() {
// Display an HTML form for the user to enter a message.
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>
<textarea id="content" name="content" rows="5" cols="32"></textarea><br>
<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();
}
function send() {
global $password, $server, $username, $key_private;
// Does the posted password match the stored password?
if( $password != $_POST["password"] ) { die(); }
// Get the posted content
$content = $_POST["content"];
// Current time
$timestamp = date("c");
// Outgoing Message ID
$guid = uuid();
// Construct the Note
$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],
"to" => ["https://www.w3.org/ns/activitystreams#Public"]
];
// Construct the Message
$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
];
$message_json = json_encode($message);
// Create the context for the permalink
$note = [ "@context" => "https://www.w3.org/ns/activitystreams", ...$note];
// Save the permalink
$note_json = json_encode( $note );
file_put_contents( "posts/{$guid}.json", print_r( $note_json, true ) );
// Read existing users and get their hosts
$followers_file = file_get_contents( "followers.json" );
$followers_json = json_decode( $followers_file, true );
$hosts = array_keys( $followers_json );
// Prepare to use the multiple cURL handle
$mh = curl_multi_init();
// Loop through all the severs of the followers
// Each server needs its own cURL handle
// Each POST to an inbox needs to be signed separately
foreach ( $hosts as $host ) {
$path = '/inbox';
// Set up signing
$privateKey = $key_private;
$keyId = "https://{$server}/{$username}#main-key";
$hash = hash( "sha256", $message_json, true );
$digest = base64_encode( $hash );
$date = date('D, d M Y H:i:s \G\M\T');
$signer = openssl_get_privatekey( $key_private );
$stringToSign = "(request-target): post $path\nhost: $host\ndate: $date\ndigest: SHA-256=$digest";
openssl_sign( $stringToSign, $signature, $signer, OPENSSL_ALGO_SHA256 );
$signature_b64 = base64_encode($signature);
$header = 'keyId="' . $keyId . '",algorithm="rsa-sha256",headers="(request-target) host date digest",signature="' . $signature_b64 . '"';
// Header for POST reply
$headers = array(
"Host: {$host}",
"Date: {$date}",
"Digest: SHA-256={$digest}",
"Signature: {$header}",
"Content-Type: application/activity+json",
"Accept: application/activity+json",
);
// Specify the URL of the remote server
$remoteServerUrl = "https://{$host}{$path}";
// POST the message and header to the requester's inbox
$ch = curl_init( $remoteServerUrl );
curl_setopt( $ch, CURLOPT_RETURNTRANSFER, true );
curl_setopt( $ch, CURLOPT_CUSTOMREQUEST, "POST" );
curl_setopt( $ch, CURLOPT_POSTFIELDS, $message_json );
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();
}
die();
die();
die();