From 966be784220cced951a44ee638c43230b4f3bfe1 Mon Sep 17 00:00:00 2001 From: Alec Murphy Date: Sun, 16 Mar 2025 19:21:44 -0400 Subject: [PATCH] Slon/Api/V1/Polls: Implement Polls Fixes #8 --- Slon/Api/V1/Polls.HC | 191 +++++++++++++++++++++++++++++++++++ Slon/Api/V1/Statuses.HC | 53 ++++++++++ Slon/Endpoints/Get/Polls.HC | 4 + Slon/Endpoints/Post/Polls.HC | 4 + Slon/Http/Server.HC | 2 + Slon/MakeSlon.HC | 1 + Slon/Modules/ActivityPub.HC | 41 ++++++++ Slon/Modules/Api.HC | 51 ++++++++++ 8 files changed, 347 insertions(+) create mode 100644 Slon/Api/V1/Polls.HC create mode 100644 Slon/Endpoints/Get/Polls.HC create mode 100644 Slon/Endpoints/Post/Polls.HC diff --git a/Slon/Api/V1/Polls.HC b/Slon/Api/V1/Polls.HC new file mode 100644 index 0000000..2a5ec4a --- /dev/null +++ b/Slon/Api/V1/Polls.HC @@ -0,0 +1,191 @@ +U0 @slon_api_v1_async_vote_fedi(JsonObject* vote_object) +{ + U8 scratch_buffer[512]; + I64 this_vote_id = SysTimerRead; + + JsonArray* choices = vote_object->a("choices"); + JsonObject* status = vote_object->o("status"); + JsonObject* ap_vote_object = NULL; + JsonObject* object = NULL; + U8* choice_name = NULL; + + U8* this_actor = db->o("actors")->o(vote_object->@("username"))->@("id"); + U8* remote_actor = status->o("account")->@("remote_actor"); + + U8* fetch_buffer = CAlloc(HTTP_FETCH_BUFFER_SIZE, slon_mem_task); + + I64 i; + for (i = 0; i < choices->length; i++) { + choice_name = status->o("poll")->a("options")->o(i)->@("title"); + + ap_vote_object = Json.CreateObject(slon_mem_task); + ap_vote_object->set("@context", "https://www.w3.org/ns/activitystreams", JSON_STRING); + StrPrint(scratch_buffer, "%s#votes/%d/activity", this_actor, this_vote_id); + ap_vote_object->set("id", scratch_buffer, JSON_STRING); + ap_vote_object->set("to", remote_actor, JSON_STRING); + ap_vote_object->set("actor", this_actor, JSON_STRING); + ap_vote_object->set("type", "Create", JSON_STRING); + + object = Json.CreateObject(slon_mem_task); + StrPrint(scratch_buffer, "%s#votes/%d", this_actor, this_vote_id); + object->set("id", scratch_buffer, JSON_STRING); + object->set("type", "Note", JSON_STRING); + object->set("name", choice_name, JSON_STRING); + object->set("attributedTo", this_actor, JSON_STRING); + object->set("to", remote_actor, JSON_STRING); + object->set("inReplyTo", status->@("uri"), JSON_STRING); + + ap_vote_object->set("object", object, JSON_OBJECT); + StrPrint(scratch_buffer, "%s/inbox", remote_actor); + @slon_activitypub_signed_request(scratch_buffer, fetch_buffer, ap_vote_object); + MemSet(fetch_buffer, NULL, HTTP_FETCH_BUFFER_SIZE); + } + Free(fetch_buffer); +} + +U0 @slon_api_v1_vote_fedi(U8* username, JsonObject* status, JsonArray* choices) +{ + JsonObject* vote_object = Json.CreateObject(slon_mem_task); + vote_object->set("username", username, JSON_STRING); + vote_object->set("status", status, JSON_OBJECT); + vote_object->set("choices", choices, JSON_ARRAY); + Spawn(&@slon_api_v1_async_vote_fedi, vote_object, "SlonAsyncVoteTask"); +} + +U0 @slon_api_v1_polls_get(SlonHttpSession* session) +{ + if (session->path_count() < 4) { + session->status(400); + return; + } + + U8* id = session->path(3); + JsonObject* status = NULL; + JsonObject* poll = NULL; + JsonArray* poll_choices = NULL; + JsonItem* poll_choice = NULL; + JsonArray* own_votes = NULL; + + U8* account_id = NULL; + if (session->auth) { + account_id = session->auth->@("account_id"); + } + + I64 i; + if (@slon_api_authorized(session)) { + status = @slon_api_find_status_by_poll_id(id, NULL); + if (status) { + poll = Json.Clone(status->o("poll"), session->mem_task); + poll_choices = @slon_api_status_poll_choices(session, status, account_id); + own_votes = Json.CreateArray(session->mem_task); + for (i = 0; i < poll_choices->length; i++) { + poll_choice = poll_choices->@(i, TRUE); + switch (poll_choice->type) { + case JSON_NUMBER: + own_votes->append(poll_choice->value, JSON_NUMBER); + break; + case JSON_STRING: + own_votes->append(Str2I64(poll_choice->value), JSON_NUMBER); + break; + default: + break; + } + } + poll->set("voted", poll_choices > NULL, JSON_BOOLEAN); + poll->set("own_votes", own_votes, JSON_ARRAY); + session->send(poll); + return; + } + session->status(404); + } else { + session->status(401); + } +} + +U0 @slon_api_v1_polls_post(SlonHttpSession* session) +{ + SLON_SCRATCH_BUFFER_AND_REQUEST_JSON + no_warn scratch_buffer; + + if (session->path_count() < 5) { + session->status(400); + return; + } + + U8* id = session->path(3); + + JsonObject* status = NULL; + JsonObject* poll = NULL; + JsonObject* return_poll = NULL; + + JsonArray* choices = NULL; + JsonArray* options = NULL; + JsonItem* choice = NULL; + JsonObject* option = NULL; + JsonArray* own_votes = NULL; + + JsonObject* vote = NULL; + + // Still won't let us vote: iOS Masto + + if (@slon_api_authorized(session)) { + SLON_AUTH_ACCOUNT_ID + + status = @slon_api_find_status_by_poll_id(id, NULL); + if (!status) { + session->status(404); + return; + } + + I64 i; + I64 choice_index = 0; + poll = status->o("poll"); + choices = request_json->@("choices"); + options = poll->a("options"); + own_votes = Json.CreateArray(session->mem_task); + + for (i = 0; i < choices->length; i++) { + choice = choices->@(i, TRUE); + switch (choice->type) { + case JSON_STRING: + choice_index = Str2I64(choice->value); + break; + case JSON_NUMBER: + choice_index = choice->value; + break; + default: + break; + } + own_votes->append(choice_index, JSON_NUMBER); + option = options->o(choice_index); + option->set("votes_count", option->@("votes_count") + 1, JSON_NUMBER); + poll->set("votes_count", poll->@("votes_count") + 1, JSON_NUMBER); + } + + @slon_db_save_status_to_disk(status); + + vote = Json.CreateObject(slon_db_mem_task); + vote->set("status_id", status->@("id"), JSON_STRING); + vote->set("account_id", account_id, JSON_STRING); + vote->set("choices", Json.Clone(choices, slon_db_mem_task), JSON_ARRAY); + + if (!db->o("votes")->a(account_id)) { + db->o("votes")->set(account_id, Json.CreateArray(slon_db_mem_task), JSON_ARRAY); + } + db->o("votes")->a(account_id)->append(vote); + @slon_db_save_votes_to_disk; + + return_poll = Json.Clone(poll, session->mem_task); + return_poll->set("voted", TRUE, JSON_BOOLEAN); + return_poll->set("own_votes", own_votes, JSON_ARRAY); + + // Multiple-choice votes have to be sent in separate requests for each option? wtf + if (status->o("account")->@("remote_actor")) { + // Send votes asynchronously + @slon_api_v1_vote_fedi(@slon_api_account_by_id(account_id)->@("acct"), status, vote->a("choices")); + } + session->send(return_poll); + } else { + session->status(401); + } +} diff --git a/Slon/Api/V1/Statuses.HC b/Slon/Api/V1/Statuses.HC index d589e3e..18a5092 100644 --- a/Slon/Api/V1/Statuses.HC +++ b/Slon/Api/V1/Statuses.HC @@ -40,6 +40,11 @@ U0 @slon_api_v1_statuses_query(SlonHttpSession* session, JsonArray* status_array Bool exclude_reblogs = @slon_api_get_value_as_boolean(request_json->@("exclude_reblogs", TRUE)); Bool pinned = @slon_api_get_value_as_boolean(request_json->@("pinned", TRUE)); + JsonObject* poll = NULL; + JsonArray* poll_choices = NULL; + JsonItem* poll_choice = NULL; + JsonArray* own_votes = NULL; + no_warn exclude_reblogs; // FIXME: Implement "only_media", "exclude_reblogs", "tagged" Bool exclude_status = FALSE; @@ -71,6 +76,27 @@ U0 @slon_api_v1_statuses_query(SlonHttpSession* session, JsonArray* status_array status->o("reblog")->set("reblogged", TRUE, JSON_BOOLEAN); } } + if (status->@("poll")) { + poll = status->o("poll"); + poll_choices = @slon_api_status_poll_choices(session, status, account_id); + poll_choice = NULL; + own_votes = Json.CreateArray(session->mem_task); + for (i = 0; i < poll_choices->length; i++) { + poll_choice = poll_choices->@(i, TRUE); + switch (poll_choice->type) { + case JSON_NUMBER: + own_votes->append(poll_choice->value, JSON_NUMBER); + break; + case JSON_STRING: + own_votes->append(Str2I64(poll_choice->value), JSON_NUMBER); + break; + default: + break; + } + } + poll->set("voted", poll_choices > NULL, JSON_BOOLEAN); + poll->set("own_votes", own_votes, JSON_ARRAY); + } exclude_status = FALSE; if (status->@("deleted")) { exclude_status = TRUE; @@ -182,6 +208,11 @@ U0 @slon_api_v1_statuses_get(SlonHttpSession* session) U8* id = session->path(3); JsonObject* status = NULL; + JsonObject* poll = NULL; + JsonArray* poll_choices = NULL; + JsonItem* poll_choice = NULL; + JsonArray* own_votes = NULL; + if (@slon_api_authorized(session)) { SLON_AUTH_ACCOUNT_ID @@ -218,6 +249,28 @@ U0 @slon_api_v1_statuses_get(SlonHttpSession* session) status->o("reblog")->set("reblogged", TRUE, JSON_BOOLEAN); } } + if (status->@("poll")) { + poll = status->o("poll"); + poll_choices = @slon_api_status_poll_choices(session, status, account_id); + poll_choice = NULL; + own_votes = Json.CreateArray(session->mem_task); + I64 i; + for (i = 0; i < poll_choices->length; i++) { + poll_choice = poll_choices->@(i, TRUE); + switch (poll_choice->type) { + case JSON_NUMBER: + own_votes->append(poll_choice->value, JSON_NUMBER); + break; + case JSON_STRING: + own_votes->append(Str2I64(poll_choice->value), JSON_NUMBER); + break; + default: + break; + } + } + poll->set("voted", poll_choices > NULL, JSON_BOOLEAN); + poll->set("own_votes", own_votes, JSON_ARRAY); + } session->send(status); return; } diff --git a/Slon/Endpoints/Get/Polls.HC b/Slon/Endpoints/Get/Polls.HC new file mode 100644 index 0000000..9f6241d --- /dev/null +++ b/Slon/Endpoints/Get/Polls.HC @@ -0,0 +1,4 @@ +if (String.BeginsWith("/api/v1/polls", session->path())) { + @slon_api_v1_polls_get(session); + return; +} diff --git a/Slon/Endpoints/Post/Polls.HC b/Slon/Endpoints/Post/Polls.HC new file mode 100644 index 0000000..46cbcb5 --- /dev/null +++ b/Slon/Endpoints/Post/Polls.HC @@ -0,0 +1,4 @@ +if (String.BeginsWith("/api/v1/polls", session->path())) { + @slon_api_v1_polls_post(session); + return; +} diff --git a/Slon/Http/Server.HC b/Slon/Http/Server.HC index 65f60d6..701bdfc 100644 --- a/Slon/Http/Server.HC +++ b/Slon/Http/Server.HC @@ -681,6 +681,7 @@ U0 @slon_http_handle_get_request(SlonHttpSession* session) #include "Endpoints/Get/Notifications"; #include "Endpoints/Get/NodeInfo"; #include "Endpoints/Get/OAuth"; + #include "Endpoints/Get/Polls"; #include "Endpoints/Get/Search"; #include "Endpoints/Get/Statuses"; #include "Endpoints/Get/Suggestions"; @@ -743,6 +744,7 @@ U0 @slon_http_handle_post_request(SlonHttpSession* session) #include "Endpoints/Post/Markers"; #include "Endpoints/Post/Media"; #include "Endpoints/Post/OAuth"; + #include "Endpoints/Post/Polls"; #include "Endpoints/Post/Statuses"; /* clang-format on */ diff --git a/Slon/MakeSlon.HC b/Slon/MakeSlon.HC index 06b57ec..71c2638 100644 --- a/Slon/MakeSlon.HC +++ b/Slon/MakeSlon.HC @@ -22,6 +22,7 @@ WinMax(Fs); #include "Api/V1/Markers"; #include "Api/V1/Media"; #include "Api/V1/Notifications"; +#include "Api/V1/Polls"; #include "Api/V1/Statuses"; #include "Api/V1/Timelines"; diff --git a/Slon/Modules/ActivityPub.HC b/Slon/Modules/ActivityPub.HC index 2e9b1ee..c9212f8 100644 --- a/Slon/Modules/ActivityPub.HC +++ b/Slon/Modules/ActivityPub.HC @@ -950,6 +950,14 @@ U0 @slon_activitypub_users_inbox(SlonHttpSession* session, U8* user) JsonArray* statuses = NULL; JsonObject* status = NULL; + JsonObject* poll = NULL; + + JsonArray* poll_options = NULL; + JsonObject* poll_option = NULL; + I64 votes_count = 0; + + JsonArray* ap_poll_options = NULL; + JsonObject* ap_poll_option = NULL; JsonObject* request_object = NULL; U8* status_id = NULL; @@ -1101,6 +1109,39 @@ U0 @slon_activitypub_users_inbox(SlonHttpSession* session, U8* user) request_object = Json.Clone(request_json, slon_mem_task); } + if (!StrICmp("update", request_json->@("type"))) { + if (request_json->o("object") && !StrICmp("question", request_json->o("object")->@("type"))) { + status_id = StrFind("/", StrFind("/statuses/", request_json->o("object")->@("id")) + 1) + 1; + status = @slon_api_find_status_by_id(status_id, @slon_api_account_by_remote_actor(request_json->@("actor"))); + if (status) { + // Update local copy of poll with latest vote counts + poll = status->o("poll"); + poll_options = poll->a("options"); + + if (request_json->o("object")->a("anyOf")) { + ap_poll_options = request_json->o("object")->a("anyOf"); + } + if (request_json->o("object")->a("oneOf")) { + ap_poll_options = request_json->o("object")->a("oneOf"); + } + if (ap_poll_options) { + for (i = 0; i < ap_poll_options->length; i++) { + ap_poll_option = ap_poll_options->@(i); + for (j = 0; j < poll_options->length; j++) { + poll_option = poll_options->o(j); + if (!StrICmp(ap_poll_option->@("name"), poll_option->@("title"))) { + poll_option->set("votes_count", ap_poll_option->o("replies")->@("totalItems"), JSON_NUMBER); + votes_count += ap_poll_option->o("replies")->@("totalItems"); + } + } + } + @slon_db_save_status_to_disk(status); + } + } + } + request_object = Json.Clone(request_json, slon_mem_task); + } + if (request_object) { JsonObject* o = Json.CreateObject(slon_mem_task); o->set("actor_for_key_id", session->actor_for_key_id, JSON_STRING); diff --git a/Slon/Modules/Api.HC b/Slon/Modules/Api.HC index 3973fec..aa7ee1f 100644 --- a/Slon/Modules/Api.HC +++ b/Slon/Modules/Api.HC @@ -179,6 +179,22 @@ JsonObject* @slon_api_status_lookup_by_in_reply_to_id(U8* id, JsonArray* statuse return NULL; } +JsonObject* @slon_api_status_lookup_by_poll_id(U8* id, JsonArray* statuses) +{ + if (!id || !statuses) { + return NULL; + } + I64 i; + JsonObject* status; + for (i = 0; i < statuses->length; i++) { + status = statuses->@(i); + if (!status->@("deleted") && status->@("poll") && !StrICmp(status->o("poll")->@("id"), id)) { + return status; + } + } + return NULL; +} + JsonObject* @slon_api_status_lookup_by_uri(U8* uri, JsonArray* statuses) { if (!uri || !statuses) { @@ -212,6 +228,23 @@ JsonObject* @slon_api_find_status_by_id(U8* id, U8* account_id = NULL) return NULL; } +JsonObject* @slon_api_find_status_by_poll_id(U8* id, U8* account_id = NULL) +{ + if (account_id) { + return @slon_api_status_lookup_by_poll_id(id, db->o("statuses")->a(account_id)); + } + JsonObject* status = NULL; + JsonKey* key = db->o("statuses")->keys; + while (key) { + status = @slon_api_status_lookup_by_poll_id(id, key->value); + if (status) { + return status; + } + key = key->next; + } + return NULL; +} + JsonObject* @slon_api_find_status_by_uri(U8* uri, U8* account_id = NULL) { if (account_id) { @@ -285,6 +318,24 @@ Bool @slon_api_status_is_bookmarked(SlonHttpSession* session, JsonObject* status return FALSE; } +JsonArray* @slon_api_status_poll_choices(SlonHttpSession* session, JsonObject* status, U8* account_id) +{ + no_warn session; + JsonArray* votes = db->o("votes")->a(account_id); + JsonObject* vote = NULL; + if (!votes) { + return NULL; + } + I64 i; + for (i = 0; i < votes->length; i++) { + vote = votes->o(i); + if (!StrICmp(vote->@("status_id"), status->@("id")) && !StrICmp(vote->@("account_id"), account_id)) { + return vote->a("choices"); + } + } + return NULL; +} + U0 @slon_api_bookmark_status(SlonHttpSession* session, JsonObject* status, U8* account_id) { Bool is_already_bookmarked = FALSE;