diff --git a/public/api/v1/actor.php b/public/api/v1/actor.php index 53f3d3c..3cb9657 100644 --- a/public/api/v1/actor.php +++ b/public/api/v1/actor.php @@ -47,33 +47,57 @@ function get_actor(string $preferred_username) { 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'); + $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); $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, + $sql_query = <<<'EOT' + 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 + post.content as post_content, post.published as 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 - 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']) + where + activity_actor = :actor_id + and post_published < :before + and post_published > :after + order by object.published desc + limit :limit + EOT; + + $create_activities_result = prepare_and_execute($conn, $sql_query, + array( + ':actor_id' => $actor['id'], + ':before' => strtotime($before), + ':after' => strtotime($after), + ':limit' => $limit + ) ); $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( '@context' => HOSTAS_CONTEXT, - 'id' => "https://" . HOSTAS_DOMAIN . "/api/v1/actor/$preferred_username/outbox", + 'id' => $collection_id, 'type' => 'OrderedCollection', 'totalItems' => $total_items, - 'orderedItems' => $ordered_items, + 'first' => $collection_id . '?before=' . date(DATE_ISO8601, time()), + 'last' => $collection_id . '?after=' . date(DATE_ISO8601, 0), )); die(); @@ -118,8 +194,15 @@ 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]); + 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]); break; default: