summaryrefslogtreecommitdiff
path: root/mastoapi.c
diff options
context:
space:
mode:
Diffstat (limited to 'mastoapi.c')
-rw-r--r--mastoapi.c339
1 files changed, 325 insertions, 14 deletions
diff --git a/mastoapi.c b/mastoapi.c
index 2c111ca..94912f1 100644
--- a/mastoapi.c
+++ b/mastoapi.c
@@ -533,6 +533,117 @@ xs_str *mastoapi_id(const xs_dict *msg)
533#define MID_TO_MD5(id) (id + 10) 533#define MID_TO_MD5(id) (id + 10)
534 534
535 535
536static xs_val *get_count_from_actor(const xs_dict *actor, const char *field_names[], const char *collection_url_field)
537/* helper to extract count from actor dict using various field name variations or from cached collection */
538{
539 xs_val *count = NULL;
540
541 /* try direct field name variations first */
542 for (int i = 0; field_names[i] && xs_type(count) != XSTYPE_NUMBER; i++) {
543 const xs_number *val = xs_dict_get(actor, field_names[i]);
544 if (xs_type(val) == XSTYPE_NUMBER)
545 count = xs_dup(val);
546 }
547
548 /* if not found directly, try to get from cached collection object */
549 if (xs_type(count) != XSTYPE_NUMBER) {
550 const char *url = xs_dict_get(actor, collection_url_field);
551 if (!xs_is_null(url)) {
552 xs *coll = NULL;
553 if (valid_status(object_get(url, &coll))) {
554 const xs_number *total = xs_dict_get(coll, "totalItems");
555 if (xs_type(total) == XSTYPE_NUMBER)
556 count = xs_dup(total);
557 }
558 }
559 }
560
561 return count;
562}
563
564
565static const xs_list *get_collection_items(snac *snac, const char *collection_url,
566 xs_dict **out_collection, xs_dict **out_page)
567/* fetches items from an ActivityPub collection (outbox, etc) with minimal HTTP requests */
568{
569 const xs_list *items = NULL;
570 xs_dict *collection = NULL;
571
572 if (valid_status(activitypub_request(snac, collection_url, &collection))) {
573 /* check if items are directly embedded */
574 items = xs_dict_get(collection, "orderedItems");
575 if (xs_is_null(items))
576 items = xs_dict_get(collection, "items");
577
578 if (!xs_is_null(items)) {
579 /* items found in main collection - transfer ownership to keep items valid */
580 if (out_collection)
581 *out_collection = collection;
582 return items;
583 }
584
585 /* if no items, try fetching first page (only 1 extra request) */
586 const char *first_url = xs_dict_get(collection, "first");
587 if (!xs_is_null(first_url)) {
588 xs_dict *first_page = NULL;
589 if (valid_status(activitypub_request(snac, first_url, &first_page))) {
590 items = xs_dict_get(first_page, "orderedItems");
591 if (xs_is_null(items))
592 items = xs_dict_get(first_page, "items");
593
594 if (!xs_is_null(items)) {
595 /* items found in first page - transfer ownership to keep items valid */
596 if (out_page)
597 *out_page = first_page;
598 xs_free(collection);
599 return items;
600 }
601 xs_free(first_page);
602 }
603 }
604 xs_free(collection);
605 }
606
607 return NULL;
608}
609
610
611static const xs_dict *extract_post_from_item(const xs_val *item)
612/* extracts the post object from an outbox item, handling Create/Announce wrappers */
613{
614 if (xs_type(item) != XSTYPE_DICT)
615 return NULL;
616
617 const char *item_type = xs_dict_get(item, "type");
618 const xs_dict *post = item;
619
620 /* if it's an activity, try to get embedded object */
621 if (!xs_is_null(item_type) &&
622 (strcmp(item_type, "Create") == 0 || strcmp(item_type, "Announce") == 0)) {
623 const xs_val *obj = xs_dict_get(item, "object");
624
625 /* only use embedded objects, skip URL references (would need HTTP fetch) */
626 if (!xs_is_null(obj) && xs_type(obj) == XSTYPE_DICT)
627 post = obj;
628 else
629 return NULL;
630 }
631
632 return post;
633}
634
635
636static int is_valid_post_type(const char *post_type)
637/* checks if a type is a valid post type for timeline display */
638{
639 return !xs_is_null(post_type) &&
640 (strcmp(post_type, "Note") == 0 ||
641 strcmp(post_type, "Article") == 0 ||
642 strcmp(post_type, "Question") == 0 ||
643 strcmp(post_type, "Page") == 0);
644}
645
646
536xs_dict *mastoapi_account(snac *logged, const xs_dict *actor) 647xs_dict *mastoapi_account(snac *logged, const xs_dict *actor)
537/* converts an ActivityPub actor to a Mastodon account */ 648/* converts an ActivityPub actor to a Mastodon account */
538{ 649{
@@ -661,9 +772,22 @@ xs_dict *mastoapi_account(snac *logged, const xs_dict *actor)
661 } 772 }
662 773
663 acct = xs_dict_append(acct, "locked", xs_stock(XSTYPE_FALSE)); 774 acct = xs_dict_append(acct, "locked", xs_stock(XSTYPE_FALSE));
664 acct = xs_dict_append(acct, "followers_count", xs_stock(0)); 775
665 acct = xs_dict_append(acct, "following_count", xs_stock(0)); 776 /* try to get counts from actor object if available (some servers include these) */
666 acct = xs_dict_append(acct, "statuses_count", xs_stock(0)); 777 const char *fcount_fields[] = { "followersCount", "followers_count", NULL };
778 const char *gcount_fields[] = { "followingCount", "following_count", NULL };
779 const char *scount_fields[] = { "statusesCount", "statuses_count", "totalItems", NULL };
780
781 xs *followers_count = get_count_from_actor(actor, fcount_fields, "followers");
782 xs *following_count = get_count_from_actor(actor, gcount_fields, "following");
783 xs *statuses_count = get_count_from_actor(actor, scount_fields, "outbox");
784
785 acct = xs_dict_append(acct, "followers_count",
786 xs_type(followers_count) == XSTYPE_NUMBER ? followers_count : xs_stock(0));
787 acct = xs_dict_append(acct, "following_count",
788 xs_type(following_count) == XSTYPE_NUMBER ? following_count : xs_stock(0));
789 acct = xs_dict_append(acct, "statuses_count",
790 xs_type(statuses_count) == XSTYPE_NUMBER ? statuses_count : xs_stock(0));
667 791
668 xs *fields = xs_list_new(); 792 xs *fields = xs_list_new();
669 p = xs_dict_get(actor, "attachment"); 793 p = xs_dict_get(actor, "attachment");
@@ -1679,6 +1803,11 @@ int mastoapi_get_handler(const xs_dict *req, const char *q_path,
1679 xs *out = NULL; 1803 xs *out = NULL;
1680 xs *actor = NULL; 1804 xs *actor = NULL;
1681 1805
1806 if (logged_in && strcmp(uid, "familiar_followers") == 0) { /** **/
1807 /* familiar followers endpoint - return empty array */
1808 out = xs_list_new();
1809 }
1810 else
1682 if (logged_in && strcmp(uid, "search") == 0) { /** **/ 1811 if (logged_in && strcmp(uid, "search") == 0) { /** **/
1683 /* search for accounts starting with q */ 1812 /* search for accounts starting with q */
1684 const char *aq = xs_dict_get(args, "q"); 1813 const char *aq = xs_dict_get(args, "q");
@@ -1764,26 +1893,59 @@ int mastoapi_get_handler(const xs_dict *req, const char *q_path,
1764 else 1893 else
1765 if (strcmp(opt, "statuses") == 0) { /** **/ 1894 if (strcmp(opt, "statuses") == 0) { /** **/
1766 /* the public list of posts of a user */ 1895 /* the public list of posts of a user */
1896 const char *limit_s = xs_dict_get(args, "limit");
1897 const char *o_max_id = xs_dict_get(args, "max_id");
1898 int limit = limit_s ? atoi(limit_s) : 20;
1899 xs *max_id = o_max_id ? xs_tolower_i(xs_dup(o_max_id)) : NULL;
1900
1901 srv_debug(1, xs_fmt("account statuses: max_id=%s limit=%d", max_id ? max_id : "(null)", limit));
1902
1767 xs *timeline = timeline_simple_list(&snac2, "public", 0, 256, NULL); 1903 xs *timeline = timeline_simple_list(&snac2, "public", 0, 256, NULL);
1768 xs_list *p = timeline; 1904 xs_list *p = timeline;
1769 const xs_str *v; 1905 const xs_str *v;
1906 xs_set seen;
1907 int cnt = 0;
1908 int skip_until_max = max_id != NULL;
1770 1909
1771 out = xs_list_new(); 1910 out = xs_list_new();
1911 xs_set_init(&seen);
1772 1912
1773 while (xs_list_iter(&p, &v)) { 1913 while (xs_list_iter(&p, &v) && cnt < limit) {
1774 xs *msg = NULL; 1914 xs *msg = NULL;
1775 1915
1776 if (valid_status(timeline_get_by_md5(&snac2, v, &msg))) { 1916 if (valid_status(timeline_get_by_md5(&snac2, v, &msg))) {
1917 const char *msg_id = xs_dict_get(msg, "id");
1918
1777 /* add only posts by the author */ 1919 /* add only posts by the author */
1778 if (strcmp(xs_dict_get(msg, "type"), "Note") == 0 && 1920 if (!xs_is_null(msg_id) &&
1921 strcmp(xs_dict_get(msg, "type"), "Note") == 0 &&
1779 xs_startswith(xs_dict_get(msg, "id"), snac2.actor) && is_msg_public(msg)) { 1922 xs_startswith(xs_dict_get(msg, "id"), snac2.actor) && is_msg_public(msg)) {
1780 xs *st = mastoapi_status(&snac2, msg);
1781 1923
1782 if (st) 1924 /* if max_id is set, skip entries until we find it */
1783 out = xs_list_append(out, st); 1925 if (skip_until_max) {
1926 xs *mid = mastoapi_id(msg);
1927 if (strcmp(mid, max_id) == 0) {
1928 skip_until_max = 0;
1929 srv_debug(2, xs_fmt("account statuses: found max_id, starting from next post"));
1930 }
1931 continue;
1932 }
1933
1934 /* deduplicate by message id */
1935 if (xs_set_add(&seen, msg_id) == 1) {
1936 xs *st = mastoapi_status(&snac2, msg);
1937
1938 if (st) {
1939 out = xs_list_append(out, st);
1940 cnt++;
1941 }
1942 }
1784 } 1943 }
1785 } 1944 }
1786 } 1945 }
1946
1947 srv_debug(1, xs_fmt("account statuses: returning %d posts (requested %d)", cnt, limit));
1948 xs_set_free(&seen);
1787 } 1949 }
1788 else 1950 else
1789 if (strcmp(opt, "featured_tags") == 0) { 1951 if (strcmp(opt, "featured_tags") == 0) {
@@ -1815,6 +1977,11 @@ int mastoapi_get_handler(const xs_dict *req, const char *q_path,
1815 if (strcmp(opt, "lists") == 0) { 1977 if (strcmp(opt, "lists") == 0) {
1816 out = mastoapi_account_lists(&snac1, uid); 1978 out = mastoapi_account_lists(&snac1, uid);
1817 } 1979 }
1980 else
1981 if (strcmp(opt, "familiar_followers") == 0) {
1982 /* familiar followers - not implemented, return empty array */
1983 out = xs_list_new();
1984 }
1818 1985
1819 user_free(&snac2); 1986 user_free(&snac2);
1820 } 1987 }
@@ -1827,8 +1994,95 @@ int mastoapi_get_handler(const xs_dict *req, const char *q_path,
1827 } 1994 }
1828 else 1995 else
1829 if (strcmp(opt, "statuses") == 0) { 1996 if (strcmp(opt, "statuses") == 0) {
1830 /* we don't serve statuses of others; return the empty list */ 1997 /* fetch statuses from remote outbox */
1831 out = xs_list_new(); 1998 out = xs_list_new();
1999 const char *outbox_url = xs_dict_get(actor, "outbox");
2000
2001 if (!xs_is_null(outbox_url)) {
2002 /* extract query parameters */
2003 const char *limit_s = xs_dict_get(args, "limit");
2004 const char *exclude_replies_s = xs_dict_get(args, "exclude_replies");
2005 const char *o_max_id = xs_dict_get(args, "max_id");
2006
2007 int limit = 20;
2008 if (!xs_is_null(limit_s))
2009 limit = atoi(limit_s);
2010 if (limit == 0 || limit > 40)
2011 limit = 20;
2012
2013 int exclude_replies = !xs_is_null(exclude_replies_s) &&
2014 strcmp(exclude_replies_s, "true") == 0;
2015
2016 xs *max_id = o_max_id ? xs_tolower_i(xs_dup(o_max_id)) : NULL;
2017 int skip_until_max = max_id != NULL;
2018
2019 srv_debug(1, xs_fmt("remote account statuses: fetching from %s (max_id=%s limit=%d)",
2020 outbox_url, max_id ? max_id : "(null)", limit));
2021
2022 /* fetch first page only - safer for memory on large instances */
2023 xs *outbox_collection = NULL;
2024 xs *first_page = NULL;
2025 const xs_list *items = get_collection_items(&snac1, outbox_url,
2026 &outbox_collection, &first_page);
2027
2028 int count = 0;
2029 int processed = 0;
2030
2031 if (!xs_is_null(items) && xs_type(items) == XSTYPE_LIST) {
2032 int total_items = xs_list_len(items);
2033 srv_debug(1, xs_fmt("remote account statuses: got %d items from outbox", total_items));
2034
2035 const xs_val *item;
2036
2037 xs_list_foreach(items, item) {
2038 processed++;
2039
2040 if (count >= limit)
2041 break;
2042
2043 const xs_dict *post = extract_post_from_item(item);
2044 if (!post)
2045 continue;
2046
2047 const char *post_type = xs_dict_get(post, "type");
2048 const char *in_reply_to = xs_dict_get(post, "inReplyTo");
2049
2050 /* apply filters */
2051 if (exclude_replies && !xs_is_null(in_reply_to))
2052 continue;
2053
2054 if (is_valid_post_type(post_type)) {
2055 /* store object locally so mastoapi_id() can generate valid IDs */
2056 const char *post_id = xs_dict_get(post, "id");
2057 if (!xs_is_null(post_id))
2058 object_add(post_id, post);
2059
2060 /* handle pagination with max_id */
2061 if (skip_until_max) {
2062 xs *mid = mastoapi_id(post);
2063 if (!xs_is_null(mid) && strcmp(mid, max_id) == 0) {
2064 skip_until_max = 0;
2065 srv_debug(2, xs_fmt("remote account statuses: found max_id at position %d", processed));
2066 }
2067 continue;
2068 }
2069
2070 /* pass logged-in user context to enable media proxying if configured */
2071 xs *st = mastoapi_status(&snac1, post);
2072 if (st) {
2073 out = xs_list_append(out, st);
2074 count++;
2075 }
2076 }
2077 }
2078 }
2079 else {
2080 srv_debug(1, xs_fmt("remote account statuses: no items found in outbox"));
2081 }
2082
2083 srv_debug(1, xs_fmt("remote account statuses: processed %d items, returning %d posts (requested %d)",
2084 processed, count, limit));
2085 }
1832 } 2086 }
1833 else 2087 else
1834 if (strcmp(opt, "featured_tags") == 0) { 2088 if (strcmp(opt, "featured_tags") == 0) {
@@ -1840,6 +2094,11 @@ int mastoapi_get_handler(const xs_dict *req, const char *q_path,
1840 if (strcmp(opt, "lists") == 0) { 2094 if (strcmp(opt, "lists") == 0) {
1841 out = mastoapi_account_lists(&snac1, uid); 2095 out = mastoapi_account_lists(&snac1, uid);
1842 } 2096 }
2097 else
2098 if (strcmp(opt, "familiar_followers") == 0) {
2099 /* familiar followers - not implemented, return empty array */
2100 out = xs_list_new();
2101 }
1843 } 2102 }
1844 } 2103 }
1845 2104
@@ -2730,8 +2989,9 @@ int mastoapi_post_handler(const xs_dict *req, const char *q_path,
2730 const char *i_ctype = xs_dict_get(req, "content-type"); 2989 const char *i_ctype = xs_dict_get(req, "content-type");
2731 2990
2732 if (i_ctype && xs_startswith(i_ctype, "application/json")) { 2991 if (i_ctype && xs_startswith(i_ctype, "application/json")) {
2733 if (!xs_is_null(payload)) 2992 if (!xs_is_null(payload)) {
2734 args = xs_json_loads(payload); 2993 args = xs_json_loads(payload);
2994 }
2735 } 2995 }
2736 else if (i_ctype && xs_startswith(i_ctype, "application/x-www-form-urlencoded")) 2996 else if (i_ctype && xs_startswith(i_ctype, "application/x-www-form-urlencoded"))
2737 { 2997 {
@@ -2740,11 +3000,24 @@ int mastoapi_post_handler(const xs_dict *req, const char *q_path,
2740 args = xs_url_vars(payload); 3000 args = xs_url_vars(payload);
2741 } 3001 }
2742 } 3002 }
2743 else 3003 else if (i_ctype && xs_startswith(i_ctype, "multipart/form-data"))
3004 {
3005 // Handle multipart/form-data by using p_vars (already parsed by httpd)
3006 args = xs_dup(xs_dict_get(req, "p_vars"));
3007 }
3008
3009 /* if args still NULL, try falling back to p_vars or q_vars */
3010 if (args == NULL)
2744 args = xs_dup(xs_dict_get(req, "p_vars")); 3011 args = xs_dup(xs_dict_get(req, "p_vars"));
2745 3012
2746 if (args == NULL) 3013 if (args == NULL)
3014 args = xs_dup(xs_dict_get(req, "q_vars"));
3015
3016 if (args == NULL) {
3017 srv_debug(1, xs_fmt("mastoapi_post_handler: failed to parse args for %s, content-type: %s",
3018 q_path, i_ctype ? i_ctype : "(null)"));
2747 return HTTP_STATUS_BAD_REQUEST; 3019 return HTTP_STATUS_BAD_REQUEST;
3020 }
2748 3021
2749 xs *cmd = xs_replace_n(q_path, "/api", "", 1); 3022 xs *cmd = xs_replace_n(q_path, "/api", "", 1);
2750 3023
@@ -2926,7 +3199,12 @@ int mastoapi_post_handler(const xs_dict *req, const char *q_path,
2926 /* skip the 'fake' part of the id */ 3199 /* skip the 'fake' part of the id */
2927 mid = MID_TO_MD5(mid); 3200 mid = MID_TO_MD5(mid);
2928 3201
2929 if (valid_status(timeline_get_by_md5(&snac, mid, &msg))) { 3202 /* try timeline first, then global object store for remote posts */
3203 int found = valid_status(timeline_get_by_md5(&snac, mid, &msg));
3204 if (!found)
3205 found = valid_status(object_get_by_md5(mid, &msg));
3206
3207 if (found) {
2930 const char *id = xs_dict_get(msg, "id"); 3208 const char *id = xs_dict_get(msg, "id");
2931 3209
2932 if (op == NULL) { 3210 if (op == NULL) {
@@ -3599,11 +3877,31 @@ int mastoapi_put_handler(const xs_dict *req, const char *q_path,
3599 if (!xs_is_null(payload)) 3877 if (!xs_is_null(payload))
3600 args = xs_json_loads(payload); 3878 args = xs_json_loads(payload);
3601 } 3879 }
3602 else 3880 else if (i_ctype && xs_startswith(i_ctype, "application/x-www-form-urlencoded"))
3881 {
3882 // Some apps send form data instead of json so we should cater for those
3883 if (!xs_is_null(payload)) {
3884 args = xs_url_vars(payload);
3885 }
3886 }
3887 else if (i_ctype && xs_startswith(i_ctype, "multipart/form-data"))
3888 {
3889 // Handle multipart/form-data by using p_vars (already parsed by httpd)
3603 args = xs_dup(xs_dict_get(req, "p_vars")); 3890 args = xs_dup(xs_dict_get(req, "p_vars"));
3891 }
3604 3892
3893 /* if args still NULL, try falling back to p_vars or q_vars */
3605 if (args == NULL) 3894 if (args == NULL)
3895 args = xs_dup(xs_dict_get(req, "p_vars"));
3896
3897 if (args == NULL)
3898 args = xs_dup(xs_dict_get(req, "q_vars"));
3899
3900 if (args == NULL) {
3901 srv_debug(1, xs_fmt("mastoapi_put_handler: failed to parse args for %s, content-type: %s",
3902 q_path, i_ctype ? i_ctype : "(null)"));
3606 return HTTP_STATUS_BAD_REQUEST; 3903 return HTTP_STATUS_BAD_REQUEST;
3904 }
3607 3905
3608 xs *cmd = xs_replace_n(q_path, "/api", "", 1); 3906 xs *cmd = xs_replace_n(q_path, "/api", "", 1);
3609 3907
@@ -3753,11 +4051,24 @@ int mastoapi_patch_handler(const xs_dict *req, const char *q_path,
3753 args = xs_url_vars(payload); 4051 args = xs_url_vars(payload);
3754 } 4052 }
3755 } 4053 }
3756 else 4054 else if (i_ctype && xs_startswith(i_ctype, "multipart/form-data"))
4055 {
4056 // Handle multipart/form-data by using p_vars (already parsed by httpd)
4057 args = xs_dup(xs_dict_get(req, "p_vars"));
4058 }
4059
4060 /* if args still NULL, try falling back to p_vars or q_vars */
4061 if (args == NULL)
3757 args = xs_dup(xs_dict_get(req, "p_vars")); 4062 args = xs_dup(xs_dict_get(req, "p_vars"));
3758 4063
3759 if (args == NULL) 4064 if (args == NULL)
4065 args = xs_dup(xs_dict_get(req, "q_vars"));
4066
4067 if (args == NULL) {
4068 srv_debug(1, xs_fmt("mastoapi_patch_handler: failed to parse args for %s, content-type: %s",
4069 q_path, i_ctype ? i_ctype : "(null)"));
3760 return HTTP_STATUS_BAD_REQUEST; 4070 return HTTP_STATUS_BAD_REQUEST;
4071 }
3761 4072
3762 xs *cmd = xs_replace_n(q_path, "/api", "", 1); 4073 xs *cmd = xs_replace_n(q_path, "/api", "", 1);
3763 4074