Move GET and POST into their own functions

merge-requests/5/head
Terence Eden 2024-03-03 21:57:19 +00:00
rodzic 098e1a23e5
commit 5fdfd84e6f
1 zmienionych plików z 221 dodań i 196 usunięć

417
index.php
Wyświetl plik

@ -43,7 +43,7 @@
$server = $_SERVER["SERVER_NAME"]; // Do not change this!
// Set up where logs and messages go.
// If you want to, you can change these directories to something more suitable for you.
// You can change these directories to something more suitable if you like.
$data = "data";
$directories = array(
"inbox" => "{$data}/inbox",
@ -53,6 +53,7 @@
"posts" => "posts",
"images" => "images",
);
// Create the directories if they don't already exist.
foreach ( $directories as $directory ) {
if( !is_dir( $directory ) ) { mkdir( $data ); mkdir( $directory ); }
}
@ -71,7 +72,8 @@
$bodyData = print_r( $body, true );
$requestData = print_r( $_REQUEST, true );
$serverData = print_r( $_SERVER, true );
!empty( $_GET["path"] ) ? $path = $_GET["path"] : $path = "/";
// If the root has been requested, manually set the path to `/`
!empty( $_GET["path"] ) ? $path = $_GET["path"] : $path = "/";
// Get the type of request - used in the log filename
if ( isset( $body["type"] ) ) {
@ -123,21 +125,21 @@
case "outbox":
outbox(); // Optional. Dynamic.
case "write":
write(); // User interface for writing posts
write(); // User interface for writing posts.
case "send":
send(); // API for posting content to the Fediverse
send(); // API for posting content to the Fediverse.
case "follow":
follow(); // User interface for following an external user
follow(); // User interface for following an external user.
case "follow_user":
follow_user(); // API for following a user
follow_user(); // API for following a user.
case "read":
view( "read" ); // User interface for reading posts
view( "read" );// User interface for reading posts.
case ".well-known/nodeinfo":
wk_nodeinfo(); // Optional. Static.
wk_nodeinfo(); // Optional. Static.
case "nodeinfo/2.1":
nodeinfo(); // Optional. Static.
nodeinfo(); // Optional. Static.
case "/":
view( "home" ); // Optional. Can be dynamic
view( "home" );// Optional. Can be dynamic
default:
die();
}
@ -221,7 +223,7 @@
// Create a list of all the followers
$items = array();
foreach ( $following_files as $following_file ) {
$following = json_decode( file_get_contents( $following_file ),true );
$following = json_decode( file_get_contents( $following_file ), true );
$items[] = $following["id"];
}
@ -238,8 +240,8 @@
}
function followers() {
global $server, $directories;
// The number of followers is self-reported
// You can set this to any number you like
// The number of followers is self-reported.
// You can set this to any number you like.
// Get all the files
$follower_files = glob( $directories["followers"] . "/*.json");
@ -322,13 +324,13 @@
$inbox_actor_json = curl_exec( $ch );
// Check for errors
if (curl_errno($ch)) {
if ( curl_errno( $ch ) ) {
// TODO: Handle cURL error
die();
}
// Close cURL session
curl_close($ch);
curl_close( $ch );
// Save the actor's data in `/data/followers/`
$follower_filename = urlencode( $follower_actor );
@ -362,24 +364,7 @@
];
// 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 );
// Generate the signed headers
$headers = generate_signed_headers( $message, $follower_host, $follower_inbox_path, "POST" );
// POST the message and header to the requester's inbox
$ch = curl_init( $follower_inbox );
curl_setopt( $ch, CURLOPT_RETURNTRANSFER, true );
curl_setopt( $ch, CURLOPT_CUSTOMREQUEST, "POST" );
curl_setopt( $ch, CURLOPT_POSTFIELDS, json_encode( $message ) );
curl_setopt( $ch, CURLOPT_HTTPHEADER, $headers );
curl_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 ) );
}
curl_close( $ch );
sentMessageToSingle( $follower_inbox, $message );
die();
}
@ -405,7 +390,7 @@
global $server, $username, $key_private;
// Location of the Public Key
$keyId = "https://{$server}/{$username}#main-key";
$keyId = "https://{$server}/{$username}#main-key";
// Get the Private Key
$signer = openssl_get_privatekey( $key_private );
@ -413,7 +398,7 @@
// 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
// There are subtly different signing requirements for POST and GET.
if ( "POST" == $method ) {
// Encode the message object to JSON
$message_json = json_encode( $message );
@ -497,64 +482,64 @@
}
// Counters for followers, following, and posts
$follower_files = glob( $directories["followers"] . "/*.json");
$follower_files = glob( $directories["followers"] . "/*.json" );
$totalFollowers = count( $follower_files );
$following_files = glob( $directories["following"] . "/*.json");
$following_files = glob( $directories["following"] . "/*.json" );
$totalFollowing = count( $following_files );
// Show the HTML page
echo <<< HTML
<!DOCTYPE html>
<html lang="en-GB">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta property="og:url" content="https://{$server}">
<meta property="og:type" content="website">
<meta property="og:title" content="{$realName}">
<meta property="og:description" content="{$summary}">
<meta property="og:image" content="https://{$server}/banner.png">
<title>{$h1} {$realName}</title>
<style>
body { margin:0; padding: 0; font-family:sans-serif; }
@media screen and (max-width: 800px) { body { width: 100%; }}
@media screen and (min-width: 799px) { body { width: 800px; margin: 0 auto; }}
address { font-style: normal; }
img { max-width: 50%; }
.h-feed { margin:auto; width: 100%; }
.h-feed > header { text-align: center; margin: 0 auto; }
.h-feed .banner { text-align: center; margin:0 auto; max-width: 650px; }
.h-feed > h1, .h-feed > h2 { margin-top: 10px; margin-bottom: 0; }
.h-feed > header > h1:has(span.p-author), h2:has(a.p-nickname) { word-wrap: break-word; max-width: 90%; padding-left:20px; }
.h-feed .u-feature:first-child { margin-top: 10px; margin-bottom: -150px; max-width: 100%;}
.h-feed .u-photo { max-height: 8vw; max-width:100%; min-height: 120px; }
.h-feed .about { font-size: smaller; background-color: #F5F5F5; padding: 10px; border-top: dotted 1px #808080; border-bottom: dotted 1px #808080; }
.h-feed > ul { padding-left: 0; list-style-type: none; }
.h-feed > ul > li { padding: 10px; border-bottom: dotted 1px #808080; }
.h-entry { padding-right: 10px; }
.h-entry time { font-weight: bold; }
.h-entry .e-content a { word-wrap: break-word; }
</style>
</head>
<body>
<main class="h-feed">
<header>
<div class="banner">
<img src="banner.png" alt="" class="u-feature"><br>
<img src="icon.png" alt="icon" class="u-photo">
</div>
<address>
<h1 class="p-name p-author">{$realName}</h1>
<h2><a class="p-nickname u-url" rel="author" href="https://{$server}/{$username}">@{$rawUsername}@{$server}</a></h2>
</address>
<p class="p-summary">{$summary}</p>
<p>Following: {$totalFollowing} | Followers: {$totalFollowers}</p>
<div class="about">
<p><a href="https://gitlab.com/edent/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>
<!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: 800px) { body { width: 100%; }}
@media screen and (min-width: 799px) { body { width: 800px; margin: 0 auto; }}
address { font-style: normal; }
img { max-width: 50%; }
.h-feed { margin:auto; width: 100%; }
.h-feed > header { text-align: center; margin: 0 auto; }
.h-feed .banner { text-align: center; margin:0 auto; max-width: 650px; }
.h-feed > h1, .h-feed > h2 { margin-top: 10px; margin-bottom: 0; }
.h-feed > header > h1:has(span.p-author), h2:has(a.p-nickname) { word-wrap: break-word; max-width: 90%; padding-left:20px; }
.h-feed .u-feature:first-child { margin-top: 10px; margin-bottom: -150px; max-width: 100%;}
.h-feed .u-photo { max-height: 8vw; max-width:100%; min-height: 120px; }
.h-feed .about { font-size: smaller; background-color: #F5F5F5; padding: 10px; border-top: dotted 1px #808080; border-bottom: dotted 1px #808080; }
.h-feed > ul { padding-left: 0; list-style-type: none; }
.h-feed > ul > li { padding: 10px; border-bottom: dotted 1px #808080; }
.h-entry { padding-right: 10px; }
.h-entry time { font-weight: bold; }
.h-entry .e-content a { word-wrap: break-word; }
</style>
</head>
<body>
<main class="h-feed">
<header>
<div class="banner">
<img src="banner.png" alt="" class="u-feature"><br>
<img src="icon.png" alt="icon" class="u-photo">
</div>
<address>
<h1 class="p-name p-author">{$realName}</h1>
<h2><a class="p-nickname u-url" rel="author" href="https://{$server}/{$username}">@{$rawUsername}@{$server}</a></h2>
</address>
<p class="p-summary">{$summary}</p>
<p>Following: {$totalFollowing} | Followers: {$totalFollowers}</p>
<div class="about">
<p><a href="https://gitlab.com/edent/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>
HTML;
// Get all the files in the directory
$message_files = array_reverse( glob( $directories[$directory] . "/*.json") );
@ -612,16 +597,21 @@ HTML;
// Set up the common components
$object = $message["object"];
// Get the message's ID.
// Set up the HTML representation
if ( isset( $message["object"]["id"] ) ) {
$id = $message["object"]["id"];
$timeHTML = "<time datetime=\"{$published}\" class=\"u-url\" rel=\"bookmark\"><a href=\"{$id}\">{$published}</a></time>";
$publishedHTML = "<a href=\"{$id}\">{$published}</a>";
} else if ( isset( $message["id"] ) ) {
$id = $message["id"];
$timeHTML = "<time datetime=\"{$published}\" class=\"u-url\" rel=\"bookmark\"><a href=\"{$id}\">{$published}</a></time>";
$publishedHTML = "<a href=\"{$id}\">{$published}</a>";
} else {
$id = "";
$timeHTML = "<time datetime=\"{$published}\" class=\"u-url\" rel=\"bookmark\">{$published}</time>";
$publishedHTML = $published;
}
// For displaying the post's information
$timeHTML = "<time datetime=\"{$published}\" class=\"u-url\" rel=\"bookmark\">{$publishedHTML}</time>";
// Get the actor who authored the message
if ( isset( $message["actor"] ) ) {
@ -656,10 +646,11 @@ HTML;
// Render the message according to type
if ( "Create" == $type || "Update" == $type || "Note" == $type ) {
// Get the HTML content and sanitise it.
// Get the HTML content
// There is a slight difference between the formatting of sent and received messages
"Note" == $type ? $content = $message["content"] : $content = $object["content"];
$content = strip_tags( $content, $allowed_elements );
// Sanitise the HTML
$content = strip_tags( $content, $allowed_elements );
// Is this a reply to something?
if ( isset( $object["inReplyTo"] ) ) {
@ -680,7 +671,7 @@ HTML;
if ( isset( $object["summary"] ) ) {
$summary = $object["summary"];
$summary = strip_tags( $summary, $allowed_elements );
// Hide the content until the user interacts with it.
$content = "<details><summary>{$summary}</summary>{$content}</details>";
}
@ -712,16 +703,20 @@ HTML;
// Only use things which have a MIME Type set
if ( isset( $attachment["mediaType"] ) ) {
$mediaType = explode( "/", $attachment["mediaType"])[0];
$mediaURl = $attachment["url"];
$mime = $attachment["mediaType"];
// Use the first half of the MIME Type.
// For example `image/png` or `video/mp4`
$mediaType = explode( "/", $mime )[0];
if ( "image" == $mediaType ) {
// Get the alt text
isset( $attachment["name"] ) ? $alt = $attachment["name"] : $alt = "";
$content .= "<img src='" . $attachment["url"] . "' alt='{$alt}'>";
$content .= "<img src='{$mediaURl}' alt='{$alt}'>";
} else if ( "video" == $mediaType ) {
$content .= "<video controls><source src='" . $attachment["url"] . "' type='" . $attachment["mediaType"] . "' /></video>";
$content .= "<video controls><source src='{$mediaURl}' type='{$mime}'></video>";
}else if ( "audio" == $mediaType ) {
$content .= "<audio controls src='" . $attachment["url"] . "' type='" . $attachment["mediaType"] . "'></audio>";
$content .= "<audio controls src='{$mediaURl}' type='{$mime}'></audio>";
}
}
}
@ -767,7 +762,6 @@ HTML;
}
echo <<< HTML
</ul>
</div>
</main>
</body>
</html>
@ -866,7 +860,7 @@ HTML;
$type = $_POST["type"];
// Likes and Announces have an identical message structure
if ( $type == "Like" || $type == "Announce" ) {
if ( "Like" == $type || "Announce" == $type ) {
// Was a URl sent?
if ( isset( $_POST["postURl"] ) && filter_var( $_POST["postURl"], FILTER_VALIDATE_URL ) ) {
$postURl = $_POST["postURl"];
@ -875,6 +869,11 @@ HTML;
die();
}
if ( "Like" == $type ) {
// The message will need to be sent to the inbox of the author of the message
$inbox_single = getInboxFromMessageURl( $postURl );
}
// Outgoing Message ID
$guid = uuid();
@ -888,16 +887,14 @@ HTML;
"object" => $postURl
];
// Annouces are sent to an audience
// Announces are sent to an audience
// The audience is public and it is sent to all followers
if ( $type == "Announce") {
// TODO: Let the original poster know we boosted them
if ( $type == "Announce" ) {
$message = array_merge( $message,
array("to" => [
"https://www.w3.org/ns/activitystreams#Public"
],
"cc" => [
"https://{$server}/followers"
])
array(
"to" => ["https://www.w3.org/ns/activitystreams#Public"],
"cc" => ["https://{$server}/followers"])
);
}
@ -920,7 +917,7 @@ HTML;
list( "HTML" => $content, "TagArray" => $tags ) = process_content( $content );
// Is there an image attached?
if ( isset( $_FILES['image']['tmp_name'] ) && ("" != $_FILES['image']['tmp_name'] ) ) {
if ( isset( $_FILES['image']['tmp_name'] ) && ( "" != $_FILES['image']['tmp_name'] ) ) {
// Get information about the image
$image = $_FILES['image']['tmp_name'];
$image_info = getimagesize( $image );
@ -948,8 +945,7 @@ HTML;
"mediaType" => "{$image_mime}",
"url" => "https://{$server}/{$image_full_path}",
"name" => $alt
];
];
} else {
$attachment = [];
}
@ -1000,6 +996,54 @@ HTML;
$note_json = json_encode( $note );
file_put_contents( $directories["posts"] . "/{$guid}.json", print_r( $note_json, true ) );
// 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 );
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;
}
curl_close($ch);
return true;
}
// POST a signed message to the inboxes of all followers
function sendMessageToFollowers( $message ) {
global $directories;
// Read existing followers
$followers = glob( $directories["followers"] . "/*.json" );
@ -1062,9 +1106,7 @@ HTML;
// 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();
return true;
}
// Content can be plain text. But to add clickable links and hashtags, it needs to be turned into HTML.
@ -1115,7 +1157,7 @@ HTML;
// Construct the mentions value for the note object
// This goes in the generic "tag" property
// TODO: Add this to the CC field
// TODO: Add this to the CC field & appropriate inbox
foreach ( $usernames as $username ) {
list( , $user, $domain ) = explode( "@", $username );
$tags[] = array(
@ -1137,11 +1179,69 @@ HTML;
$content = "<p>{$content}</p>";
return [
"HTML" => $content,
"HTML" => $content,
"TagArray" => $tags
];
}
// 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 ) {
// 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 );
curl_setopt( $ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt( $ch, CURLOPT_HTTPHEADER, $headers );
// 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 );
}
// The Outbox contains a date-ordered list (newest first) of all the user's posts
// This is optional.
function outbox() {
@ -1239,42 +1339,11 @@ HTML;
if ( !isset( $profileURl ) ) { echo "No profile"; die(); }
// Get the user's details
// 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);
$profileData = json_decode( $profileJSON, true );
$profileData = getDataFromURl( $profileURl );
// 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 = [
@ -1287,27 +1356,11 @@ HTML;
// Sign a request to follow
// 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 ) ) {
$timestamp = ( new DateTime() )->format( DATE_RFC3339_EXTENDED );
file_put_contents( $directories["logs"] . "/{$timestamp}.Error.txt", curl_error( $ch ) );
}
curl_close($ch);
sentMessageToSingle( $profileInbox, $message );
// Save the user's details
$following_filename = urlencode( $profileURl );
file_put_contents( $directories["following"] . "/{$following_filename}.json", $profileJSON );
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" );
@ -1422,36 +1475,8 @@ HTML;
// 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
// 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);
$userData = json_decode( $userJSON, true );
$userData = getDataFromURl( $publicKeyURL );
$publicKey = $userData["publicKey"]["publicKeyPem"];
// Get the remaining parts