Newer
Older
<?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.
* The Server produces an Actor who can be followed.
* The Actor can send messages to followers.
* The message can have linkable URls, hashtags, and mentions.
* An image and alt text can be attached to the message.
* 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/
* "Any appearance of design in the Program is purely coincidental and should not in any way be mistaken for evidence of thoughtful software construction."
// This is where you set up your account's name and bio.
// You also need to provide a public/private keypair.
// The posting endpoint is protected with a password that also needs to be set here.
// Set up the Actor's information here, or in the .env file
$env = parse_ini_file('.env');
$username = rawurlencode( $env["USERNAME"] ); // Type the @ username that you want. Do not include an "@".
$realName = $env["REALNAME"]; // The bot's "real" name.
$summary = $env["SUMMARY"]; // The bio of your bot.
$birthday = $env["BIRTHDAY"]; // The "joined" date of your bot.
// Must be in RFC3339 format. 2024-02-29T12:34:56Z
$language = $env["LANGUAGE"]; // This is the primary human language used by your bot.
$oldName = $env["OLD_NAME"]; // To transfer followers from an old account, enter the full account URl.
// For example 'https://example.social/users/username'
$atproto = $env["DID"]; // To set a BSky handle. For example 'did:plc:abc123'
// See https://atproto.com/specs/handle#handle-resolution
// Generate locally or from https://cryptotools.net/rsagen
// Newlines must be replaced with "\n"
$key_private = str_replace('\n', "\n", $env["KEY_PRIVATE"] );
$key_public = str_replace('\n', "\n", $env["KEY_PUBLIC"] );
/** No need to edit anything below here. But please go exploring! **/
// Uncomment these headers if you want to run conformance tests using
// https://socialweb.coop/activitypub/actor/tester/
// header('Access-Control-Allow-Origin: *');
// header("Access-Control-Allow-Headers: accept, content-type");
// header("Vary: Origin");
// Internal data
$server = $_SERVER["SERVER_NAME"]; // Do not change this!
// Some requests require a User-Agent string.
// You can change these directories to something more suitable if you like.
"inbox" => "{$data}/inbox",
"followers" => "{$data}/followers",
"following" => "{$data}/following",
"logs" => "{$data}/logs",
"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 ); }
}
$input = file_get_contents( "php://input" );
$body = json_decode( $input, true );
$bodyData = print_r( $body, true );
// If the root has been requested, manually set the path to `/`
!empty( $_GET["path"] ) ? $path = $_GET["path"] : $path = "/";
case ".well-known/nodeinfo":
wk_nodeinfo(); // Optional. Static.

Chris Neale
committed
case ".well-known/host-meta":
meta(); // Optional? Static.
case ".well-known/atproto-did":
atproto(); // Optional https://atproto.com/specs/handle#handle-resolution.
case "@" . rawurldecode( $username ): // Some software assumes usernames start with an `@`
following(); // Mandatory. Can be static or dynamic.
followers( $path ); // Mandatory. Can be static or dynamic.
case "followers_synchronization" : // Optional. Used for showing followers
followers_synchronization();
case "action/send":
send(); // API for posting content to the Fediverse.
case "action/users":
action_users(); // API for interacting with Fediverse users.
view( "home" );// User interface for seeing what the user has posted.
// 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.
function webfinger() {
global $username, $server;
$webfinger = array(
"subject" => "acct:{$username}@{$server}",
"links" => array(
array(
"rel" => "self",
"type" => "application/activity+json",
"href" => "https://{$server}/{$username}"
)
)
);
// User:
// Requesting `example.com/username` returns a JSON document with the user's information.
global $username, $realName, $summary, $birthday, $oldName, $server, $key_public;
// Was HTML requested?
// If so, probably a browser. Redirect to homepage.
foreach (getallheaders() as $name => $value) {
if ("Accept" == $name) {
$accepts = explode( ",", $value );
if ( "text/html" == $accepts[0] ) {
header( "Location: https://{$server}/" );
die();
}
if ( "" != $oldName ) {
$aka = [ "{$oldName}" ];
} else {
$aka = [];
}
$user = array(
"@context" => [
"https://www.w3.org/ns/activitystreams",
"https://w3id.org/security/v1"
],
"id" => "https://{$server}/{$username}",
"following" => "https://{$server}/following",
"followers" => "https://{$server}/followers",
"inbox" => "https://{$server}/inbox",
"url" => "https://{$server}/{$username}",
"icon" => [
"type" => "Image",
"mediaType" => "image/png",
"url" => "https://{$server}/icon.png"
],
"image" => [
"type" => "Image",
"mediaType" => "image/png",
"url" => "https://{$server}/banner.png"
],
"publicKey" => [
"id" => "https://{$server}/{$username}#main-key",
"owner" => "https://{$server}/{$username}",
"publicKeyPem" => $key_public
]
);
// 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.
global $server, $directories;
// Get all the files
$following_files = glob( $directories["following"] . "/*.json" );
// Sort users by most recent first
usort( $following_files, function( $a, $b ) {
return filemtime($b) - filemtime($a);
});
// Create a list of all accounts being followed
$items = array();
foreach ( $following_files as $following_file ) {
$following = json_decode( file_get_contents( $following_file ), true );
$following = array(
"@context" => "https://www.w3.org/ns/activitystreams",
"id" => "https://{$server}/following",
"totalItems" => $totalItems,
"items" => $items
echo json_encode( $following );
die();
}
function followers() {
global $server, $directories, $username;
// The number of followers is self-reported.
// You can set this to any number you like.
// Sort users by most recent first
usort( $follower_files, function( $a, $b ) {
return filemtime($b) - filemtime($a);
});
$items = array();
foreach ( $follower_files as $follower_file ) {
$following = json_decode( file_get_contents( $follower_file ), true );
// Which page, if any, has been requested
if ( !isset($_GET["page"]) ) {
// // No page, so show the link to the pagination
// $followers = array(
// "@context" => ["https://www.w3.org/ns/activitystreams"],
// "id" => "https://{$server}/followers",
// "actor" => "https://{$server}/{$username}",
// "type" => "OrderedCollection",
// "totalItems" => $totalItems,
// "first" => "https://{$server}/followers?page=1"
// );
// header( "Content-Type: application/activity+json" );
// echo json_encode( $followers );
// die();
$page = 1;
$id = "https://{$server}/followers";
$id = "https://{$server}/followers?page={$page}";
$batchSize = 20;
$batches = array_chunk( $items, $batchSize );
$followers_batch = $batches[$page - 1];
"@context" => ["https://www.w3.org/ns/activitystreams"],
"id" => $id,
"actor" => "https://{$server}/{$username}",
"type" => "OrderedCollectionPage",
"totalItems" => $totalItems,
"partOf" => "https://{$server}/followers",
"orderedItems" => $followers_batch,
"first" => "https://{$server}/followers?page=1"
// Next and Prev pagination links if necessary
if ( $page < sizeof( $batches ) ) {
$followers["next"] = "https://{$server}/followers?page=" . $page + 1;
}
if ( $page > 1 ) {
$followers["prev"] = "https://{$server}/followers?page=" . $page - 1;
}
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
function followers_synchronization() {
// This is a Follower Synchronisation request
// https://docs.joinmastodon.org/spec/activitypub/#follower-synchronization-mechanism
global $directories;
// Host that made the request can be found in the signature
// 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 );
// Examine the signature
$signatureHeader = $headers["signature"];
// Extract key information from the Signature header
$signatureParts = [];
// Converts 'a=b,c=d e f' into ["a"=>"b", "c"=>"d e f"]
// word="text"
preg_match_all('/(\w+)="([^"]+)"/', $signatureHeader, $matches);
foreach ( $matches[1] as $index => $key ) {
$signatureParts[$key] = $matches[2][$index];
}
// Get the Public Key
// The link to the key might be sent with the body, but is always sent in the Signature header.
// This is usually in the form `https://example.com/user/username#main-key`
$publicKeyURL = $signatureParts["keyId"];
// Finally, get the host
$host = parse_url( $publicKeyURL, PHP_URL_HOST );
// Is the domain host name valid?
$host = filter_var( $host, FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME );
if ( $host == false ) {
// Something dodgy is going on!
header( "HTTP/1.1 401 Unauthorized" );
echo "Bad domain value";
die();
}
// Get all the follower files
$follower_files = glob( $directories["followers"] . "/*.json" );
// Return only the users from this domain
$items_filtered = [];
foreach ( $follower_files as $follower_file ) {
$following = json_decode( file_get_contents( $follower_file ), true );
$following_id = $following["id"];
$following_host = parse_url( $following_id )["host"];
if ( $host === $following_host ) {
$items_filtered[] = $following_id;
}
}
$followers = array(
"@context" => "https://www.w3.org/ns/activitystreams",
"id" => "https://{$server}/followers?domain={$host}",
"type" => "OrderedCollection",
"orderedItems" => $items_filtered
);
header( "Content-Type: application/activity+json" );
echo json_encode( $followers );
die();
}
// Inbox:
// The `/inbox` is the main server. It receives all requests.
global $body, $server, $username, $key_private, $directories;
// Get the message, type, and ID
$inbox_message = $body;
$inbox_type = $inbox_message["type"];
// This inbox only sends responses to follow / unfollow requests.
// A remote server sends the inbox a 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.
if ( "Follow" == $inbox_type ) {
// Validate HTTP Message Signature
if ( !verifyHTTPSignature() ) {
header("HTTP/1.1 401 Unauthorized");
die();
}
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
// Get the parameters
$follower_id = $inbox_message["id"]; // E.g. https://mastodon.social/(unique id)
$follower_actor = $inbox_message["actor"]; // E.g. https://mastodon.social/users/Edent
// Get the actor's profile as JSON
$follower_actor_details = getDataFromURl( $follower_actor );
// 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 ) );
// Get the new follower's Inbox
$follower_inbox = $follower_actor_details["inbox"];
// 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
$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,
"type" => $inbox_type,
"actor" => $follower_actor,
"object" => "https://{$server}/{$username}",
]
];
// The Accept is POSTed to the inbox on the server of the user who requested the follow
sendMessageToSingle( $follower_inbox, $message );
// Messages to ignore.
// Some servers are very chatty. They send lots of irrelevant messages.
// Before even bothering to validate them, we can delete them.
// This server doesn't handle Add, Remove, Reject, Favourite, Replies, Repost
// See https://www.w3.org/wiki/ActivityPub/Primer
if ( "Add" == $inbox_type ||
"Remove" == $inbox_type ||
"Reject" == $inbox_type ||
"Like" == $inbox_type ||
"Create" == $inbox_type ||
"Announce" == $inbox_type ) {
// TODO: Better HTTP header
die();
}
// Get a list of every account following us
// Get all the files
$followers_files = glob( $directories["followers"] . "/*.json" );
// Create a list of all accounts being followed
$followers_ids = array();
foreach ( $followers_files as $follower_file ) {
$follower = json_decode( file_get_contents( $follower_file ), true );
$followers_ids[] = $follower["id"];
}
// Is this from someone following us?
$from_follower = in_array( $inbox_message["actor"], $followers_ids );
// As long as one of these is true, the server will process it
// Don't bother processing it at all.
die();
}
// Validate HTTP Message Signature
if ( !verifyHTTPSignature() ) { die(); }
// If this is an Undo (Unfollow) try to process it
if ( "Undo" == $inbox_type ) {
$uuid = uuid( $inbox_message );
$inbox_filename = $uuid . "." . urlencode( $inbox_type ) . ".json";
file_put_contents( $directories["inbox"] . "/{$inbox_filename}", json_encode( $inbox_message ) );
// 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.
function uuid( $message = null) {
// UUIDs that this server *sends* will be [timestamp]-[random]
// 65e99ab4-5d43-f074-b43e-463f9c5cf05c
if ( is_null( $message ) ) {
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)
);
} else {
// UUIDs that this server *saves* will be [timestamp]-[hash of message ID]
// 65eadace-8f434346648f6b96df89dda901c5176b10a6d83961dd3c1ac88b59b2dc327aa4
// The message might have its own object
if ( isset( $message["object"]["id"] ) ) {
$id = $message["object"]["id"];
} else {
$id = $message["id"];
}
return sprintf( "%08x", time() ) . "-" . hash( "sha256", $id );
}
// Every message that your server sends needs to be cryptographically signed with your Private Key.
// This is a complicated process.
// Please read https://blog.joinmastodon.org/2018/07/how-to-make-friends-and-verify-requests/ for more information.
function generate_signed_headers( $message, $host, $path, $method ) {
global $server, $username, $key_private;
$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 . '"';
// Add Follower Synchronization Header
// https://docs.joinmastodon.org/spec/activitypub/#follower-synchronization-mechanism
$sync = "collectionId='https://{$server}/followers," .
"url='https://{$server}/{$username}/followers_synchronization'," .
"digest='" . followers_digest( $host ) . "'";
"Host: {$host}",
"Date: {$date}",
"Digest: SHA-256={$digest}",
"Signature: {$signature_header}",
// 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 . '"';
$headers = array(
"Host: {$host}",
"Date: {$date}",
"Signature: {$signature_header}",
"Accept: application/activity+json, application/json",
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
function followers_digest( $host ) {
global $server, $directories;
// Get all the current followers
$follower_files = glob( $directories["followers"] . "/*.json" );
// Return only the users from this domain
$items_filtered = [];
foreach ( $follower_files as $follower_file ) {
$following = json_decode( file_get_contents( $follower_file ), true );
$following_id = $following["id"];
$following_domain = parse_url( $following_id )["host"];
if ( $host === $following_domain) {
$items_filtered[] = $following_id;
}
}
// Get the XOR of the SHA256 hashes
$xor_result = array_fill( 0, 32, 0 ); // SHA256 hashes are 32 bytes long
foreach ( $items_filtered as $item ) {
$hash = hash( "sha256", $item, true );
for ( $i = 0; $i < 32; $i++ ) {
$xor_result[$i] ^= ord($hash[$i]); // XOR each byte
}
}
// Return the hex representation
return bin2hex( implode( "", array_map( "chr", $xor_result ) ) ); // Convert to hex
}

Chris Neale
committed
// Mastodon .well-known/host-meta
function meta() {
global $server;
header('Content-Type: application/xrd+xml');
echo <<< XML
<?xml version="1.0" encoding="UTF-8"?>
<XRD xmlns="http://docs.oasis-open.org/ns/xri/xrd-1.0">
<Link rel="lrdd" template="https://{$server}/.well-known/webfinger?resource={uri}"/>
</XRD>
XML;
die();
}
// This creates a basic HTML page. This content appears when someone visits the root of your site.
function view( $style ) {
global $username, $server, $realName, $summary, $directories;
$rawUsername = rawurldecode( $username );
$h1 = "HomePage";
$directory = "posts";
// Counters for followers, following, and posts
$follower_files = glob( $directories["followers"] . "/*.json" );
$totalFollowers = count( $follower_files );
$following_files = glob( $directories["following"] . "/*.json" );
$totalFollowing = count( $following_files );
<!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>
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
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/activity-bot/">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>
// Get all the files in the directory
$message_files = array_reverse( glob( "posts" . "/*.json") );
// There are lots of messages. The UI will only show 200.
$message_files = array_slice( $message_files, 0, 1000 );
// Loop through the messages, get their conent:
// Ensure messages are in the right order.
$messages_ordered = [];
foreach ( $message_files as $message_file ) {
// Split the filename
$type = $file_parts[1];
// Get the contents of the JSON
$message = json_decode( file_get_contents( $message_file ), true );
$published = $message["published"];
// Place in an array where the key is the timestamp
$messages_ordered[$published] = $message;
}
// 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 $message ) {
// The object of this *is* the message
$object = $message;
// Is this a Direct Messsage?
$to = $object["to"];
if ( $to[0] != "https://www.w3.org/ns/activitystreams#Public" ) {
// Don't display it to the public
continue;
}
// Get basic details
$id = $object["id"];
$published = $object["published"];
// HTML for who wrote this
$publishedHTML = "<a href=\"{$id}\">{$published}</a>";
// For displaying the post's information
$timeHTML = "<time datetime=\"{$published}\" class=\"u-url\" rel=\"bookmark\">{$publishedHTML}</time>";
// Get the actor who authored the message
$actor = $object["attributedTo"];
// Assume that what comes after the final `/` in the URl is the name
$actorArray = explode( "/", $actor );
$actorName = end( $actorArray );
$actorServer = parse_url( $actor, PHP_URL_HOST );
// Make i18n usernames readable and safe.
$actorName = htmlspecialchars( rawurldecode( $actorName ) );
$actorHTML = "<a href=\"$actor\">@{$actorName}</a>";
// What type of message is this?
$type = $message["type"];
// Get the HTML content
$content = $message["content"];
// Sanitise the HTML
$content = strip_tags( $content, $allowed_elements );
// Is there is a Content Warning?
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>";
}
// 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"] ) ) {
$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 = htmlspecialchars( $attachment["name"] ) : $alt = "";
$content .= "<img src='{$mediaURl}' alt='{$alt}'>";
} else if ( "video" == $mediaType ) {
$content .= "<video controls><source src='{$mediaURl}' type='{$mime}'></video>";
}else if ( "audio" == $mediaType ) {
$content .= "<audio controls src='{$mediaURl}' type='{$mime}'></audio>";
}
}
}
$verb = "posted";
$messageHTML = "{$timeHTML} {$actorHTML} {$verb}: <blockquote class=\"e-content\">{$content}</blockquote>";
echo "<li><article class=\"h-entry\">{$messageHTML}<br></article></li>";
// Send Endpoint:
// This takes the submitted message and checks the password is correct.
// 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.
global $password, $server, $username, $key_private, $directories, $language;
// Does the posted password match the stored password?
if( $password != $_POST["password"] ) {
header("HTTP/1.1 401 Unauthorized");
echo "Wrong password.";
die();
}
// Get the posted content
$content = $_POST["content"];
// Process the content into HTML to get hashtags etc
list( "HTML" => $content, "TagArray" => $tags ) = process_content( $content );
// Is this a reply?
if ( isset( $_POST["inReplyTo"] ) && filter_var( $_POST["inReplyTo"], FILTER_VALIDATE_URL ) ) {
$inReplyTo = $_POST["inReplyTo"];
} else {
$inReplyTo = null;
}
// Is this a Direct Message?
// https://seb.jambor.dev/posts/understanding-activitypub/#direct-messages
if ( isset( $_POST["DM"] ) ) {
$dm_name = $_POST["DM"];
$details = getUserDetails( $dm_name );
$profileInbox = $details["inbox"];
$to = $details["profile"];
$cc = null;
$tags[] = array( "type" => "Mention", "href" => $to, "name" => $dm_name );
} else {
// This is a public message
$to = "https://www.w3.org/ns/activitystreams#Public";
$cc = "https://{$server}/followers";
}
// 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 );
$image_full_path = $directories["images"] . "/{$sha1}{$image_ext}";
// Move media to the correct location
move_uploaded_file( $image, $image_full_path );
// Get the alt text
if ( isset( $_POST["alt"] ) ) {
$alt = $_POST["alt"];
// Construct the attachment value for the post
$attachment = array( [
"type" => "Image",
"mediaType" => "{$image_mime}",
"url" => "https://{$server}/{$image_full_path}",
"name" => $alt
] );
} else {
$attachment = [];
}
// 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
$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,
"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}",
// Save the permalink
$note_json = json_encode( $note );
file_put_contents( $directories["posts"] . "/{$guid}.json", print_r( $note_json, true ) );
// Send the message either publicly or privately
if ( $cc == null ) {
$messageSent = sendMessageToSingle( $profileInbox, $message );
} else {
// Send to all the user's followers
$messageSent = sendMessageToFollowers( $message );
}
// Return 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
// Used for sending Accept messages to follow requests
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_setopt( $ch, CURLOPT_USERAGENT, USERAGENT );