diff --git a/Apache.md b/Apache.md
new file mode 100644
index 0000000..21d0acd
--- /dev/null
+++ b/Apache.md
@@ -0,0 +1,17 @@
+# Using this bot with Apache webserver
+
+To aid anyone else who wants to use this simple bot system on a site using Apache, here are the ReWrite rules
+that we use.
+
+The files from this code are installed in DOCUMENT_ROOT/activitypub. We want to provide a users link that doesn't
+necessarily reveal the install path, and we also need to make sure webfinger can be found under .well-known
+
+(Using these rules is why we also have a change in functions.php from the original, to detect the correct path
+when webfinger is called).
+
+Assuming you have RewriteEngine On already in your site config, add these two rules
+
+ # For ActivityPub
+ RewriteRule ^/.well-known/webfinger /activitypub/webfinger.php [L,QSA]
+ RewriteRule ^/users/(.*) /activitypub/user.php?user=$1 [L]
+
diff --git a/POSTING.md b/POSTING.md
new file mode 100644
index 0000000..3007a16
--- /dev/null
+++ b/POSTING.md
@@ -0,0 +1,60 @@
+# Mastodon post example
+
+This is an example to show how a post can be formatted so that it displays with a summary text, an image, and a link in the text body.
+Our use case is posting details of forthcoming events from our calendar. The formatting is aimed at Mastodon, but should work find on
+other fedi platforms. On Mastodon, the summary (which includes event name and date) will appear as a content warning, with the rest
+of the text collapsed; this avoids cluttering up timelines with long event descriptions if people don't want to read them.
+
+ {
+ "type": "Note",
+ "summary": "Tom of Finland Art & Culture Festival and HUNTER - Launch Party, 21 July 2023",
+ "attachment": {
+ "summary": "HUNTER, Electrowerkz, Islington",
+ "url": "https://bluf.com/photos/events/4662/f5255291-056f-45a9-840d-6723665b79a8_1_201_a.jpg"
+ },
+ "content": "
HUNTER Invites you to the Opening Party of the Tom Of Finland Arts & Culture Festival and launch of HUNTER, your new Hardcore Leather F*tish night.
\n@electrowerkz_ teams up with infamous @brewhunter.dom (who reignited London’s Leather scene with his now-fabled night MASTERY) to present an explosive night on the London scene. HUNTER is also proud to be the Opening Party for Tom Of Finland Art & Culture London Festival 2023.
\nThe Launch will feature a welcome hour zone, but as darkness falls, the CIGAR ZONE, MASTERY ZONE and DARK ZONE (hardcore dress code only) will open for The Hunt. Vintage Cinema, cigars, crusing, cttaging, dungeon and hardcore play.
\n9p-10p FREE ENTY RELAXED DRESS CODE FOR WELCOME HOUR ONLY (STRICT DRESS CODE REQUIRED ALL OTHER ZONES, ACCESS AT 10p)
\n10p-3a £10 LAUNCH PARTY TICKETS HUNTER HARDCORE
\nMore info: SMOKINLEATHER (2316)
Electrowerkz, 7 Torrens St, London, United Kingdom
View on bluf.com"
+ }
+
+You can post an item like this - assuming it's saved in a file called event.json with curl from the command line:
+
+ curl -X POST -H 'X-API-KEY: {actor POST key}' https://example.com/{install path}/post.php -d @event.json
+
+## Automated posting
+We use this on BLUF.com, where our site has an event object, to which we have added method asActivityPub, which returns JSON formatted as
+above for an event with an image, or a simple note, for events without an image. Automating posts via the phpActivityPub is simple. You can see our bot in action as @events@bluf.com from your favourite Fediverse platform.
+
+Here's the code-snippets:
+
+ define('FEDI_URL','https://example.com//path/to/activityPub/post.php') ;
+ define('APIKEY','POST_KEY for your actor') ;
+
+ while ($event = $events->fetch_assoc()) {
+ $e = new \BLUF\Calendar\event($event['id']) ;
+ fedi_post($e->asActivityPub()) ;
+
+ sleep(rand(5, 20)) ; // avoid flooding
+ }
+
+ function fedi_post($note)
+ {
+ $curl = curl_init() ;
+
+ curl_setopt_array($curl, [
+ CURLOPT_URL => FEDI_URL,
+ CURLOPT_RETURNTRANSFER => true,
+ CURLOPT_ENCODING => "",
+ CURLOPT_MAXREDIRS => 10,
+ CURLOPT_TIMEOUT => 30,
+ CURLOPT_POSTFIELDS => $note,
+ CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
+ CURLOPT_HTTPHEADER => [
+ "Content-Type: application/json;charset=utf-8",
+ "X-API-KEY: " . APIKEY
+ ],
+ ]);
+
+ $response = curl_exec($curl);
+ $err = curl_error($curl);
+
+ curl_close($curl);
+ }
diff --git a/README.md b/README.md
index f34186f..854fd31 100644
--- a/README.md
+++ b/README.md
@@ -49,3 +49,24 @@ creates a Note (the default) with the text "Hello, world!"
```
etc.
+
+See [POSTING.md](POSTING.md) for a more detailed example.
+
+# Nigel Whitfield updates, July 2023
+This fork provides some additional functionality for bots. It additionally addresses a couple of minor issues relevant to my own installation, viz database creation, and an Apache re-write. For details of rewrite rules, have a look at [Apache.md](Apache.md)
+
+Additonal features:
+
+### Admin profile page
+In [admin/profile.php](admin/profile.php) you can populate some of the information that will be displayed when people search for your actor.
+
+### User profile page
+Created by [user.php](user.php), which displays a barebones profile, inlcuding list of followers. You can use the profile admin
+page to set the URL of this for each user you create. This solves the problem where, for example, a follower on a remote Mastodon instance can't see who's following your bot, because that's not stored locally. Mastodon displays the 'url' of the actor as link to
+the original profile.
+
+### Inbox detection of DMs
+The latest update to [inbox.php](inbox.php) detects when a DM is sent to one of your actors, and then hands the content off to
+a parse_content function in the include file [dm_parser.php](dm_parser.php). This is intended to keep some site specific code
+separate from the rest of the functionality. The example included allows followers to request a summary of events in a particular
+city by sending a DM to our bot with the name of the city.
diff --git a/actor.php b/actor.php
index c9788dd..023eae3 100644
--- a/actor.php
+++ b/actor.php
@@ -50,59 +50,96 @@
// check query parameters and extract userpart
if (! empty($_GET['user'])) {
- if (strtolower($_GET['user']) === $_SERVER['SERVER_NAME']) {
- // instance actor - always served without needing signature
- $db = new SQLite3("admin/db.sqlite3", SQLITE3_OPEN_READONLY);
- $info = query($db, 'SELECT rsa_public FROM instance');
- $db->close();
-
- if (count($info) > 0) {
- // The instance actor has been set up properly.
- $user = $_SERVER['SERVER_NAME'];
- $type = 'Application';
- $rsa_public = $info[0]['rsa_public'];
- } else {
- response(500, [ 'error' => 'Instance actor has not been properly configured.' ]);
- }
+ if (strtolower($_GET['user']) === $_SERVER['SERVER_NAME']) {
+ // instance actor - always served without needing signature
+ $db = new SQLite3("admin/db.sqlite3", SQLITE3_OPEN_READONLY);
+ $info = query($db, 'SELECT rsa_public FROM instance');
+ $db->close();
+
+ if (count($info) > 0) {
+ // The instance actor has been set up properly.
+ $user = $_SERVER['SERVER_NAME'];
+ $type = 'Application';
+ $rsa_public = $info[0]['rsa_public'];
+ } else {
+ response(500, [ 'error' => 'Instance actor has not been properly configured.' ]);
+ }
+ } else {
+ // TODO: Verify signature
+
+ // connect to db, look up user info (verify the actor exists)
+ $db = new SQLite3("admin/db.sqlite3", SQLITE3_OPEN_READONLY);
+ $info = query($db, 'SELECT user, type, rsa_public FROM acct WHERE user=?', $_GET['user']);
+ $db->close();
+
+ if (count($info) > 0) {
+ // Found an account by this name.
+ $user = $info[0]['user'];
+ $type = $info[0]['type'];
+ $rsa_public = $info[0]['rsa_public'];
} else {
- // TODO: Verify signature
-
- // connect to db, look up user info (verify the actor exists)
- $db = new SQLite3("admin/db.sqlite3", SQLITE3_OPEN_READONLY);
- $info = query($db, 'SELECT user, type, rsa_public FROM acct WHERE user=?', $_GET['user']);
- $db->close();
-
- if (count($info) > 0) {
- // Found an account by this name.
- $user = $info[0]['user'];
- $type = $info[0]['type'];
- $rsa_public = $info[0]['rsa_public'];
- } else {
- response(404, [ 'error' => 'No such user ' . $_GET['user'] . ' in Actor request' ]);
- }
+ response(404, [ 'error' => 'No such user ' . $_GET['user'] . ' in Actor request' ]);
}
+ }
// Build the complete Actor doc and send it out.
- $actor = [
- "@context" => ["https://www.w3.org/ns/activitystreams", "https://w3id.org/security/v1"],
- "id" => $phpActivityPub_root . 'actor.php?user=' . $user,
- "type" => $type,
-
- "inbox" => $phpActivityPub_root . 'inbox.php?user=' . $user,
- "outbox" => $phpActivityPub_root . 'outbox.php?user=' . $user,
-
- "preferredUsername" => $user,
- "followers" => $phpActivityPub_root . 'followers.php?user=' . $user,
- "following" => $phpActivityPub_root . 'following.php?user=' . $user,
-
- "publicKey" => [
- "id" => $phpActivityPub_root . 'actor.php?user=' . $user . '#main-key',
- "owner" => $phpActivityPub_root . 'actor.php?user=' . $user,
- "publicKeyPem" => $rsa_public
- ]
- ];
-
- response(200, $actor);
+ $actor = [
+ "@context" => ["https://www.w3.org/ns/activitystreams", "https://w3id.org/security/v1"],
+ "id" => $phpActivityPub_root . 'actor.php?user=' . $user,
+ "type" => $type,
+
+ "inbox" => $phpActivityPub_root . 'inbox.php?user=' . $user,
+ "outbox" => $phpActivityPub_root . 'outbox.php?user=' . $user,
+
+ "preferredUsername" => $user,
+
+ "followers" => $phpActivityPub_root . 'followers.php?user=' . $user,
+ "following" => $phpActivityPub_root . 'following.php?user=' . $user,
+
+ "publicKey" => [
+ "id" => $phpActivityPub_root . 'actor.php?user=' . $user . '#main-key',
+ "owner" => $phpActivityPub_root . 'actor.php?user=' . $user,
+ "publicKeyPem" => $rsa_public
+ ]
+ ];
+
+ // NW: check for extended profile information
+ $db = new SQLite3("admin/db.sqlite3", SQLITE3_OPEN_READONLY);
+ $profile = query($db, 'SELECT summary, url, name, icon, homepage FROM profile WHERE user=?', $_GET['user']);
+ $db->close() ;
+
+ if (count($profile) > 0) {
+ // there is profile info for this user
+ if (strlen($profile[0]['summary']) > 0) {
+ $actor['summary'] = $profile[0]['summary'] ;
+ }
+ if (strlen($profile[0]['url']) > 0) {
+ $actor['url'] = $profile[0]['url'] ;
+ }
+ if (strlen($profile[0]['name']) > 0) {
+ $actor['name'] = $profile[0]['name'] ;
+ }
+ if (strlen($profile[0]['icon']) > 0) {
+ $icon = [
+ "type" => "Image",
+ "mediaType" => "image/png",
+ "url" => $profile[0]['icon']
+ ] ;
+ $actor['icon'] = $icon ;
+ }
+ if (strlen($profile[0]['homepage']) > 0) {
+ $homepage = [
+ "type" => "PropertyValue",
+ "name" => "Homepage",
+ "value" => "" . preg_replace('#http(s)?://#', '', $profile[0]['homepage']) . ""
+ ] ;
+
+ $actor['attachment'] = [ $homepage ] ;
+ }
+ }
+
+
+ response(200, $actor);
} else {
- response(400, [ 'error' => 'User missing from Actor request' ]);
+ response(400, [ 'error' => 'User missing from Actor request' ]);
}
diff --git a/admin/functions.php b/admin/functions.php
index c568d85..193c0aa 100644
--- a/admin/functions.php
+++ b/admin/functions.php
@@ -10,12 +10,23 @@
// SERVER_NAME is only defined when invoked through CGI.
// You may set a value here that be used for CLI calls.
if (empty($_SERVER['SERVER_NAME'])) {
- $_SERVER['SERVER_NAME'] = 'example.com';
+ $_SERVER['SERVER_NAME'] = 'example.com';
}
// Sets the "root" of phpActivityPub - that is, the hostname plus path to the
// PHP scripts (but not the scripts themselves)
-$phpActivityPub_root = 'https://' . $_SERVER['SERVER_NAME'] . preg_replace('#[^/]+\.php$#', '', $_SERVER['SCRIPT_NAME']);
+
+// NW; check to handle webfinger being redirected via Apache
+// for example, we have a rewrite rule like this:
+//
+// RewriteRule ^/.well-known/webfinger /activitypub/webfinger.php [L,QSA]
+//
+if (preg_match('#/webfinger.php$#', $_SERVER['SCRIPT_FILENAME'])) {
+ $filepath = preg_replace('#[^/]+\.php$#', '', $_SERVER['SCRIPT_FILENAME']);
+ $phpActivityPub_root = 'https://' . $_SERVER['SERVER_NAME'] . preg_replace('#' . $_SERVER['DOCUMENT_ROOT'] .'#', '', $filepath);
+} else {
+ $phpActivityPub_root = 'https://' . $_SERVER['SERVER_NAME'] . preg_replace('#[^/]+\.php$#', '', $_SERVER['SCRIPT_NAME']);
+}
// ////////////////
// TEMPLATE
@@ -23,12 +34,12 @@
// Send a JSON response and a status code, then exit
function response($code = 204, $object = null)
{
- http_response_code($code);
- if ($object) {
- header('Content-Type: application/json');
- echo json_encode($object, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
- }
- exit;
+ http_response_code($code);
+ if ($object) {
+ header('Content-Type: application/json');
+ echo json_encode($object, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
+ }
+ exit;
}
// ////////////////
@@ -38,33 +49,33 @@ function response($code = 204, $object = null)
function query($db, $sql, ...$params)
{
// prepare query and bind parameters
- $stmt = $db->prepare($sql);
+ $stmt = $db->prepare($sql);
// check that the right number of params were passed in
- if ($stmt->paramCount() != count($params)) {
- throw new Exception("Error in query '$sql': expected " . $stmt->paramCount() . " but got " . count($params));
- }
+ if ($stmt->paramCount() != count($params)) {
+ throw new Exception("Error in query '$sql': expected " . $stmt->paramCount() . " but got " . count($params));
+ }
- for ($i = 0; $i < count($params); $i++) {
- $stmt->bindParam($i + 1, $params[$i], gettype($params[$i]) == 'integer' ? SQLITE3_INTEGER : SQLITE3_TEXT);
- }
+ for ($i = 0; $i < count($params); $i++) {
+ $stmt->bindParam($i + 1, $params[$i], gettype($params[$i]) == 'integer' ? SQLITE3_INTEGER : SQLITE3_TEXT);
+ }
- $result = $stmt->execute();
+ $result = $stmt->execute();
- $response = array();
+ $response = array();
// a FALSE value here might indicate a db error but we should not explode
// e.g. in case of duplicate key or something non-fatal
- if ($result) {
- // retrieve all responses
- while ($info = $result->fetchArray(SQLITE3_ASSOC)) {
- array_push($response, $info);
- }
- $result->finalize();
+ if ($result) {
+ // retrieve all responses
+ while ($info = $result->fetchArray(SQLITE3_ASSOC)) {
+ array_push($response, $info);
}
+ $result->finalize();
+ }
// close everything and return
- $stmt->close();
+ $stmt->close();
- return $response;
+ return $response;
}
// ////////////////
@@ -78,96 +89,96 @@ function request($user, $url, $body = null)
{
// fetch the private key: we need these to encode the message headers
- $db = new SQLite3("admin/db.sqlite3", SQLITE3_OPEN_READONLY);
- $result = query($db, 'SELECT rsa_private FROM acct WHERE user=?', $user);
- if (count($result) == 1) {
- $keypair = $result[0]['rsa_private'];
- } else {
- throw new Exception("Failed to get rsa_private for user $user");
- }
- $db->close();
+ $db = new SQLite3("admin/db.sqlite3", SQLITE3_OPEN_READONLY);
+ $result = query($db, 'SELECT rsa_private FROM acct WHERE user=?', $user);
+ if (count($result) == 1) {
+ $keypair = $result[0]['rsa_private'];
+ } else {
+ throw new Exception("Failed to get rsa_private for user $user");
+ }
+ $db->close();
// Break the URL into components for putting into headers
- $components = parse_url($url);
+ $components = parse_url($url);
// Build headers and signature string
// get today's date as well
- $date = gmdate('D, d M Y H:i:s T');
+ $date = gmdate('D, d M Y H:i:s T');
// concatenate it all together
- $signed_string = "(request-target): " . ($body ? 'post' : 'get') . ' ' . $components['path'] .
- "\nhost: " . $components['host'] .
- "\ndate: " . $date;
- if ($body) {
- // create digest of the document
- $digest = 'SHA-256=' . base64_encode(hash('sha256', $body, true));
- $signed_string .= "\ndigest: " . $digest;
- }
+ $signed_string = "(request-target): " . ($body ? 'post' : 'get') . ' ' . $components['path'] .
+ "\nhost: " . $components['host'] .
+ "\ndate: " . $date;
+ if ($body) {
+ // create digest of the document
+ $digest = 'SHA-256=' . base64_encode(hash('sha256', $body, true));
+ $signed_string .= "\ndigest: " . $digest;
+ }
// SIGN THE HEADERS
- openssl_sign($signed_string, $signature, $keypair, OPENSSL_ALGO_SHA256);
+ openssl_sign($signed_string, $signature, $keypair, OPENSSL_ALGO_SHA256);
// Create the final header with signature
- $header = 'keyId="' . $GLOBALS['phpActivityPub_root'] . 'actor.php?user=' . $user . '#main-key",headers="(request-target) host date' . ($body ? ' digest' : '') . '",signature="' . base64_encode($signature) . '"';
+ $header = 'keyId="' . $GLOBALS['phpActivityPub_root'] . 'actor.php?user=' . $user . '#main-key",headers="(request-target) host date' . ($body ? ' digest' : '') . '",signature="' . base64_encode($signature) . '"';
// READY TO POST!
//open connection
- $ch = curl_init($url);
-
- curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
- curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
- curl_setopt($ch, CURLOPT_MAXREDIRS, 50);
-
- #'Host: ' . $components['host'],
- $headers = array(
- 'Accept: application/ld+json; profile="https://www.w3.org/ns/activitystreams"',
- 'Date: ' . $date,
- 'Signature: ' . $header
+ $ch = curl_init($url);
+
+ curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
+ curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
+ curl_setopt($ch, CURLOPT_MAXREDIRS, 50);
+
+ #'Host: ' . $components['host'],
+ $headers = array(
+ 'Accept: application/ld+json; profile="https://www.w3.org/ns/activitystreams"',
+ 'Date: ' . $date,
+ 'Signature: ' . $header
+ );
+
+ if ($body) {
+ curl_setopt($ch, CURLOPT_POST, true);
+ curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
+ array_push(
+ $headers,
+ 'Content-Type: application/ld+json; profile="https://www.w3.org/ns/activitystreams"',
+ 'Digest: ' . $digest
);
+ };
- if ($body) {
- curl_setopt($ch, CURLOPT_POST, true);
- curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
- array_push(
- $headers,
- 'Content-Type: application/ld+json; profile="https://www.w3.org/ns/activitystreams"',
- 'Digest: ' . $digest
- );
- };
-
- curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
-
- error_log("REQUEST: $url, headers:" . print_r($headers, true));
- if ($body) {
- error_log("body: $body");
- }
-
- //execute the request!
- $result = curl_exec($ch);
+ curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
- if ($result === false) {
- $e = curl_errno($ch);
- $err_str = "cURL error: ($e: " . curl_strerror($e) . "): " . curl_error($ch);
- curl_close($ch);
+ error_log("REQUEST: $url, headers:" . print_r($headers, true));
+ if ($body) {
+ error_log("body: $body");
+ }
- throw new Exception($err_str);
- }
+ //execute the request!
+ $result = curl_exec($ch);
- $responseCode = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
+ if ($result === false) {
+ $e = curl_errno($ch);
+ $err_str = "cURL error: ($e: " . curl_strerror($e) . "): " . curl_error($ch);
curl_close($ch);
- if ($responseCode < 200 || $responseCode >= 300) {
- throw new Exception("cURL error: Server responded $responseCode: $result");
- }
- if ($result) {
- error_log("Received result: $result");
- $json_response = json_decode($result, true);
- if (! $json_response) {
- throw new Exception("json_decode error: Failed to decode '$result'");
- }
- return $json_response;
+
+ throw new Exception($err_str);
+ }
+
+ $responseCode = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
+ curl_close($ch);
+ if ($responseCode < 200 || $responseCode >= 300) {
+ throw new Exception("cURL error: Server responded $responseCode: $result");
+ }
+ if ($result) {
+ error_log("Received result: $result");
+ $json_response = json_decode($result, true);
+ if (! $json_response) {
+ throw new Exception("json_decode error: Failed to decode '$result'");
}
- return array();
+ return $json_response;
+ }
+ return array();
}
// Given an Actor URL, get some info we need to post to them
@@ -177,13 +188,13 @@ function request($user, $url, $body = null)
function get_inbox($user, $url)
{
// use the instance actor for this req
- $json_content = request($user, $url);
+ $json_content = request($user, $url);
- if ($json_content['inbox']) {
- return $json_content['inbox'];
- }
+ if ($json_content['inbox']) {
+ return $json_content['inbox'];
+ }
- throw new Exception("Failed to look up actor for " . $url . ": " . $json_content);
+ throw new Exception("Failed to look up actor for " . $url . ": " . $json_content);
}
// Send an Activity to a remote server.
@@ -192,12 +203,12 @@ function sendActivity($user, $dest, $activity)
{
// right FIRST of all, we need the destination inbox
- $dest = get_inbox($user, $dest);
+ $dest = get_inbox($user, $dest);
- $document = json_encode(array_merge([
- '@context' => 'https://www.w3.org/ns/activitystreams',
- 'actor' => $GLOBALS['phpActivityPub_root'] . 'actor.php?user=' . $user
- ], $activity), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
+ $document = json_encode(array_merge([
+ '@context' => 'https://www.w3.org/ns/activitystreams',
+ 'actor' => $GLOBALS['phpActivityPub_root'] . 'actor.php?user=' . $user
+ ], $activity), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
- return request($user, $dest, $document);
+ return request($user, $dest, $document);
}
diff --git a/admin/index.php b/admin/index.php
index 795868d..fbf1733 100644
--- a/admin/index.php
+++ b/admin/index.php
@@ -1,17 +1,21 @@
-
- phpAP Admin
-
+ phpAP Admin
+ close();
?>
-
-
- phpAP Admin
-
- Instance Actor
- Public key:
-
-
- User List
-
- | User | Type | Post Key |
-
+
+
+ phpAP Admin
+
+ Instance Actor
+ Public key:
+
+
+ User List
+
+
+ | User |
+ Type |
+ Post Key |
+
+ ', $a['user'], ' | ', $a['type'], ' | ', $a['key'], " | \n";
+ // NW - added link to profile script
+ echo '| ', $a['user'], ' | ', $a['type'], ' | ', $a['key'], " | Profile |
\n";
}
?>
-
-
- Add User
-
-
-
+
+
+ Add User
+
+
+
+