Compare commits

...

6 Commits

Author SHA1 Message Date
Nat cce480bad7 chore: remove unused addstatus hostasctl command 2024-09-01 14:57:47 -07:00
Nat 54669d2d4b feat: implement paginated outboxes
Mastodon will *only* show posts if they're paginated, fun fact.
2024-09-01 14:56:37 -07:00
Nat ba8e95fa4f fix: exclude query params from REQUEST_PATH 2024-09-01 14:56:21 -07:00
Nat 9e7c18afba feat: have sql_get_actor return null when no actor is found 2024-09-01 14:54:35 -07:00
Nat c6f9f74be3 fix: add foreign references to activity table 2024-09-01 14:53:09 -07:00
Nat cb803a1c70 chore: add vim swap files to gitignore 2024-09-01 12:06:50 -07:00
7 changed files with 123 additions and 40 deletions

4
.gitignore vendored
View File

@ -2,3 +2,7 @@ etc/keys/*
etc/nginx.conf etc/nginx.conf
public/config.php public/config.php
# Vim
~*
.*.sw*

View File

@ -1,18 +0,0 @@
<?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: ');

View File

@ -40,6 +40,12 @@ CREATE TABLE IF NOT EXISTS activity (
instrument TEXT, -- Object with which the activity was performed instrument TEXT, -- Object with which the activity was performed
FOREIGN KEY (objectId) REFERENCES object(id), FOREIGN KEY (objectId) REFERENCES object(id),
FOREIGN KEY (actor) REFERENCES object(id),
FOREIGN KEY (object) REFERENCES object(id),
FOREIGN KEY (origin) REFERENCES object(id),
FOREIGN KEY (target) REFERENCES object(id),
FOREIGN KEY (result) REFERENCES object(id),
FOREIGN KEY (instrument) REFERENCES object(id),
PRIMARY KEY (objectId) PRIMARY KEY (objectId)
); );

View File

@ -10,3 +10,5 @@ define('HOSTAS_UNIX_USER', 'www-data');
define('HOSTAS_ACCESS_LIST', array( define('HOSTAS_ACCESS_LIST', array(
'example.org' => HOSTAS_ACCESS_LEVEL->trusted 'example.org' => HOSTAS_ACCESS_LEVEL->trusted
)); ));
define('HOSTAS_MAX_POSTS_PER_PAGE', 50);

View File

@ -47,33 +47,57 @@ function get_actor(string $preferred_username) {
die(); die();
} }
function get_actor_outbox(string $preferred_username) { /** /api/v1/actor/:preferred_username/outbox?before=:before_date_iso&limit=:limit
* Returns an OrderedCollectionPage of at most `limit` pages published before
* `before_date_iso`.
*
* Parameters:
* before ISO timestamp string. Only posts published before this time are included
* limit Integer. Number of posts to include in the result. Default: 20. Max: HOSTAS_MAX_POSTS_PER_PAGE
*/
function get_actor_outbox_paged(string $preferred_username) {
header('Content-Type: application/activity+json'); header('Content-Type: application/activity+json');
$before = array_key_exists('before', $_GET) ? $_GET['before'] : date(DATE_ISO8601, time());
$after = array_key_exists('after', $_GET) ? $_GET['after'] : date(DATE_ISO8601, 0);
$limit = array_key_exists('limit', $_GET) ? $_GET['limit'] : 20;
// Parameters in $_GET with a + character will get replaced with whitespace,
// so we've got to change it back
$before = str_replace(" ", "+", $before);
$after = str_replace(" ", "+", $after);
// Cap the limit at the configured maximum
if ($limit > HOSTAS_MAX_POSTS_PER_PAGE) {
$limit = HOSTAS_MAX_POSTS_PER_PAGE;
}
$conn = new SQLite3(HOSTAS_DATABASE_PATH); $conn = new SQLite3(HOSTAS_DATABASE_PATH);
$actor = get_actor_or_exit($conn, $preferred_username); $actor = get_actor_or_exit($conn, $preferred_username);
$create_activities_result = prepare_and_execute($conn, $sql_query = <<<'EOT'
"select object.id as object_id, object.type as object_type, select object.id as object_id, object.type as object_type,
activity.actor as activity_actor, post.id as post_id, activity.actor as activity_actor, post.id as post_id,
post.published as post_published, post.type as post_type, post.published as post_published, post.type as post_type,
post.content as post_content post.content as post_content, post.published as post_published
from object from object
join activity on activity.objectId = object.id join activity on activity.objectId = object.id
join object as post on activity.object = post.id join object as post on activity.object = post.id
where activity.actor = :actor_id where
order by object.published", activity_actor = :actor_id
/* and post_published < :before
"select object.id as object_id, object.type as object_type, and post_published > :after
post.id as post_id, post.type as post.type, post.published as post_published, order by object.published desc
post.url as post_url, post.content as post_content limit :limit
from object EOT;
join activity on activity.objectId = object.id
join object as post on activity.object = post.id $create_activities_result = prepare_and_execute($conn, $sql_query,
where activity.actor = :actor_id array(
order by object.published", ':actor_id' => $actor['id'],
*/ ':before' => strtotime($before),
array(':actor_id' => $actor['id']) ':after' => strtotime($after),
':limit' => $limit
)
); );
$total_items = 0; $total_items = 0;
@ -99,12 +123,64 @@ function get_actor_outbox(string $preferred_username) {
)); ));
} }
$clean_uri = 'https://' . HOSTAS_DOMAIN . strtok($_SERVER['REQUEST_URI'], '?');
$response = array(
'@context' => HOSTAS_CONTEXT,
'id' => 'https://' . HOSTAS_DOMAIN . $_SERVER['REQUEST_URI'],
'type' => 'OrderedCollectionPage',
'partOf' => 'https://' . HOSTAS_DOMAIN . $clean_uri,
'orderedItems' => $ordered_items,
);
if ($total_items === 0) {
// There may be previous items, so we can reuse the before param. We
// need to do some pre-processing to avoid characters getting escaped
// from the URL
$latest_time = str_replace(" ", "+", $before);
$response['prev'] = $clean_uri . "?after=$latest_time";
} else {
$earliest_time = $ordered_items[array_key_last($ordered_items)]['published'];
$latest_time = $ordered_items[0]['published'];
$response['prev'] = $clean_uri . "?after=$latest_time";
$response['next'] = $clean_uri . "?before=$earliest_time";
}
echo json_encode($response);
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);
$sql_query = <<<'EOT'
select count(post.id) as post_count, min(post.published)
from object
join activity on activity.objectId = object.id
join object as post on activity.object = post.id
where activity.actor = :actor_id
EOT;
$items_count_result = prepare_and_execute($conn, $sql_query,
array(':actor_id' => $actor['id'])
);
$items_count_row = $items_count_result->fetchArray(SQLITE3_ASSOC);
$total_items = $items_count_row['post_count'];
$collection_id = "https://" . HOSTAS_DOMAIN . "/api/v1/actor/$preferred_username/outbox";
echo json_encode(array( echo json_encode(array(
'@context' => HOSTAS_CONTEXT, '@context' => HOSTAS_CONTEXT,
'id' => "https://" . HOSTAS_DOMAIN . "/api/v1/actor/$preferred_username/outbox", 'id' => $collection_id,
'type' => 'OrderedCollection', 'type' => 'OrderedCollection',
'totalItems' => $total_items, 'totalItems' => $total_items,
'orderedItems' => $ordered_items, 'first' => $collection_id . '?before=' . date(DATE_ISO8601, time()),
'last' => $collection_id . '?after=' . date(DATE_ISO8601, 0),
)); ));
die(); die();
@ -118,8 +194,15 @@ switch ($_SERVER['REQUEST_METHOD']) {
case 'GET': case 'GET':
if (sizeof(REQUEST_PATH) === 3) { if (sizeof(REQUEST_PATH) === 3) {
if (REQUEST_PATH[2] === 'inbox') get_actor_inbox(REQUEST_PATH[1]); if (REQUEST_PATH[2] === 'inbox') get_actor_inbox(REQUEST_PATH[1]);
else if (REQUEST_PATH[2] === 'outbox') get_actor_outbox(REQUEST_PATH[1]); else if (REQUEST_PATH[2] === 'outbox') {
if (array_key_exists('before', $_GET) || array_key_exists('after', $_GET)) {
get_actor_outbox_paged(REQUEST_PATH[1]);
} else {
get_actor_outbox(REQUEST_PATH[1]);
}
}
} }
get_actor(REQUEST_PATH[1]); get_actor(REQUEST_PATH[1]);
break; break;
default: default:

View File

@ -19,5 +19,8 @@ function sql_fetch_actor($conn, string $preferred_username) {
array(':object_id' => $object_id) array(':object_id' => $object_id)
); );
return $result->fetchArray(SQLITE3_ASSOC); // fetchArray returns false when there's no array; we're rather have it be
// explicitly null
$actor = $result->fetchArray(SQLITE3_ASSOC);
return $actor !== false ? $actor : null;
} }

View File

@ -1,9 +1,12 @@
<?php <?php
require_once($_SERVER['DOCUMENT_ROOT'] . '/config.php'); require_once($_SERVER['DOCUMENT_ROOT'] . '/config.php');
// $clean_uri is the request URI with any query strings removed
$clean_uri = strtok($_SERVER['REQUEST_URI'], '?');
// e.g. /api/v1/test/request/ -> ['', 'api', 'v1', 'test', 'request']. // e.g. /api/v1/test/request/ -> ['', 'api', 'v1', 'test', 'request'].
// For convenience later, we take a slice that excludes ['', 'api', 'v1']. // For convenience later, we take a slice that excludes ['', 'api', 'v1'].
define('REQUEST_PATH', array_slice(explode('/', $_SERVER['REQUEST_URI']), offset: 3)); define('REQUEST_PATH', array_slice(explode('/', $clean_uri), offset: 3));
switch (REQUEST_PATH[0]) { switch (REQUEST_PATH[0]) {
case 'actor': // /api/v1/actor case 'actor': // /api/v1/actor