From f1669d6e83f0d8be9e6ef829e135c348fdc9654c Mon Sep 17 00:00:00 2001 From: nat Date: Sun, 1 Sep 2024 11:54:41 -0700 Subject: [PATCH] init --- .gitignore | 4 + README.md | 30 ++++++++ bin/addstatus.php | 18 +++++ bin/adduser.php | 61 +++++++++++++++ bin/deluser.php | 47 ++++++++++++ bin/hostasctl | 13 ++++ bin/pen.php | 87 ++++++++++++++++++++++ database.ddl | 89 ++++++++++++++++++++++ etc/config.example.php | 12 +++ etc/definitions.php | 14 ++++ etc/nginx.example.conf | 87 ++++++++++++++++++++++ init.php | 30 ++++++++ public/api/v1/actor.php | 128 ++++++++++++++++++++++++++++++++ public/api/v1/database.php | 23 ++++++ public/api/v1/router.php | 15 ++++ public/api/webfinger-lookup.php | 49 ++++++++++++ public/index.php | 3 + 17 files changed, 710 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 bin/addstatus.php create mode 100644 bin/adduser.php create mode 100644 bin/deluser.php create mode 100755 bin/hostasctl create mode 100644 bin/pen.php create mode 100644 database.ddl create mode 100644 etc/config.example.php create mode 100644 etc/definitions.php create mode 100644 etc/nginx.example.conf create mode 100644 init.php create mode 100644 public/api/v1/actor.php create mode 100644 public/api/v1/database.php create mode 100644 public/api/v1/router.php create mode 100755 public/api/webfinger-lookup.php create mode 100755 public/index.php diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2ab7186 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +etc/keys/* +etc/nginx.conf + +public/config.php diff --git a/README.md b/README.md new file mode 100644 index 0000000..d22861b --- /dev/null +++ b/README.md @@ -0,0 +1,30 @@ +# hostas2 + +A backend for your ActivityPub-enabled website + +## Setup + +For Nginx: + +```bash +export HOSTAS_ROOT=/path/to/root + +# Create the database +sudo php init.php + +# Link the necessary components around the file system +ln -s $HOSTAS_ROOT/etc /etc/hostas +ln -s $HOSTAS_ROOT/etc/nginx.conf /etc/nginx/sites-enabled/example.net +ln -s $HOSTAS_ROOT/public /var/www/example.net + +# Restart Nginx +sudo service nginx restart + +# Generate the signing keys for activities +openssl genrsa -out $HOSTAS_ROOT/etc/keys/private.pem 2048 +openssl rsa -in $HOSTAS_ROOT/etc/keys/private.pem -outform PEM -pubout -out $HOSTAS_ROOT/etc/keys/public.pem + +# Create and set up the config +cp $HOSTAS_ROOT/etc/config.example.php $HOSTAS_ROOT/public/config.php +vim $HOSTAS_ROOT/public/config.php +``` diff --git a/bin/addstatus.php b/bin/addstatus.php new file mode 100644 index 0000000..20e4ec8 --- /dev/null +++ b/bin/addstatus.php @@ -0,0 +1,18 @@ +prepare(" + insert into object(id, type, name, summary, url, icon) + values (:id, 'Person', :name, :summary, :url, :icon); +"); + +$person_creation_stmt = $conn->prepare(" + insert into actor(objectId, preferredUsername) + values (:objectId, :preferredUsername); +"); + +$object_creation_stmt->bindValue(':id', $id); +$object_creation_stmt->bindValue(':name', $name); +$object_creation_stmt->bindValue(':summary', $summary); +$object_creation_stmt->bindValue(':url', $url); +$object_creation_stmt->bindValue(':icon', $icon_url); + +$object_creation_result = $object_creation_stmt->execute(); + +if (!$object_creation_result) { + echo 'Error: failed to insert object.' . PHP_EOL; + die(); +} + +$person_creation_stmt->bindValue(':objectId', $id); +$person_creation_stmt->bindValue(':preferredUsername', $preferred_username); + +$person_creation_result = $person_creation_stmt->execute(); + +if (!$person_creation_result) { + echo 'Error: failed to insert person.' . PHP_EOL; + die(); +} + +echo "Actor with ID $id created successfully!" . PHP_EOL; + +$conn->close(); diff --git a/bin/deluser.php b/bin/deluser.php new file mode 100644 index 0000000..61b54b5 --- /dev/null +++ b/bin/deluser.php @@ -0,0 +1,47 @@ +prepare(" + select * from actor where preferredUsername = :preferredUsername +"); +$person_query->bindValue(':preferredUsername', $preferred_username); +$person = ($person_query->execute())->fetchArray(); + +if ($person === null) { + echo "No user by the name $preferred_username was found. Aborting" . PHP_EOL; + die(); +} + +$objectId = $person['objectId']; + +$person_drop_stmt = $conn->prepare(" + delete from actor where objectId = :objectId +"); + +$object_drop_stmt = $conn->prepare(" + delete from object where id = :objectId +"); + +$person_drop_stmt->bindValue(':objectId', $objectId); +$object_drop_stmt->bindValue(':objectId', $objectId); + +$person_drop_result = $object_drop_stmt->execute(); + +if (!$person_drop_stmt->execute()) { + echo "Error: failed to drop person $objectId. No changes made. Aborting." . PHP_EOL; + die(); +} + +if (!$object_drop_stmt->execute()) { + echo "Error: failed to drop object $objectId. This object has been left without a corresponding actor. Aborting" . PHP_EOL; + die(); +} + +echo "Successfully dropped actor $preferred_username!" . PHP_EOL; + +$conn->close(); diff --git a/bin/hostasctl b/bin/hostasctl new file mode 100755 index 0000000..0c09b3c --- /dev/null +++ b/bin/hostasctl @@ -0,0 +1,13 @@ +#!/usr/bin/env php +prepare(" + select * from actor + join object on actor.objectId = object.id + where preferredUsername = :preferred_username + limit 1; +"); + +$actor_query->bindValue(':preferred_username', $preferred_username); +$actor_result = $actor_query->execute(); +$actor = $actor_result->fetchArray(); + +if(!$actor) { + echo "No actor by the preferred username $preferred_username. Exiting" . PHP_EOL; + $conn->close(); + exit(1); +} + +$content = readline('Content: '); + +$note_result = prepare_and_execute($conn, + "insert into object(id, type, published, attributedTo, content) + values (:id, :type, :published, :attributedTo, :content);", + array( + ':id' => $note_id, + ':type' => 'Note', + ':published' => $published, + ':attributedTo' => $actor['id'], + ':content' => $content + ) +); + +if (!$note_result) { + echo 'Failed to create note. Exiting' . PHP_EOL; + $conn->close(); + exit(); +} + +echo "Note created at $note_id" . PHP_EOL; + +$activity_object_result = prepare_and_execute($conn, + "insert into object(id, type, published) + values(:id, :type, :published);", + array( + ':id' => $activity_id, + ':type' => 'Create', + ':published' => $published, + ) +); + +if (!$activity_object_result) { + echo 'Failed to create the activity\'s object. Exiting' . PHP_EOL; + $conn->close(); + exit(); +} + +echo "Object created for Create activity at $activity_id" . PHP_EOL; + +$activity_result = prepare_and_execute($conn, + "insert into activity(objectId, actor, object) + values(:objectId, :actor, :object);", + array( + ':objectId' => $activity_id, + ':actor' => $actor['id'], + ':object' => $note_id, + ) +); + +if (!$activity_result) { + echo 'Failed to create the activity record. Exiting' . PHP_EOL; + $conn->close(); + exit(); +} + +echo "Successfully created activity." . PHP_EOL; +$conn->close(); diff --git a/database.ddl b/database.ddl new file mode 100644 index 0000000..4e2f923 --- /dev/null +++ b/database.ddl @@ -0,0 +1,89 @@ +CREATE TABLE IF NOT EXISTS actor ( + objectId TEXT, -- UUIDv4 of the corresponding object + preferredUsername TEXT, + + FOREIGN KEY (objectId) REFERENCES object(id), + PRIMARY KEY (objectId) +); + +CREATE TABLE IF NOT EXISTS object ( + id TEXT, -- Dereferenceable link to the object + type TEXT, -- Object type, see + + name TEXT, -- Title of the object + attributedTo TEXT, -- object ID of the actor to whom the object is attributed + summary TEXT, -- Natural language summary of the object + content TEXT, -- Text content or representation + url TEXT, -- A URL that represents the object + mediaType TEXT, -- MIME type of `content` + icon TEXT, -- URL of a 1:1 image that represents the object + + -- These four columns are unix timestamps. AP expects ISO timestamps + startTime INTEGER, -- When the object is said to have "begun," in some context + endTime INTEGER, -- When the object is said to "end," in some context + published INTEGER, -- When the object was first created + updated INTEGER, -- When the object was last updated + + FOREIGN KEY (attributedTo) REFERENCES object(id), + PRIMARY KEY(id) +); + +CREATE TABLE IF NOT EXISTS activity ( + objectId TEXT, + + -- All of these are object IDs, all of them are optional + actor TEXT, -- Actor "behind" the activity + object TEXT, -- Object encapsulated by the activity, if applicable + origin TEXT, -- Object the activity is "from" + target TEXT, -- Object the activity is "going to", or the encapsulated object is + result TEXT, -- Object describing the outcome + instrument TEXT, -- Object with which the activity was performed + + FOREIGN KEY (objectId) REFERENCES object(id), + PRIMARY KEY (objectId) +); + +CREATE TABLE IF NOT EXISTS object_contentMap ( + objectId INTEGER, -- The object attaching `attachedId` + key TEXT, -- Langauge code + content TEXT, -- The content being mapped to by the key + + FOREIGN KEY(objectId) REFERENCES object(id), + PRIMARY KEY(objectId, key) +); + +CREATE TABLE IF NOT EXISTS object_attachment ( + objectId INTEGER, -- The object attaching `attachedId` + attachedId INTEGER, -- The object attached by `objectId` + + FOREIGN KEY(objectId) REFERENCES object(id), + FOREIGN KEY(attachedId) REFERENCES object(id), + PRIMARY KEY(objectId, attachedId) +); + +CREATE TABLE IF NOT EXISTS object_audience ( + objectId INTEGER, -- Object identifying an audience + audienceId INTEGER, -- Audience being identified + + FOREIGN KEY(objectId) REFERENCES object(id), + FOREIGN KEY(audienceId) REFERENCES object(id), + PRIMARY KEY(objectId, audienceId) +); + +CREATE TABLE IF NOT EXISTS object_replies ( + objectId INTEGER, -- "original post," or whatever is being replied to + responseId INTEGER, -- The response to the original object + + FOREIGN KEY(objectId) REFERENCES object(id), + FOREIGN KEY(responseId) REFERENCES object(id), + PRIMARY KEY(objectId, responseId) +); + +CREATE TABLE IF NOT EXISTS object_tags ( + taggerId INTEGER, + taggedId INTEGER, + + FOREIGN KEY(taggerId) REFERENCES object(id), + FOREIGN KEY(taggedId) REFERENCES object(id), + PRIMARY KEY(taggerId, taggedId) +); diff --git a/etc/config.example.php b/etc/config.example.php new file mode 100644 index 0000000..ef0827c --- /dev/null +++ b/etc/config.example.php @@ -0,0 +1,12 @@ + HOSTAS_ACCESS_LEVEL->trusted +)); diff --git a/etc/definitions.php b/etc/definitions.php new file mode 100644 index 0000000..1092195 --- /dev/null +++ b/etc/definitions.php @@ -0,0 +1,14 @@ + 0, // Can view, cannot respond + 'trusted' => 1, // Can view, can respond + 'blocked' => 2, // Cannot view or respond +)); + +define('HOSTAS_CONTEXT', 'https://www.w3.org/ns/activitystreams'); + + +define('HOSTAS_PUBKEY_PATH', '/etc/hostas/keys/public.pem'); +define('HOSTAS_PRIVKEY_PATH', '/etc/hostas/keys/public.pem'); + diff --git a/etc/nginx.example.conf b/etc/nginx.example.conf new file mode 100644 index 0000000..6741ee4 --- /dev/null +++ b/etc/nginx.example.conf @@ -0,0 +1,87 @@ +server { + # SSL configuration + # + # listen 443 ssl default_server; + # listen [::]:443 ssl default_server; + # + # Note: You should disable gzip for SSL traffic. + # See: https://bugs.debian.org/773332 + # + # Read up on ssl_ciphers to ensure a secure configuration. + # See: https://bugs.debian.org/765782 + # + # Self signed certs generated by the ssl-cert package + # Don't use them in a production server! + # + # include snippets/snakeoil.conf; + + root /rootdir/of/hostas2/; + + # Add index.php to the list if you are using PHP + index index.php index.html index.htm index.nginx-debian.html; + + server_name example.net; + + # For WebFinger lookup + location /.well-known/webfinger { + rewrite ^/.well-known/webfinger /api/webfinger-lookup.php; + } + + # API + location /api/v1/ { + index router.php; + rewrite ^/api/v1/(.*)$ /api/v1/router.php?$args; + } + + # pass PHP scripts to FastCGI server + + location ~ \.php$ { + include snippets/fastcgi-php.conf; + + # With php-fpm (or other unix sockets): + fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name; + fastcgi_param DOCUMENT_ROOT $realpath_root; + fastcgi_pass unix:/run/php/php8.2-fpm.sock; + # With php-cgi (or other tcp sockets): + # fastcgi_pass 127.0.0.1:9000; + } + + location / { + autoindex on; + # First attempt to serve request as file, then + # as directory, then fall back to displaying a 404. + try_files $uri $uri/ =404; + } + + # deny access to .htaccess files, if Apache's document root + # concurs with nginx's one + # + #location ~ /\.ht { + # deny all; + #} + + location /config.php { + deny all; + } + + listen [::]:443 ssl; + listen 443 ssl; + ssl_certificate /etc/letsencrypt/live/example.net/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/example.net/privkey.pem; + include /etc/letsencrypt/options-ssl-nginx.conf; + ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; +} +server { + if ($host = example.net) { + return 301 https://$host$request_uri; + } + + + listen 80; + listen [::]:80; + + server_name example.net; + return 404; + + +} diff --git a/init.php b/init.php new file mode 100644 index 0000000..67f617e --- /dev/null +++ b/init.php @@ -0,0 +1,30 @@ + + exec('chown ' . HOSTAS_UNIX_USER . ' ' . dirname(HOSTAS_DATABASE_PATH)); +} + +if (!file_exists(HOSTAS_DATABASE_PATH)) { + $conn = new SQLite3(HOSTAS_DATABASE_PATH); + echo "Creating the database..." . PHP_EOL; + $ddl = file_get_contents('./database.ddl'); + $conn->exec($ddl); + + // Make sure the right permissions are set + exec('chown ' . HOSTAS_UNIX_USER . ' ' . HOSTAS_DATABASE_PATH); + exec('chgrp ' . HOSTAS_UNIX_USER . ' ' . HOSTAS_DATABASE_PATH); +} + + diff --git a/public/api/v1/actor.php b/public/api/v1/actor.php new file mode 100644 index 0000000..53f3d3c --- /dev/null +++ b/public/api/v1/actor.php @@ -0,0 +1,128 @@ + HOSTAS_CONTEXT, + 'id' => $actor['id'], + 'type' => 'Person', + 'preferredUsername' => $actor['preferredUsername'], + 'name' => $actor['name'], + 'summary' => $actor['summary'], + 'url' => $actor['url'], + 'icon' => array( + 'type' => 'Image', + 'url' => $actor['icon'], + ), + 'publicKey' => array( + 'id' => "{$actor['id']}#main-key", + 'owner' => $actor['id'], + 'publicKeyPem' => $public_key, + ), + 'inbox' => "https://" . HOSTAS_DOMAIN . "/api/v1/actor/$preferred_username/inbox", + 'outbox' => "https://" . HOSTAS_DOMAIN . "/api/v1/actor/$preferred_username/outbox", + ); + + header('Content-Type: application/activity+json'); + echo json_encode($activity_representation); + + $conn->close(); + die(); +} + +function get_actor_outbox(string $preferred_username) { + header('Content-Type: application/activity+json'); + + $conn = new SQLite3(HOSTAS_DATABASE_PATH); + $actor = get_actor_or_exit($conn, $preferred_username); + + $create_activities_result = prepare_and_execute($conn, + "select object.id as object_id, object.type as object_type, + activity.actor as activity_actor, post.id as post_id, + post.published as post_published, post.type as post_type, + post.content as post_content + from object + join activity on activity.objectId = object.id + join object as post on activity.object = post.id + where activity.actor = :actor_id + order by object.published", +/* + "select object.id as object_id, object.type as object_type, + post.id as post_id, post.type as post.type, post.published as post_published, + post.url as post_url, post.content as post_content + from object + join activity on activity.objectId = object.id + join object as post on activity.object = post.id + where activity.actor = :actor_id + order by object.published", +*/ + array(':actor_id' => $actor['id']) + ); + + $total_items = 0; + $ordered_items = array(); + + while ($entry = $create_activities_result->fetchArray()) { + $total_items += 1; + array_push($ordered_items, array( + 'id' => $entry['object_id'], + 'type' => $entry['object_type'], + 'actor' => $entry['activity_actor'], + 'published' => date(DATE_ISO8601, $entry['post_published']), + 'cc' => array('https://www.w3.org/ns/activitystreams#Public'), + 'object' => array( + 'id' => $entry['post_id'], + 'type'=> $entry['post_type'], + 'published' => date(DATE_ISO8601, $entry['post_published']), + 'url' => $entry['post_id'], + 'attributedTo' => $actor['id'], + 'cc' => array('https://www.w3.org/ns/activitystreams#Public'), + 'content' => $entry['post_content'], + ), + )); + } + + echo json_encode(array( + '@context' => HOSTAS_CONTEXT, + 'id' => "https://" . HOSTAS_DOMAIN . "/api/v1/actor/$preferred_username/outbox", + 'type' => 'OrderedCollection', + 'totalItems' => $total_items, + 'orderedItems' => $ordered_items, + )); + + die(); +} + +function get_actor_inbox(string $objectId) { + die(); +} + +switch ($_SERVER['REQUEST_METHOD']) { + case 'GET': + if (sizeof(REQUEST_PATH) === 3) { + if (REQUEST_PATH[2] === 'inbox') get_actor_inbox(REQUEST_PATH[1]); + else if (REQUEST_PATH[2] === 'outbox') get_actor_outbox(REQUEST_PATH[1]); + } + get_actor(REQUEST_PATH[1]); + break; + default: + http_response_code(405); + die(); +} diff --git a/public/api/v1/database.php b/public/api/v1/database.php new file mode 100644 index 0000000..c579daf --- /dev/null +++ b/public/api/v1/database.php @@ -0,0 +1,23 @@ +prepare($stmt_string); + + foreach ($parameters as $k => $v) { + $stmt->bindValue($k, $v); + } + + return $stmt->execute(); +} + +function sql_fetch_actor($conn, string $preferred_username) { + $object_id = 'https://' . HOSTAS_DOMAIN . "/api/v1/actor/$preferred_username"; + $result = prepare_and_execute($conn, + "select * from actor + join object on actor.objectId = object.id + where object.id = :object_id", + array(':object_id' => $object_id) + ); + + return $result->fetchArray(SQLITE3_ASSOC); +} diff --git a/public/api/v1/router.php b/public/api/v1/router.php new file mode 100644 index 0000000..fff0c0c --- /dev/null +++ b/public/api/v1/router.php @@ -0,0 +1,15 @@ + ['', 'api', 'v1', 'test', 'request']. +// For convenience later, we take a slice that excludes ['', 'api', 'v1']. +define('REQUEST_PATH', array_slice(explode('/', $_SERVER['REQUEST_URI']), offset: 3)); + +switch (REQUEST_PATH[0]) { + case 'actor': // /api/v1/actor + require_once($_SERVER['DOCUMENT_ROOT'] . '/api/v1/actor.php'); + break; + default: + http_response_code(404); + die(); +} diff --git a/public/api/webfinger-lookup.php b/public/api/webfinger-lookup.php new file mode 100755 index 0000000..5e40781 --- /dev/null +++ b/public/api/webfinger-lookup.php @@ -0,0 +1,49 @@ +prepare(" + select * from actor + join object on actor.objectId = object.id + where actor.preferredUsername = :preferredUsername +"); + +$people_query->bindValue(':preferredUsername', $local_username); +$people_query_results = $people_query->execute(); +$person = $people_query_results->fetchArray(); + +if (!$person) { + http_response_code(404); + die(); +} + + +header('Content-Type: application/jrd+json; charset=utf8'); +echo json_encode(array( + 'subject' => "acct:$local_username@$given_domain", + 'links' => array( + array( + 'rel'=> 'self', + 'href'=> $person['url'], + 'type'=> 'application/activity+json' + ) + ) +)); + +$conn->close(); diff --git a/public/index.php b/public/index.php new file mode 100755 index 0000000..0dbdf01 --- /dev/null +++ b/public/index.php @@ -0,0 +1,3 @@ +