This commit is contained in:
Nat 2024-09-01 11:54:41 -07:00
commit f1669d6e83
17 changed files with 710 additions and 0 deletions

4
.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
etc/keys/*
etc/nginx.conf
public/config.php

30
README.md Normal file
View File

@ -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
```

18
bin/addstatus.php Normal file
View File

@ -0,0 +1,18 @@
<?php
require_once('./public/config.php');
$uuid = shell_exec('uuidgen');
// Have the user write the content to a temporary file
$temp_file_name = sys_get_temp_dir() . '/' . $uuid;
shell_exec("vim $temp_file_name");
$content = file_get_contents($temp_file_name);
// Clean up
unlink($temp_file_name);
$object_id = 'https://' . HOSTAS_DOMAIN . "/api/v1/object/$uuid";
$preferred_username = readline('Author username: ');

61
bin/adduser.php Normal file
View File

@ -0,0 +1,61 @@
<?php
require_once('./public/config.php');
echo 'Creating new actor. Please fill out the following prompts:' . PHP_EOL;
$name = readline('Name: ');
$preferred_username = readline('Username: ');
$summary = readline('Summary: ');
// We use this link as the user's ID because in ActivityStreams, all IDs
// must be dereferenceable
$id = "https://" . HOSTAS_DOMAIN . "/api/v1/actor/$preferred_username";
$url = readline('Canonical URL (hit enter to generate one): ');
if ($url === '') {
$url = $id;
echo 'Canonical URL: ' . $url . PHP_EOL;
}
$icon_url = readline('Icon URL (hit enter for none): ');
$icon_url = $icon_url === '' ? null : $icon_url;
$conn = new SQLite3(HOSTAS_DATABASE_PATH);
$object_creation_stmt = $conn->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();

47
bin/deluser.php Normal file
View File

@ -0,0 +1,47 @@
<?php
require_once('./public/config.php');
$preferred_username = readline('Username: ');
$conn = new SQLite3(HOSTAS_DATABASE_PATH);
$person_query = $conn->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();

13
bin/hostasctl Executable file
View File

@ -0,0 +1,13 @@
#!/usr/bin/env php
<?php
if ($argc === 1) {
echo 'Error: No sub-command provided. Please specify a sub-command' . PHP_EOL;
die();
}
try {
require_once(dirname($_SERVER['SCRIPT_NAME']) . "/{$argv[1]}.php");
} catch (err) {
echo "Unknown sub-command '$argv[1]'" . PHP_EOL;
}

87
bin/pen.php Normal file
View File

@ -0,0 +1,87 @@
<?php
require_once('./public/config.php');
require_once('./public/api/v1/database.php');
$note_id = 'https://' . HOSTAS_DOMAIN . '/api/v1/object/' . exec('uuidgen');
$activity_id= 'https://' . HOSTAS_DOMAIN . '/api/v1/object/' . exec('uuidgen');
$published = time();
$preferred_username = readline('Username: ');
$conn = new SQLite3(HOSTAS_DATABASE_PATH);
$actor_query = $conn->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();

89
database.ddl Normal file
View File

@ -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 <https://www.w3.org/TR/activitystreams-vocabulary/#object-types>
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)
);

12
etc/config.example.php Normal file
View File

@ -0,0 +1,12 @@
<?php
require_once('/etc/hostas/definitions.php');
define('HOSTAS_DATABASE_PATH', '/var/db/hostas2.db');
define('HOSTAS_INSTALL_PATH', '/path/to/hostas2/website');
define('HOSTAS_DOMAIN', 'example.com');
define('HOSTAS_UNIX_USER', 'www-data');
define('HOSTAS_ACCESS_LIST', array(
'example.org' => HOSTAS_ACCESS_LEVEL->trusted
));

14
etc/definitions.php Normal file
View File

@ -0,0 +1,14 @@
<?php
define('HOSTAS_ACCESS_LEVEL', (object) array(
'default' => 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');

87
etc/nginx.example.conf Normal file
View File

@ -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;
}

30
init.php Normal file
View File

@ -0,0 +1,30 @@
<?php
include_once('./public/config.php');
// Create the database if it doesn't already exist
if (!file_exists(dirname(HOSTAS_DATABASE_PATH))) {
mkdir(
directory: dirname(HOSTAS_DATABASE_PATH),
recursive: true
);
// The SQLite driver can't write to a database unless its directory is
// writeable.
//
// See <https://stackoverflow.com/questions/3319112/sqlite-error-attempt-to-write-a-readonly-database-during-insert>
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);
}

128
public/api/v1/actor.php Normal file
View File

@ -0,0 +1,128 @@
<?php
require_once($_SERVER['DOCUMENT_ROOT'] . '/config.php');
require_once($_SERVER['DOCUMENT_ROOT'] . '/api/v1/database.php');
function get_actor_or_exit($conn, string $preferred_username) {
$actor = sql_fetch_actor($conn, $preferred_username);
if (!$actor) {
http_response_code(404);
die();
}
return $actor;
}
function get_actor(string $preferred_username) {
$public_key = file_get_contents(HOSTAS_PUBKEY_PATH);
$conn = new SQLite3(HOSTAS_DATABASE_PATH);
$actor = get_actor_or_exit($conn, $preferred_username);
$activity_representation = array(
'@context' => 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();
}

View File

@ -0,0 +1,23 @@
<?php
function prepare_and_execute($conn, $stmt_string, $parameters) {
$stmt = $conn->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);
}

15
public/api/v1/router.php Normal file
View File

@ -0,0 +1,15 @@
<?php
require_once($_SERVER['DOCUMENT_ROOT'] . '/config.php');
// e.g. /api/v1/test/request/ -> ['', '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();
}

49
public/api/webfinger-lookup.php Executable file
View File

@ -0,0 +1,49 @@
<?php
require_once($_SERVER['DOCUMENT_ROOT'] . '/config.php');
$resource = $_GET['resource'];
$canonical_username = substr($resource, offset: 5); // Drop the 'acct:'
$username_parts = explode(separator: '@', string: $canonical_username, limit: 2);
$local_username = $username_parts[0];
$given_domain = $username_parts[1];
// We're only returning results at our domain
if ($given_domain !== HOSTAS_DOMAIN) {
http_response_code(404);
die();
}
$conn = new SQLite3(HOSTAS_DATABASE_PATH);
$people_query = $conn->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();

3
public/index.php Executable file
View File

@ -0,0 +1,3 @@
<?php
echo 'hi';