summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGravatar ltning2025-01-27 18:07:00 +0000
committerGravatar ltning2025-01-27 18:07:00 +0000
commitf6044d3aa0241a832b0ad1d2c394c0a1b814dbe3 (patch)
tree72334e7a24b997957d201490681552b6b1ad2e2f
parentAdd short_description_raw option (diff)
parentFixed crash in the notification area after deleting a post. (diff)
downloadpenes-snac2-f6044d3aa0241a832b0ad1d2c394c0a1b814dbe3.tar.gz
penes-snac2-f6044d3aa0241a832b0ad1d2c394c0a1b814dbe3.tar.xz
penes-snac2-f6044d3aa0241a832b0ad1d2c394c0a1b814dbe3.zip
Merge branch 'master' into master
-rw-r--r--LICENSE2
-rw-r--r--Makefile3
-rw-r--r--Makefile.NetBSD5
-rw-r--r--RELEASE_NOTES.md30
-rw-r--r--TODO.md6
-rw-r--r--activitypub.c283
-rw-r--r--data.c63
-rw-r--r--doc/snac.19
-rw-r--r--doc/snac.57
-rw-r--r--doc/snac.840
-rw-r--r--format.c49
-rw-r--r--html.c393
-rw-r--r--http.c2
-rw-r--r--httpd.c54
-rw-r--r--main.c74
-rw-r--r--mastoapi.c76
-rw-r--r--sandbox.c9
-rw-r--r--snac.c2
-rw-r--r--snac.h8
-rw-r--r--upgrade.c2
-rw-r--r--utils.c10
-rw-r--r--webfinger.c2
-rw-r--r--xs.h26
-rw-r--r--xs_curl.h2
-rw-r--r--xs_fcgi.h2
-rw-r--r--xs_glob.h2
-rw-r--r--xs_hex.h2
-rw-r--r--xs_html.h2
-rw-r--r--xs_httpd.h2
-rw-r--r--xs_io.h2
-rw-r--r--xs_json.h2
-rw-r--r--xs_match.h2
-rw-r--r--xs_mime.h2
-rw-r--r--xs_openssl.h2
-rw-r--r--xs_random.h2
-rw-r--r--xs_regex.h2
-rw-r--r--xs_set.h2
-rw-r--r--xs_socket.h2
-rw-r--r--xs_time.h2
-rw-r--r--xs_unicode.h2
-rw-r--r--xs_unix_socket.h2
-rw-r--r--xs_url.h25
-rw-r--r--xs_version.h2
43 files changed, 1020 insertions, 198 deletions
diff --git a/LICENSE b/LICENSE
index cdb4e0b..702b487 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,6 +1,6 @@
1MIT License 1MIT License
2 2
3Copyright (c) 2022 - 2024 grunfink et al. (Fediverse: @grunfink@comam.es) 3Copyright (c) 2022 - 2025 grunfink et al. (Fediverse: @grunfink@comam.es)
4 4
5Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 5Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
6 6
diff --git a/Makefile b/Makefile
index 8aa2ca9..75d9562 100644
--- a/Makefile
+++ b/Makefile
@@ -42,7 +42,8 @@ data.o: data.c xs.h xs_hex.h xs_io.h xs_json.h xs_openssl.h xs_glob.h \
42format.o: format.c xs.h xs_regex.h xs_mime.h xs_html.h xs_json.h \ 42format.o: format.c xs.h xs_regex.h xs_mime.h xs_html.h xs_json.h \
43 xs_time.h xs_match.h snac.h http_codes.h 43 xs_time.h xs_match.h snac.h http_codes.h
44html.o: html.c xs.h xs_io.h xs_json.h xs_regex.h xs_set.h xs_openssl.h \ 44html.o: html.c xs.h xs_io.h xs_json.h xs_regex.h xs_set.h xs_openssl.h \
45 xs_time.h xs_mime.h xs_match.h xs_html.h xs_curl.h snac.h http_codes.h 45 xs_time.h xs_mime.h xs_match.h xs_html.h xs_curl.h xs_unicode.h snac.h \
46 http_codes.h
46http.o: http.c xs.h xs_io.h xs_openssl.h xs_curl.h xs_time.h xs_json.h \ 47http.o: http.c xs.h xs_io.h xs_openssl.h xs_curl.h xs_time.h xs_json.h \
47 snac.h http_codes.h 48 snac.h http_codes.h
48httpd.o: httpd.c xs.h xs_io.h xs_json.h xs_socket.h xs_unix_socket.h \ 49httpd.o: httpd.c xs.h xs_io.h xs_json.h xs_socket.h xs_unix_socket.h \
diff --git a/Makefile.NetBSD b/Makefile.NetBSD
index 20cf28b..93222b2 100644
--- a/Makefile.NetBSD
+++ b/Makefile.NetBSD
@@ -5,7 +5,7 @@ LDFLAGS=-lrt
5 5
6all: snac 6all: snac
7 7
8snac: snac.o main.o data.o http.o httpd.o webfinger.o \ 8snac: snac.o main.o sandbox.o data.o http.o httpd.o webfinger.o \
9 activitypub.o html.o utils.o format.o upgrade.o mastoapi.o 9 activitypub.o html.o utils.o format.o upgrade.o mastoapi.o
10 $(CC) $(CFLAGS) -L/usr/pkg/lib *.o -lcurl -lcrypto -pthread $(LDFLAGS) -Wl,-rpath,/usr/lib -Wl,-rpath,/usr/pkg/lib -o $@ 10 $(CC) $(CFLAGS) -L/usr/pkg/lib *.o -lcurl -lcrypto -pthread $(LDFLAGS) -Wl,-rpath,/usr/lib -Wl,-rpath,/usr/pkg/lib -o $@
11 11
@@ -44,7 +44,8 @@ data.o: data.c xs.h xs_hex.h xs_io.h xs_json.h xs_openssl.h xs_glob.h \
44format.o: format.c xs.h xs_regex.h xs_mime.h xs_html.h xs_json.h \ 44format.o: format.c xs.h xs_regex.h xs_mime.h xs_html.h xs_json.h \
45 xs_time.h xs_match.h snac.h http_codes.h 45 xs_time.h xs_match.h snac.h http_codes.h
46html.o: html.c xs.h xs_io.h xs_json.h xs_regex.h xs_set.h xs_openssl.h \ 46html.o: html.c xs.h xs_io.h xs_json.h xs_regex.h xs_set.h xs_openssl.h \
47 xs_time.h xs_mime.h xs_match.h xs_html.h xs_curl.h snac.h http_codes.h 47 xs_time.h xs_mime.h xs_match.h xs_html.h xs_curl.h xs_unicode.h snac.h \
48 http_codes.h
48http.o: http.c xs.h xs_io.h xs_openssl.h xs_curl.h xs_time.h xs_json.h \ 49http.o: http.c xs.h xs_io.h xs_openssl.h xs_curl.h xs_time.h xs_json.h \
49 snac.h http_codes.h 50 snac.h http_codes.h
50httpd.o: httpd.c xs.h xs_io.h xs_json.h xs_socket.h xs_unix_socket.h \ 51httpd.o: httpd.c xs.h xs_io.h xs_json.h xs_socket.h xs_unix_socket.h \
diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md
index 247b53b..23a2a17 100644
--- a/RELEASE_NOTES.md
+++ b/RELEASE_NOTES.md
@@ -1,6 +1,34 @@
1# Release Notes 1# Release Notes
2 2
3## UNRELEASED 3## 2.69 "Yin/Yang of Love"
4
5Added support for subscribing to LitePub (Pleroma-style) Fediverse Relays like e.g. https://fedi-relay.gyptazy.com to improve federation. See `snac(8)` (the Administrator Manual) for more information on how to use this feature.
6
7Added support for following hashtags. This is only useful if your instance is subscribed to relays (see above).
8
9Added support for a Mastodon-like `/authorize_interaction` webpoint entry, that allows following, liking and boosting from another account's Mastodon public web interface. To be able to use it, you must reconfigure your https proxy to redirect `/authorize_interaction` to snac (see `snac(8)`).
10
11Some fixes to accept `Event` objects properly (like those coming from implementations like https://gancio.org/ or https://mobilizon.fr).
12
13Added some caching for local `Actor` objects.
14
15Hashtags that are not explicitly linked in a post's content are shown below it.
16
17Fixed broken NetBSD build (missing dependency in Makefile.NetBSD).
18
19The user profile can now include longitude and latitude data for your current location.
20
21Mastodon API: implemented limit= on notification fetches (contributed by nowster), implemented faster min_id handling (contributed by nowster), obey the quiet public visibility set for posts, other timeline improvements (contributed by nowster).
22
23Reduced RSA key size for new users from 4096 to 2048. This will be friendlier to smaller machines, and everybody else out there is using 2048.
24
25If the `SNAC_BASEDIR` environment variable is defined and set to the base directory of your installation, you don't have to include the base directory in the command line.
26
27Fixed a bug in the generation of the top page (contributed by an-im-dugud).
28
29Added support for Markdown headers and underlining (contributed by an-im-dugud).
30
31## 2.68
4 32
5Fixed regression in link verification code (contributed by nowster). 33Fixed regression in link verification code (contributed by nowster).
6 34
diff --git a/TODO.md b/TODO.md
index 8ca0b14..86c706a 100644
--- a/TODO.md
+++ b/TODO.md
@@ -22,7 +22,7 @@ Mastoapi: implement /v1/conversations.
22 22
23Implement following of hashtags (this is not trivial). 23Implement following of hashtags (this is not trivial).
24 24
25Track 'Event' data types standardization; how to add plan-to-attend and similar activities (more info: https://event-federation.eu/) 25Track 'Event' data types standardization; how to add plan-to-attend and similar activities (more info: https://event-federation.eu/). Friendica interacts with events via activities `Accept` (will go), `TentativeAccept` (will try to go) or `Reject` (cannot go) (`object` field as id, not object). `Undo` for any of these activities cancel (`object` as an object, not id).
26 26
27Implement "FEP-3b86: Activity Intents" https://codeberg.org/fediverse/fep/src/branch/main/fep/3b86/fep-3b86.md 27Implement "FEP-3b86: Activity Intents" https://codeberg.org/fediverse/fep/src/branch/main/fep/3b86/fep-3b86.md
28 28
@@ -32,8 +32,6 @@ Integrate "Added handling for International Domain Names" PR https://codeberg.or
32 32
33Do something about Akkoma and Misskey's quoted replies (they use the `quoteUrl` field instead of `inReplyTo`). 33Do something about Akkoma and Misskey's quoted replies (they use the `quoteUrl` field instead of `inReplyTo`).
34 34
35Add support for /authorize_interaction (whatever it is).
36
37Add a list of hashtags to drop. 35Add a list of hashtags to drop.
38 36
39Take a look at crashes in the brittle Mastodon official app (crashes when hitting the reply button, crashes or 'ownVotes is null' errors when trying to show polls). 37Take a look at crashes in the brittle Mastodon official app (crashes when hitting the reply button, crashes or 'ownVotes is null' errors when trying to show polls).
@@ -365,3 +363,5 @@ Unfollowing lemmy groups gets rejected with an http status of 400 (it seems to w
365CSV import/export does not work with OpenBSD security on; document it or fix it (2025-01-04T19:35:09+0100). 363CSV import/export does not work with OpenBSD security on; document it or fix it (2025-01-04T19:35:09+0100).
366 364
367Add support for /share?text=tt&website=url (whatever it is, see https://mastodonshare.com/ for details) (2025-01-06T18:43:52+0100). 365Add support for /share?text=tt&website=url (whatever it is, see https://mastodonshare.com/ for details) (2025-01-06T18:43:52+0100).
366
367Add support for /authorize_interaction (whatever it is) (2025-01-16T14:45:28+0100).
diff --git a/activitypub.c b/activitypub.c
index 1ae5ad9..cade0d9 100644
--- a/activitypub.c
+++ b/activitypub.c
@@ -1,5 +1,5 @@
1/* snac - A simple, minimalistic ActivityPub instance */ 1/* snac - A simple, minimalistic ActivityPub instance */
2/* copyright (c) 2022 - 2024 grunfink et al. / MIT license */ 2/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */
3 3
4#include "xs.h" 4#include "xs.h"
5#include "xs_json.h" 5#include "xs_json.h"
@@ -587,6 +587,70 @@ int is_msg_from_private_user(const xs_dict *msg)
587} 587}
588 588
589 589
590int followed_hashtag_check(snac *user, const xs_dict *msg)
591/* returns true if this message contains a hashtag followed by me */
592{
593 const xs_list *fw_tags = xs_dict_get(user->config, "followed_hashtags");
594
595 if (xs_is_list(fw_tags)) {
596 const xs_list *tags_in_msg = xs_dict_get(msg, "tag");
597
598 if (xs_is_list(tags_in_msg)) {
599 const xs_dict *te;
600
601 /* iterate the tags in the message */
602 xs_list_foreach(tags_in_msg, te) {
603 if (xs_is_dict(te)) {
604 const char *type = xs_dict_get(te, "type");
605 const char *name = xs_dict_get(te, "name");
606
607 if (xs_is_string(type) && xs_is_string(name)) {
608 if (strcmp(type, "Hashtag") == 0) {
609 xs *lc_name = xs_utf8_to_lower(name);
610
611 if (xs_list_in(fw_tags, lc_name) != -1)
612 return 1;
613 }
614 }
615 }
616 }
617 }
618 }
619
620 return 0;
621}
622
623
624void followed_hashtag_distribute(const xs_dict *msg)
625/* distribute this post to all users following the included hashtags */
626{
627 const char *id = xs_dict_get(msg, "id");
628 const xs_list *tags_in_msg = xs_dict_get(msg, "tag");
629
630 if (!xs_is_string(id) || !xs_is_list(tags_in_msg) || xs_list_len(tags_in_msg) == 0)
631 return;
632
633 srv_debug(1, xs_fmt("followed_hashtag_distribute check for %s", id));
634
635 xs *users = user_list();
636 const char *uid;
637
638 xs_list_foreach(users, uid) {
639 snac user;
640
641 if (user_open(&user, uid)) {
642 if (followed_hashtag_check(&user, msg)) {
643 timeline_add(&user, id, msg);
644
645 snac_log(&user, xs_fmt("followed hashtag in %s", id));
646 }
647
648 user_free(&user);
649 }
650 }
651}
652
653
590int is_msg_for_me(snac *snac, const xs_dict *c_msg) 654int is_msg_for_me(snac *snac, const xs_dict *c_msg)
591/* checks if this message is for me */ 655/* checks if this message is for me */
592{ 656{
@@ -602,19 +666,32 @@ int is_msg_for_me(snac *snac, const xs_dict *c_msg)
602 if (xs_match(type, "Like|Announce|EmojiReact")) { 666 if (xs_match(type, "Like|Announce|EmojiReact")) {
603 const char *object = xs_dict_get(c_msg, "object"); 667 const char *object = xs_dict_get(c_msg, "object");
604 668
605 if (xs_type(object) == XSTYPE_DICT) 669 if (xs_is_dict(object))
606 object = xs_dict_get(object, "id"); 670 object = xs_dict_get(object, "id");
607 671
608 /* bad object id? reject */ 672 /* bad object id? reject */
609 if (xs_type(object) != XSTYPE_STRING) 673 if (!xs_is_string(object))
610 return 0; 674 return 0;
611 675
612 /* if it's about one of our posts, accept it */ 676 /* if it's about one of our posts, accept it */
613 if (xs_startswith(object, snac->actor)) 677 if (xs_startswith(object, snac->actor))
614 return 2; 678 return 2;
615 679
616 /* if it's by someone we don't follow, reject */ 680 /* if it's by someone we follow, accept it */
617 return following_check(snac, actor); 681 if (following_check(snac, actor))
682 return 1;
683
684 /* do we follow any hashtag? */
685 if (xs_is_list(xs_dict_get(snac->config, "followed_hashtags"))) {
686 xs *obj = NULL;
687
688 /* if the admired object contains any followed hashtag, accept it */
689 if (valid_status(object_get(object, &obj)) &&
690 followed_hashtag_check(snac, obj))
691 return 7;
692 }
693
694 return 0;
618 } 695 }
619 696
620 /* if it's an Undo, it must be from someone related to us */ 697 /* if it's an Undo, it must be from someone related to us */
@@ -675,7 +752,7 @@ int is_msg_for_me(snac *snac, const xs_dict *c_msg)
675 752
676 if (pub_msg) { 753 if (pub_msg) {
677 /* a public message for someone we follow? (probably cc'ed) accept */ 754 /* a public message for someone we follow? (probably cc'ed) accept */
678 if (following_check(snac, v)) 755 if (strcmp(v, public_address) != 0 && following_check(snac, v))
679 return 5; 756 return 5;
680 } 757 }
681 else 758 else
@@ -708,30 +785,8 @@ int is_msg_for_me(snac *snac, const xs_dict *c_msg)
708 } 785 }
709 786
710 /* does this message contain a tag we are following? */ 787 /* does this message contain a tag we are following? */
711 const xs_list *fw_tags = xs_dict_get(snac->config, "followed_hashtags"); 788 if (pub_msg && followed_hashtag_check(snac, msg))
712 if (pub_msg && xs_type(fw_tags) == XSTYPE_LIST) { 789 return 7;
713 const xs_list *tags_in_msg = xs_dict_get(msg, "tag");
714 if (xs_type(tags_in_msg) == XSTYPE_LIST) {
715 const xs_dict *te;
716
717 /* iterate the tags in the message */
718 xs_list_foreach(tags_in_msg, te) {
719 if (xs_type(te) == XSTYPE_DICT) {
720 const char *type = xs_dict_get(te, "type");
721 const char *name = xs_dict_get(te, "name");
722
723 if (xs_type(type) == XSTYPE_STRING && xs_type(name) == XSTYPE_STRING) {
724 if (strcmp(type, "Hashtag") == 0) {
725 xs *lc_name = xs_utf8_to_lower(name);
726
727 if (xs_list_in(fw_tags, lc_name) != -1)
728 return 7;
729 }
730 }
731 }
732 }
733 }
734 }
735 790
736 return 0; 791 return 0;
737} 792}
@@ -889,6 +944,11 @@ void notify(snac *snac, const char *type, const char *utype, const char *actor,
889 /* if it's not an admiration about something by us, done */ 944 /* if it's not an admiration about something by us, done */
890 if (xs_is_null(objid) || !xs_startswith(objid, snac->actor)) 945 if (xs_is_null(objid) || !xs_startswith(objid, snac->actor))
891 return; 946 return;
947
948 /* if it's an announce by our own relay, done */
949 xs *relay_id = xs_fmt("%s/relay", srv_baseurl);
950 if (xs_startswith(id, relay_id))
951 return;
892 } 952 }
893 953
894 /* updated poll? */ 954 /* updated poll? */
@@ -1184,6 +1244,28 @@ xs_dict *msg_repulsion(snac *user, const char *id, const char *type)
1184} 1244}
1185 1245
1186 1246
1247xs_dict *msg_actor_place(snac *user, const char *label)
1248/* creates a Place object, if the user has a location defined */
1249{
1250 xs_dict *place = NULL;
1251 const char *latitude = xs_dict_get_def(user->config, "latitude", "");
1252 const char *longitude = xs_dict_get_def(user->config, "longitude", "");
1253
1254 if (*latitude && *longitude) {
1255 xs *d_la = xs_number_new(atof(latitude));
1256 xs *d_lo = xs_number_new(atof(longitude));
1257
1258 place = msg_base(user, "Place", NULL, user->actor, NULL, NULL);
1259
1260 place = xs_dict_set(place, "name", label);
1261 place = xs_dict_set(place, "latitude", d_la);
1262 place = xs_dict_set(place, "longitude", d_lo);
1263 }
1264
1265 return place;
1266}
1267
1268
1187xs_dict *msg_actor(snac *snac) 1269xs_dict *msg_actor(snac *snac)
1188/* create a Person message for this actor */ 1270/* create a Person message for this actor */
1189{ 1271{
@@ -1194,10 +1276,20 @@ xs_dict *msg_actor(snac *snac)
1194 xs *avtr = NULL; 1276 xs *avtr = NULL;
1195 xs *kid = NULL; 1277 xs *kid = NULL;
1196 xs *f_bio = NULL; 1278 xs *f_bio = NULL;
1197 xs_dict *msg = msg_base(snac, "Person", snac->actor, NULL, NULL, NULL); 1279 xs_dict *msg = NULL;
1198 const char *p; 1280 const char *p;
1199 int n; 1281 int n;
1200 1282
1283 /* everybody loves some caching */
1284 if (time(NULL) - object_mtime(snac->actor) < 3 * 3600 &&
1285 valid_status(object_get(snac->actor, &msg))) {
1286 snac_debug(snac, 2, xs_fmt("Returning cached actor %s", snac->actor));
1287
1288 return msg;
1289 }
1290
1291 msg = msg_base(snac, "Person", snac->actor, NULL, NULL, NULL);
1292
1201 /* change the @context (is this really necessary?) */ 1293 /* change the @context (is this really necessary?) */
1202 ctxt = xs_list_append(ctxt, "https:/" "/www.w3.org/ns/activitystreams"); 1294 ctxt = xs_list_append(ctxt, "https:/" "/www.w3.org/ns/activitystreams");
1203 ctxt = xs_list_append(ctxt, "https:/" "/w3id.org/security/v1"); 1295 ctxt = xs_list_append(ctxt, "https:/" "/w3id.org/security/v1");
@@ -1242,6 +1334,10 @@ xs_dict *msg_actor(snac *snac)
1242 if (xs_type(xs_dict_get(snac->config, "bot")) == XSTYPE_TRUE) 1334 if (xs_type(xs_dict_get(snac->config, "bot")) == XSTYPE_TRUE)
1243 msg = xs_dict_set(msg, "type", "Service"); 1335 msg = xs_dict_set(msg, "type", "Service");
1244 1336
1337 /* if it's named "relay", then identify as an "Application" */
1338 if (strcmp(snac->uid, "relay") == 0)
1339 msg = xs_dict_set(msg, "type", "Application");
1340
1245 /* add the header image, if there is one defined */ 1341 /* add the header image, if there is one defined */
1246 const char *header = xs_dict_get(snac->config, "header"); 1342 const char *header = xs_dict_get(snac->config, "header");
1247 if (!xs_is_null(header)) { 1343 if (!xs_is_null(header)) {
@@ -1307,7 +1403,7 @@ xs_dict *msg_actor(snac *snac)
1307 } 1403 }
1308 1404
1309 /* use shared inboxes? */ 1405 /* use shared inboxes? */
1310 if (xs_type(xs_dict_get(srv_config, "shared_inboxes")) == XSTYPE_TRUE) { 1406 if (xs_is_true(xs_dict_get(srv_config, "shared_inboxes")) || strcmp(snac->uid, "relay") == 0) {
1311 xs *d = xs_dict_new(); 1407 xs *d = xs_dict_new();
1312 xs *si = xs_fmt("%s/shared-inbox", srv_baseurl); 1408 xs *si = xs_fmt("%s/shared-inbox", srv_baseurl);
1313 d = xs_dict_append(d, "sharedInbox", si); 1409 d = xs_dict_append(d, "sharedInbox", si);
@@ -1326,6 +1422,15 @@ xs_dict *msg_actor(snac *snac)
1326 msg = xs_dict_set(msg, "manuallyApprovesFollowers", 1422 msg = xs_dict_set(msg, "manuallyApprovesFollowers",
1327 xs_stock(xs_is_true(manually) ? XSTYPE_TRUE : XSTYPE_FALSE)); 1423 xs_stock(xs_is_true(manually) ? XSTYPE_TRUE : XSTYPE_FALSE));
1328 1424
1425 /* if there are location coords, create a Place object */
1426 xs *location = msg_actor_place(snac, "Home");
1427 if (xs_type(location) == XSTYPE_DICT)
1428 msg = xs_dict_set(msg, "location", location);
1429
1430 /* cache it */
1431 snac_debug(snac, 1, xs_fmt("Caching actor %s", snac->actor));
1432 object_add_ow(snac->actor, msg);
1433
1329 return msg; 1434 return msg;
1330} 1435}
1331 1436
@@ -1423,8 +1528,9 @@ xs_dict *msg_follow(snac *snac, const char *q)
1423 1528
1424xs_dict *msg_note(snac *snac, const xs_str *content, const xs_val *rcpts, 1529xs_dict *msg_note(snac *snac, const xs_str *content, const xs_val *rcpts,
1425 const xs_str *in_reply_to, const xs_list *attach, 1530 const xs_str *in_reply_to, const xs_list *attach,
1426 int priv, const char *lang_str) 1531 int scope, const char *lang_str)
1427/* creates a 'Note' message */ 1532/* creates a 'Note' message */
1533/* scope: 0, public; 1, private (mentioned only); 2, "quiet public"; 3, followers only */
1428{ 1534{
1429 xs *ntid = tid(0); 1535 xs *ntid = tid(0);
1430 xs *id = xs_fmt("%s/p/%s", snac->actor, ntid); 1536 xs *id = xs_fmt("%s/p/%s", snac->actor, ntid);
@@ -1440,6 +1546,9 @@ xs_dict *msg_note(snac *snac, const xs_str *content, const xs_val *rcpts,
1440 xs_list *p; 1546 xs_list *p;
1441 const xs_val *v; 1547 const xs_val *v;
1442 1548
1549 /* FIXME: implement scope 3 */
1550 int priv = scope == 1;
1551
1443 if (rcpts == NULL) 1552 if (rcpts == NULL)
1444 to = xs_list_new(); 1553 to = xs_list_new();
1445 else { 1554 else {
@@ -1557,6 +1666,12 @@ xs_dict *msg_note(snac *snac, const xs_str *content, const xs_val *rcpts,
1557 } 1666 }
1558 } 1667 }
1559 1668
1669 if (scope == 2) {
1670 /* Mastodon's "quiet public": add public address to cc */
1671 if (xs_list_in(cc, public_address) == -1)
1672 cc = xs_list_append(cc, public_address);
1673 }
1674 else
1560 /* no recipients? must be for everybody */ 1675 /* no recipients? must be for everybody */
1561 if (!priv && xs_list_len(to) == 0) 1676 if (!priv && xs_list_len(to) == 0)
1562 to = xs_list_append(to, public_address); 1677 to = xs_list_append(to, public_address);
@@ -1845,6 +1960,17 @@ int process_input_message(snac *snac, const xs_dict *msg, const xs_dict *req)
1845 /* reject uninteresting messages right now */ 1960 /* reject uninteresting messages right now */
1846 if (xs_match(type, "Add|View|Reject|Read|Remove")) { 1961 if (xs_match(type, "Add|View|Reject|Read|Remove")) {
1847 srv_debug(0, xs_fmt("Ignored message of type '%s'", type)); 1962 srv_debug(0, xs_fmt("Ignored message of type '%s'", type));
1963
1964 /* archive the ignored activity */
1965 xs *ntid = tid(0);
1966 xs *fn = xs_fmt("%s/ignored/%s.json", srv_basedir, ntid);
1967 FILE *f;
1968
1969 if ((f = fopen(fn, "w")) != NULL) {
1970 xs_json_dump(msg, 4, f);
1971 fclose(f);
1972 }
1973
1848 return -1; 1974 return -1;
1849 } 1975 }
1850 1976
@@ -2118,14 +2244,14 @@ int process_input_message(snac *snac, const xs_dict *msg, const xs_dict *req)
2118 snac_log(snac, xs_fmt("new 'Question' %s %s", actor, id)); 2244 snac_log(snac, xs_fmt("new 'Question' %s %s", actor, id));
2119 } 2245 }
2120 else 2246 else
2121 if (strcmp(utype, "Video") == 0) { /** **/ 2247 if (xs_match(utype, "Audio|Video|Event")) { /** **/
2122 const char *id = xs_dict_get(object, "id"); 2248 const char *id = xs_dict_get(object, "id");
2123 2249
2124 if (xs_is_null(id)) 2250 if (xs_is_null(id))
2125 snac_log(snac, xs_fmt("malformed message: no 'id' field")); 2251 snac_log(snac, xs_fmt("malformed message: no 'id' field"));
2126 else 2252 else
2127 if (timeline_add(snac, id, object)) 2253 if (timeline_add(snac, id, object))
2128 snac_log(snac, xs_fmt("new 'Video' %s %s", actor, id)); 2254 snac_log(snac, xs_fmt("new '%s' %s %s", utype, actor, id));
2129 } 2255 }
2130 else 2256 else
2131 snac_debug(snac, 1, xs_fmt("ignored 'Create' for object type '%s'", utype)); 2257 snac_debug(snac, 1, xs_fmt("ignored 'Create' for object type '%s'", utype));
@@ -2203,15 +2329,23 @@ int process_input_message(snac *snac, const xs_dict *msg, const xs_dict *req)
2203 xs *who_o = NULL; 2329 xs *who_o = NULL;
2204 2330
2205 if (valid_status(actor_request(snac, who, &who_o))) { 2331 if (valid_status(actor_request(snac, who, &who_o))) {
2206 if (timeline_admire(snac, object, actor, 0) == HTTP_STATUS_CREATED) 2332 /* don't account as such announces by our own relay */
2207 snac_log(snac, xs_fmt("new 'Announce' %s %s", actor, object)); 2333 xs *this_relay = xs_fmt("%s/relay", srv_baseurl);
2208 else 2334
2209 snac_log(snac, xs_fmt("repeated 'Announce' from %s to %s", 2335 if (strcmp(actor, this_relay) != 0) {
2210 actor, object)); 2336 if (timeline_admire(snac, object, actor, 0) == HTTP_STATUS_CREATED)
2337 snac_log(snac, xs_fmt("new 'Announce' %s %s", actor, object));
2338 else
2339 snac_log(snac, xs_fmt("repeated 'Announce' from %s to %s",
2340 actor, object));
2341 }
2211 2342
2212 /* distribute the post with the actor as 'proxy' */ 2343 /* distribute the post with the actor as 'proxy' */
2213 list_distribute(snac, actor, a_msg); 2344 list_distribute(snac, actor, a_msg);
2214 2345
2346 /* distribute the post to users following these hashtags */
2347 followed_hashtag_distribute(a_msg);
2348
2215 do_notify = 1; 2349 do_notify = 1;
2216 } 2350 }
2217 else 2351 else
@@ -2226,14 +2360,14 @@ int process_input_message(snac *snac, const xs_dict *msg, const xs_dict *req)
2226 } 2360 }
2227 else 2361 else
2228 if (strcmp(type, "Update") == 0) { /** **/ 2362 if (strcmp(type, "Update") == 0) { /** **/
2229 if (xs_match(utype, "Person|Service")) { /** **/ 2363 if (xs_match(utype, "Person|Service|Application")) { /** **/
2230 actor_add(actor, xs_dict_get(msg, "object")); 2364 actor_add(actor, xs_dict_get(msg, "object"));
2231 timeline_touch(snac); 2365 timeline_touch(snac);
2232 2366
2233 snac_log(snac, xs_fmt("updated actor %s", actor)); 2367 snac_log(snac, xs_fmt("updated actor %s", actor));
2234 } 2368 }
2235 else 2369 else
2236 if (xs_match(utype, "Note|Page|Article|Video")) { /** **/ 2370 if (xs_match(utype, "Note|Page|Article|Video|Audio|Event")) { /** **/
2237 const char *id = xs_dict_get(object, "id"); 2371 const char *id = xs_dict_get(object, "id");
2238 2372
2239 if (xs_is_null(id)) 2373 if (xs_is_null(id))
@@ -2419,7 +2553,7 @@ int send_email(const char *msg)
2419} 2553}
2420 2554
2421 2555
2422void process_user_queue_item(snac *snac, xs_dict *q_item) 2556void process_user_queue_item(snac *user, xs_dict *q_item)
2423/* processes an item from the user queue */ 2557/* processes an item from the user queue */
2424{ 2558{
2425 const char *type; 2559 const char *type;
@@ -2430,7 +2564,7 @@ void process_user_queue_item(snac *snac, xs_dict *q_item)
2430 2564
2431 if (strcmp(type, "message") == 0) { 2565 if (strcmp(type, "message") == 0) {
2432 const xs_dict *msg = xs_dict_get(q_item, "message"); 2566 const xs_dict *msg = xs_dict_get(q_item, "message");
2433 xs *rcpts = recipient_list(snac, msg, 1); 2567 xs *rcpts = recipient_list(user, msg, 1);
2434 xs_set inboxes; 2568 xs_set inboxes;
2435 const xs_str *actor; 2569 const xs_str *actor;
2436 2570
@@ -2439,7 +2573,7 @@ void process_user_queue_item(snac *snac, xs_dict *q_item)
2439 /* add this shared inbox first */ 2573 /* add this shared inbox first */
2440 xs *this_shared_inbox = xs_fmt("%s/shared-inbox", srv_baseurl); 2574 xs *this_shared_inbox = xs_fmt("%s/shared-inbox", srv_baseurl);
2441 xs_set_add(&inboxes, this_shared_inbox); 2575 xs_set_add(&inboxes, this_shared_inbox);
2442 enqueue_output(snac, msg, this_shared_inbox, 0, 0); 2576 enqueue_output(user, msg, this_shared_inbox, 0, 0);
2443 2577
2444 /* iterate the recipients */ 2578 /* iterate the recipients */
2445 xs_list_foreach(rcpts, actor) { 2579 xs_list_foreach(rcpts, actor) {
@@ -2450,10 +2584,10 @@ void process_user_queue_item(snac *snac, xs_dict *q_item)
2450 if (inbox != NULL) { 2584 if (inbox != NULL) {
2451 /* add to the set and, if it's not there, send message */ 2585 /* add to the set and, if it's not there, send message */
2452 if (xs_set_add(&inboxes, inbox) == 1) 2586 if (xs_set_add(&inboxes, inbox) == 1)
2453 enqueue_output(snac, msg, inbox, 0, 0); 2587 enqueue_output(user, msg, inbox, 0, 0);
2454 } 2588 }
2455 else 2589 else
2456 snac_log(snac, xs_fmt("cannot find inbox for %s", actor)); 2590 snac_log(user, xs_fmt("cannot find inbox for %s", actor));
2457 } 2591 }
2458 } 2592 }
2459 2593
@@ -2465,12 +2599,36 @@ void process_user_queue_item(snac *snac, xs_dict *q_item)
2465 2599
2466 xs_list_foreach(shibx, inbox) { 2600 xs_list_foreach(shibx, inbox) {
2467 if (xs_set_add(&inboxes, inbox) == 1) 2601 if (xs_set_add(&inboxes, inbox) == 1)
2468 enqueue_output(snac, msg, inbox, 0, 0); 2602 enqueue_output(user, msg, inbox, 0, 0);
2469 } 2603 }
2470 } 2604 }
2471 } 2605 }
2472 2606
2473 xs_set_free(&inboxes); 2607 xs_set_free(&inboxes);
2608
2609 /* relay this note */
2610 if (is_msg_public(msg) && strcmp(user->uid, "relay") != 0) { /* avoid loops */
2611 snac relay;
2612 if (user_open(&relay, "relay")) {
2613 /* a 'relay' user exists */
2614 const char *type = xs_dict_get(msg, "type");
2615
2616 if (xs_is_string(type) && strcmp(type, "Create") == 0) {
2617 const xs_val *object = xs_dict_get(msg, "object");
2618
2619 if (xs_is_dict(object)) {
2620 object = xs_dict_get(object, "id");
2621
2622 snac_debug(&relay, 1, xs_fmt("relaying message %s", object));
2623
2624 xs *boost = msg_admiration(&relay, object, "Announce");
2625 enqueue_message(&relay, boost);
2626 }
2627 }
2628
2629 user_free(&relay);
2630 }
2631 }
2474 } 2632 }
2475 else 2633 else
2476 if (strcmp(type, "input") == 0) { 2634 if (strcmp(type, "input") == 0) {
@@ -2482,13 +2640,13 @@ void process_user_queue_item(snac *snac, xs_dict *q_item)
2482 if (xs_is_null(msg)) 2640 if (xs_is_null(msg))
2483 return; 2641 return;
2484 2642
2485 if (!process_input_message(snac, msg, req)) { 2643 if (!process_input_message(user, msg, req)) {
2486 if (retries > queue_retry_max) 2644 if (retries > queue_retry_max)
2487 snac_log(snac, xs_fmt("input giving up")); 2645 snac_log(user, xs_fmt("input giving up"));
2488 else { 2646 else {
2489 /* reenqueue */ 2647 /* reenqueue */
2490 enqueue_input(snac, msg, req, retries + 1); 2648 enqueue_input(user, msg, req, retries + 1);
2491 snac_log(snac, xs_fmt("input requeue #%d", retries + 1)); 2649 snac_log(user, xs_fmt("input requeue #%d", retries + 1));
2492 } 2650 }
2493 } 2651 }
2494 } 2652 }
@@ -2498,7 +2656,7 @@ void process_user_queue_item(snac *snac, xs_dict *q_item)
2498 const char *id = xs_dict_get(q_item, "message"); 2656 const char *id = xs_dict_get(q_item, "message");
2499 2657
2500 if (!xs_is_null(id)) 2658 if (!xs_is_null(id))
2501 update_question(snac, id); 2659 update_question(user, id);
2502 } 2660 }
2503 else 2661 else
2504 if (strcmp(type, "object_request") == 0) { 2662 if (strcmp(type, "object_request") == 0) {
@@ -2508,17 +2666,17 @@ void process_user_queue_item(snac *snac, xs_dict *q_item)
2508 int status; 2666 int status;
2509 xs *data = NULL; 2667 xs *data = NULL;
2510 2668
2511 status = activitypub_request(snac, id, &data); 2669 status = activitypub_request(user, id, &data);
2512 2670
2513 if (valid_status(status)) 2671 if (valid_status(status))
2514 object_add_ow(id, data); 2672 object_add_ow(id, data);
2515 2673
2516 snac_debug(snac, 1, xs_fmt("object_request %s %d", id, status)); 2674 snac_debug(user, 1, xs_fmt("object_request %s %d", id, status));
2517 } 2675 }
2518 } 2676 }
2519 else 2677 else
2520 if (strcmp(type, "verify_links") == 0) { 2678 if (strcmp(type, "verify_links") == 0) {
2521 verify_links(snac); 2679 verify_links(user);
2522 } 2680 }
2523 else 2681 else
2524 if (strcmp(type, "actor_refresh") == 0) { 2682 if (strcmp(type, "actor_refresh") == 0) {
@@ -2530,16 +2688,16 @@ void process_user_queue_item(snac *snac, xs_dict *q_item)
2530 xs *actor_o = NULL; 2688 xs *actor_o = NULL;
2531 int status; 2689 int status;
2532 2690
2533 if (valid_status((status = activitypub_request(snac, actor, &actor_o)))) 2691 if (valid_status((status = activitypub_request(user, actor, &actor_o))))
2534 actor_add(actor, actor_o); 2692 actor_add(actor, actor_o);
2535 else 2693 else
2536 object_touch(actor); 2694 object_touch(actor);
2537 2695
2538 snac_log(snac, xs_fmt("actor_refresh %s %d", actor, status)); 2696 snac_log(user, xs_fmt("actor_refresh %s %d", actor, status));
2539 } 2697 }
2540 } 2698 }
2541 else 2699 else
2542 snac_log(snac, xs_fmt("unexpected user q_item type '%s'", type)); 2700 snac_log(user, xs_fmt("unexpected user q_item type '%s'", type));
2543} 2701}
2544 2702
2545 2703
@@ -2640,7 +2798,7 @@ void process_queue_item(xs_dict *q_item)
2640 || status == HTTP_STATUS_UNPROCESSABLE_CONTENT 2798 || status == HTTP_STATUS_UNPROCESSABLE_CONTENT
2641 || status < 0) 2799 || status < 0)
2642 /* explicit error: discard */ 2800 /* explicit error: discard */
2643 srv_log(xs_fmt("output message: fatal error %s %d", inbox, status)); 2801 srv_log(xs_fmt("output message: error %s %d", inbox, status));
2644 else 2802 else
2645 if (retries > queue_retry_max) 2803 if (retries > queue_retry_max)
2646 srv_log(xs_fmt("output message: giving up %s %d", inbox, status)); 2804 srv_log(xs_fmt("output message: giving up %s %d", inbox, status));
@@ -2769,11 +2927,12 @@ void process_queue_item(xs_dict *q_item)
2769 snac user; 2927 snac user;
2770 2928
2771 if (user_open(&user, v)) { 2929 if (user_open(&user, v)) {
2772 if (is_msg_for_me(&user, msg)) { 2930 int rsn = is_msg_for_me(&user, msg);
2931 if (rsn) {
2773 xs *fn = xs_fmt("%s/queue/%s.json", user.basedir, ntid); 2932 xs *fn = xs_fmt("%s/queue/%s.json", user.basedir, ntid);
2774 2933
2775 snac_debug(&user, 1, 2934 snac_debug(&user, 1,
2776 xs_fmt("enqueue_input (from shared inbox) %s", xs_dict_get(msg, "id"))); 2935 xs_fmt("enqueue_input (from shared inbox) %s [%d]", xs_dict_get(msg, "id"), rsn));
2777 2936
2778 if (link(tmpfn, fn) < 0) 2937 if (link(tmpfn, fn) < 0)
2779 srv_log(xs_fmt("link(%s, %s) error", tmpfn, fn)); 2938 srv_log(xs_fmt("link(%s, %s) error", tmpfn, fn));
diff --git a/data.c b/data.c
index 0fd3528..40382d2 100644
--- a/data.c
+++ b/data.c
@@ -1,5 +1,5 @@
1/* snac - A simple, minimalistic ActivityPub instance */ 1/* snac - A simple, minimalistic ActivityPub instance */
2/* copyright (c) 2022 - 2024 grunfink et al. / MIT license */ 2/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */
3 3
4#include "xs.h" 4#include "xs.h"
5#include "xs_hex.h" 5#include "xs_hex.h"
@@ -319,7 +319,8 @@ int user_persist(snac *snac, int publish)
319 319
320 if (old != NULL) { 320 if (old != NULL) {
321 int nw = 0; 321 int nw = 0;
322 const char *fields[] = { "header", "avatar", "name", "bio", "metadata", NULL }; 322 const char *fields[] = { "header", "avatar", "name", "bio",
323 "metadata", "latitude", "longitude", NULL };
323 324
324 for (int n = 0; fields[n]; n++) { 325 for (int n = 0; fields[n]; n++) {
325 const char *of = xs_dict_get(old, fields[n]); 326 const char *of = xs_dict_get(old, fields[n]);
@@ -336,6 +337,10 @@ int user_persist(snac *snac, int publish)
336 337
337 if (!nw) 338 if (!nw)
338 publish = 0; 339 publish = 0;
340 else {
341 /* uncache the actor object */
342 object_del(snac->actor);
343 }
339 } 344 }
340 } 345 }
341 } 346 }
@@ -674,6 +679,37 @@ int index_desc_first(FILE *f, char md5[MD5_HEX_SIZE], int skip)
674 return 1; 679 return 1;
675} 680}
676 681
682int index_asc_first(FILE *f,char md5[MD5_HEX_SIZE], const char *seek_md5)
683/* reads the first entry of an ascending index, starting from a given md5 */
684{
685 fseek(f, SEEK_SET, 0);
686 while (fread(md5, MD5_HEX_SIZE, 1, f)) {
687 md5[MD5_HEX_SIZE - 1] = '\0';
688 if (strcmp(md5,seek_md5) == 0) {
689 return index_asc_next(f, md5);
690 }
691 }
692 return 0;
693}
694
695int index_asc_next(FILE *f, char md5[MD5_HEX_SIZE])
696/* reads the next entry of an ascending index */
697{
698 for (;;) {
699 /* read an md5 */
700 if (!fread(md5, MD5_HEX_SIZE, 1, f))
701 return 0;
702
703 /* deleted, skip */
704 if (md5[0] != '-')
705 break;
706 }
707
708 md5[MD5_HEX_SIZE - 1] = '\0';
709
710 return 1;
711}
712
677 713
678xs_list *index_list_desc(const char *fn, int skip, int show) 714xs_list *index_list_desc(const char *fn, int skip, int show)
679/* returns an index as a list, in reverse order */ 715/* returns an index as a list, in reverse order */
@@ -1363,11 +1399,13 @@ void timeline_update_indexes(snac *snac, const char *id)
1363 if (valid_status(object_get(id, &msg))) { 1399 if (valid_status(object_get(id, &msg))) {
1364 /* if its ours and is public, also store in public */ 1400 /* if its ours and is public, also store in public */
1365 if (is_msg_public(msg)) { 1401 if (is_msg_public(msg)) {
1366 object_user_cache_add(snac, id, "public"); 1402 if (object_user_cache_add(snac, id, "public") >= 0) {
1367 1403 /* also add it to the instance public timeline */
1368 /* also add it to the instance public timeline */ 1404 xs *ipt = xs_fmt("%s/public.idx", srv_basedir);
1369 xs *ipt = xs_fmt("%s/public.idx", srv_basedir); 1405 index_add(ipt, id);
1370 index_add(ipt, id); 1406 }
1407 else
1408 srv_debug(1, xs_fmt("Not added to public instance index %s", id));
1371 } 1409 }
1372 } 1410 }
1373 } 1411 }
@@ -1488,8 +1526,17 @@ xs_list *timeline_instance_list(int skip, int show)
1488/* returns the timeline for the full instance */ 1526/* returns the timeline for the full instance */
1489{ 1527{
1490 xs *idx = instance_index_fn(); 1528 xs *idx = instance_index_fn();
1529 xs *lst = index_list_desc(idx, skip, show);
1491 1530
1492 return index_list_desc(idx, skip, show); 1531 /* make the list unique */
1532 xs_set rep;
1533 xs_set_init(&rep);
1534 const char *md5;
1535
1536 xs_list_foreach(lst, md5)
1537 xs_set_add(&rep, md5);
1538
1539 return xs_set_result(&rep);
1493} 1540}
1494 1541
1495 1542
diff --git a/doc/snac.1 b/doc/snac.1
index 71dcf4a..d33bccb 100644
--- a/doc/snac.1
+++ b/doc/snac.1
@@ -256,7 +256,7 @@ it's - (a lonely hyphen), the post content will be read from stdin.
256The rest of command line arguments are treated as media files to be 256The rest of command line arguments are treated as media files to be
257attached to the post. 257attached to the post.
258.It Cm note_unlisted Ar basedir Ar uid Ar text Op file file ... 258.It Cm note_unlisted Ar basedir Ar uid Ar text Op file file ...
259Like the previous one, but creates an "unlisted" (or "quiet public") one. 259Like the previous one, but creates an "unlisted" (or "quiet public") post.
260.It Cm block Ar basedir Ar instance_url 260.It Cm block Ar basedir Ar instance_url
261Blocks a full instance, given its URL or domain name. All subsequent 261Blocks a full instance, given its URL or domain name. All subsequent
262incoming activities with identifiers from that instance will be immediately 262incoming activities with identifiers from that instance will be immediately
@@ -377,6 +377,13 @@ https://$SNAC_HOST/oauth/x-snac-get-token
377.Pp 377.Pp
378.Sh ENVIRONMENT 378.Sh ENVIRONMENT
379.Bl -tag -width Ds 379.Bl -tag -width Ds
380.It SNAC_BASEDIR
381This optional environment variable can be set to the base directory of
382your installation; if set, you don't have to add the base directory as an
383argument to command-line operations. This may prove useful if you only
384have one
385.Nm
386instance in you system (which is probably your case).
380.It Ev DEBUG 387.It Ev DEBUG
381Overrides the debugging level from the server 'dbglevel' configuration 388Overrides the debugging level from the server 'dbglevel' configuration
382variable. Set it to an integer value. The higher, the deeper in meaningless 389variable. Set it to an integer value. The higher, the deeper in meaningless
diff --git a/doc/snac.5 b/doc/snac.5
index be7bfd7..3550995 100644
--- a/doc/snac.5
+++ b/doc/snac.5
@@ -24,9 +24,11 @@ A special subset of Markdown is allowed, including:
24.It bold 24.It bold
25**text between two pairs of asterisks** 25**text between two pairs of asterisks**
26.It italic 26.It italic
27*text between a pair of asterisks* 27*text between a pair of asterisks* or _between a pair of underscores_
28.It strikethrough text 28.It strikethrough text
29~~text between a pair of tildes~~ 29~~text between a pair of tildes~~
30.It underlined text
31__text between two pairs of underscores__
30.It code 32.It code
31Text `between backticks` is formatted as code. 33Text `between backticks` is formatted as code.
32.Bd -literal 34.Bd -literal
@@ -53,6 +55,9 @@ Horizonal rules can be inserted by typing three minus symbols
53alone in a line. 55alone in a line.
54.It quoted text 56.It quoted text
55Lines starting with >. 57Lines starting with >.
58.It headers
59One, two or three # at the beginning of a line plus a space plus
60some text are converted to HTML headers.
56.It user mentions 61.It user mentions
57Strings in the format @user@host are requested using the Webfinger 62Strings in the format @user@host are requested using the Webfinger
58protocol and converted to links and mentions if something reasonable 63protocol and converted to links and mentions if something reasonable
diff --git a/doc/snac.8 b/doc/snac.8
index c44a891..e9b33dd 100644
--- a/doc/snac.8
+++ b/doc/snac.8
@@ -585,6 +585,31 @@ to pass the remote connection address in the
585.Ic X-Forwarded-For 585.Ic X-Forwarded-For
586HTTP header (unless you use the FastCGI interface; if that's the case, you don't have 586HTTP header (unless you use the FastCGI interface; if that's the case, you don't have
587to do anything). 587to do anything).
588.Pp
589.Ss Subscribing to Fediverse Relays
590Since version 2.69, a
591.Nm
592instance can subscribe to LitePub (Pleroma-style) Fediverse Relays. Doing this improves
593visibility and allows following hashtags. To do this, you must create a special user named
594relay and, from it, follow the relay actor(s) like you do with regular actor URLs. This
595special user will start receiving boosts from the relay server of posts from other instances
596also following it. If any other user of the same
597.Nm
598instance follows any of the hashtags included in these boosted posts coming from the relay,
599they will be received as if they were for them.
600.Pp
601Example:
602.Bd -literal -offset indent
603snac adduser $SNAC_BASEDIR relay # only needed once
604snac follow $SNAC_BASEDIR relay https://relay.example.com/actor
605.Ed
606.Pp
607Users on your instance do NOT need to follow the local relay user to benefit from following
608hashtags.
609.Pp
610Please take note that subscribing to relays can increase the traffic towards your instance
611significantly. In any case, lowering the "Maximum days to keep posts" value for the relay
612special user is recommended (e.g. setting to just 1 day).
588.Sh ENVIRONMENT 613.Sh ENVIRONMENT
589.Bl -tag -width Ds 614.Bl -tag -width Ds
590.It Ev DEBUG 615.It Ev DEBUG
@@ -685,6 +710,12 @@ location /share {
685 proxy_set_header Host $http_host; 710 proxy_set_header Host $http_host;
686 proxy_set_header X-Forwarded-For $remote_addr; 711 proxy_set_header X-Forwarded-For $remote_addr;
687} 712}
713# optional (Mastodon-like "authorize interaction" entrypoint)
714location /authorize_interaction {
715 proxy_pass http://localhost:8001;
716 proxy_set_header Host $http_host;
717 proxy_set_header X-Forwarded-For $remote_addr;
718}
688.Ed 719.Ed
689.Pp 720.Pp
690Restart the nginx daemon and connect to 721Restart the nginx daemon and connect to
@@ -738,6 +769,11 @@ ProxyPreserveHost On
738<Location /share> 769<Location /share>
739 ProxyPass http://127.0.0.1:8001/share 770 ProxyPass http://127.0.0.1:8001/share
740</Location> 771</Location>
772
773# optional (Mastodon-like "authorize interaction" entrypoint)
774<Location /authorize_interaction>
775 ProxyPass http://127.0.0.1:8001/share
776</Location>
741.Ed 777.Ed
742.Pp 778.Pp
743Since version 2.43, 779Since version 2.43,
@@ -797,6 +833,10 @@ location "/.well-known/host-meta" {
797location "/share" { 833location "/share" {
798 fastcgi socket tcp "127.0.0.1" 8001 834 fastcgi socket tcp "127.0.0.1" 8001
799} 835}
836
837location "/authorize_interaction" {
838 fastcgi socket tcp "127.0.0.1" 8001
839}
800.Ed 840.Ed
801.Sh SEE ALSO 841.Sh SEE ALSO
802.Xr snac 1 , 842.Xr snac 1 ,
diff --git a/format.c b/format.c
index 41e4162..e5934b8 100644
--- a/format.c
+++ b/format.c
@@ -1,5 +1,5 @@
1/* snac - A simple, minimalistic ActivityPub instance */ 1/* snac - A simple, minimalistic ActivityPub instance */
2/* copyright (c) 2022 - 2024 grunfink et al. / MIT license */ 2/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */
3 3
4#include "xs.h" 4#include "xs.h"
5#include "xs_regex.h" 5#include "xs_regex.h"
@@ -92,6 +92,8 @@ static xs_str *format_line(const char *line, xs_list **attach)
92 "`[^`]+`" "|" 92 "`[^`]+`" "|"
93 "~~[^~]+~~" "|" 93 "~~[^~]+~~" "|"
94 "\\*\\*?\\*?[^\\*]+\\*?\\*?\\*" "|" 94 "\\*\\*?\\*?[^\\*]+\\*?\\*?\\*" "|"
95 "_[^_]+_" "|" //anzu
96 "__[^_]+__" "|" //anzu
95 "!\\[[^]]+\\]\\([^\\)]+\\)" "|" 97 "!\\[[^]]+\\]\\([^\\)]+\\)" "|"
96 "\\[[^]]+\\]\\([^\\)]+\\)" "|" 98 "\\[[^]]+\\]\\([^\\)]+\\)" "|"
97 "[a-z]+:/" "/[^[:space:]]+" "|" 99 "[a-z]+:/" "/[^[:space:]]+" "|"
@@ -127,6 +129,20 @@ static xs_str *format_line(const char *line, xs_list **attach)
127 xs *s2 = xs_fmt("<i>%s</i>", s1); 129 xs *s2 = xs_fmt("<i>%s</i>", s1);
128 s = xs_str_cat(s, s2); 130 s = xs_str_cat(s, s2);
129 } 131 }
132 //anzu - begin
133 else
134 if (xs_startswith(v, "__")) {
135 xs *s1 = xs_strip_chars_i(xs_dup(v), "_");
136 xs *s2 = xs_fmt("<u>%s</u>", s1);
137 s = xs_str_cat(s, s2);
138 }
139 else
140 if (xs_startswith(v, "_")) {
141 xs *s1 = xs_strip_chars_i(xs_dup(v), "_");
142 xs *s2 = xs_fmt("<i>%s</i>", s1);
143 s = xs_str_cat(s, s2);
144 }
145 //anzu - end
130 else 146 else
131 if (xs_startswith(v, "~~")) { 147 if (xs_startswith(v, "~~")) {
132 xs *s1 = xs_strip_chars_i(xs_dup(v), "~"); 148 xs *s1 = xs_strip_chars_i(xs_dup(v), "~");
@@ -303,6 +319,31 @@ xs_str *not_really_markdown(const char *content, xs_list **attach, xs_list **tag
303 continue; 319 continue;
304 } 320 }
305 321
322 //anzu - begin
323 // h1 reserved for snac?
324 if (xs_startswith(ss, "# ")) {
325 ss = xs_strip_i(xs_crop_i(ss, 2, 0));
326 s = xs_str_cat(s, "<h2>");
327 s = xs_str_cat(s, ss);
328 s = xs_str_cat(s, "</h2>");
329 continue;
330 }
331 if (xs_startswith(ss, "## ")) {
332 ss = xs_strip_i(xs_crop_i(ss, 3, 0));
333 s = xs_str_cat(s, "<h2>");
334 s = xs_str_cat(s, ss);
335 s = xs_str_cat(s, "</h2>");
336 continue;
337 }
338 if (xs_startswith(ss, "### ")) {
339 ss = xs_strip_i(xs_crop_i(ss, 4, 0));
340 s = xs_str_cat(s, "<h3>");
341 s = xs_str_cat(s, ss);
342 s = xs_str_cat(s, "</h3>");
343 continue;
344 }
345 //anzu - end
346
306 if (xs_startswith(ss, ">")) { 347 if (xs_startswith(ss, ">")) {
307 /* delete the > and subsequent spaces */ 348 /* delete the > and subsequent spaces */
308 ss = xs_strip_i(xs_crop_i(ss, 1, 0)); 349 ss = xs_strip_i(xs_crop_i(ss, 1, 0));
@@ -336,6 +377,8 @@ xs_str *not_really_markdown(const char *content, xs_list **attach, xs_list **tag
336 s = xs_replace_i(s, "<br><br><blockquote>", "<br><blockquote>"); 377 s = xs_replace_i(s, "<br><br><blockquote>", "<br><blockquote>");
337 s = xs_replace_i(s, "</blockquote><br>", "</blockquote>"); 378 s = xs_replace_i(s, "</blockquote><br>", "</blockquote>");
338 s = xs_replace_i(s, "</pre><br>", "</pre>"); 379 s = xs_replace_i(s, "</pre><br>", "</pre>");
380 s = xs_replace_i(s, "</h2><br>", "</h2>"); //anzu ???
381 s = xs_replace_i(s, "</h3><br>", "</h3>"); //anzu ???
339 382
340 { 383 {
341 /* traditional emoticons */ 384 /* traditional emoticons */
@@ -378,7 +421,9 @@ xs_str *not_really_markdown(const char *content, xs_list **attach, xs_list **tag
378 421
379const char *valid_tags[] = { 422const char *valid_tags[] = {
380 "a", "p", "br", "br/", "blockquote", "ul", "ol", "li", "cite", "small", 423 "a", "p", "br", "br/", "blockquote", "ul", "ol", "li", "cite", "small",
381 "span", "i", "b", "u", "s", "pre", "code", "em", "strong", "hr", "img", "del", "bdi", NULL 424 "span", "i", "b", "u", "s", "pre", "code", "em", "strong", "hr", "img", "del", "bdi",
425 "h2","h3", //anzu
426 NULL
382}; 427};
383 428
384xs_str *sanitize(const char *content) 429xs_str *sanitize(const char *content)
diff --git a/html.c b/html.c
index b54556d..6c42842 100644
--- a/html.c
+++ b/html.c
@@ -1,5 +1,5 @@
1/* snac - A simple, minimalistic ActivityPub instance */ 1/* snac - A simple, minimalistic ActivityPub instance */
2/* copyright (c) 2022 - 2024 grunfink et al. / MIT license */ 2/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */
3 3
4#include "xs.h" 4#include "xs.h"
5#include "xs_io.h" 5#include "xs_io.h"
@@ -12,6 +12,7 @@
12#include "xs_match.h" 12#include "xs_match.h"
13#include "xs_html.h" 13#include "xs_html.h"
14#include "xs_curl.h" 14#include "xs_curl.h"
15#include "xs_unicode.h"
15 16
16#include "snac.h" 17#include "snac.h"
17 18
@@ -113,7 +114,8 @@ xs_str *actor_name(xs_dict *actor, const char *proxy)
113 114
114 115
115xs_html *html_actor_icon(snac *user, xs_dict *actor, const char *date, 116xs_html *html_actor_icon(snac *user, xs_dict *actor, const char *date,
116 const char *udate, const char *url, int priv, int in_people, const char *proxy) 117 const char *udate, const char *url, int priv,
118 int in_people, const char *proxy, const char *lang)
117{ 119{
118 xs_html *actor_icon = xs_html_tag("p", NULL); 120 xs_html *actor_icon = xs_html_tag("p", NULL);
119 121
@@ -219,6 +221,9 @@ xs_html *html_actor_icon(snac *user, xs_dict *actor, const char *date,
219 date_title = xs_str_cat(date_title, " / ", udate); 221 date_title = xs_str_cat(date_title, " / ", udate);
220 } 222 }
221 223
224 if (xs_is_string(lang))
225 date_title = xs_str_cat(date_title, " (", lang, ")");
226
222 xs_html_add(actor_icon, 227 xs_html_add(actor_icon,
223 xs_html_text(" "), 228 xs_html_text(" "),
224 xs_html_tag("time", 229 xs_html_tag("time",
@@ -265,6 +270,7 @@ xs_html *html_msg_icon(snac *user, const char *actor_id, const xs_dict *msg, con
265 const char *date = NULL; 270 const char *date = NULL;
266 const char *udate = NULL; 271 const char *udate = NULL;
267 const char *url = NULL; 272 const char *url = NULL;
273 const char *lang = NULL;
268 int priv = 0; 274 int priv = 0;
269 const char *type = xs_dict_get(msg, "type"); 275 const char *type = xs_dict_get(msg, "type");
270 276
@@ -276,7 +282,17 @@ xs_html *html_msg_icon(snac *user, const char *actor_id, const xs_dict *msg, con
276 date = xs_dict_get(msg, "published"); 282 date = xs_dict_get(msg, "published");
277 udate = xs_dict_get(msg, "updated"); 283 udate = xs_dict_get(msg, "updated");
278 284
279 actor_icon = html_actor_icon(user, actor, date, udate, url, priv, 0, proxy); 285 lang = xs_dict_get(msg, "contentMap");
286 if (xs_is_dict(lang)) {
287 const char *v;
288 int c = 0;
289
290 xs_dict_next(lang, &lang, &v, &c);
291 }
292 else
293 lang = NULL;
294
295 actor_icon = html_actor_icon(user, actor, date, udate, url, priv, 0, proxy, lang);
280 } 296 }
281 297
282 return actor_icon; 298 return actor_icon;
@@ -933,6 +949,7 @@ static xs_html *html_user_body(snac *user, int read_only)
933 xs_html_raw("&#10004; "), 949 xs_html_raw("&#10004; "),
934 xs_html_tag("a", 950 xs_html_tag("a",
935 xs_html_attr("rel", "me"), 951 xs_html_attr("rel", "me"),
952 xs_html_attr("target", "_blank"),
936 xs_html_attr("href", v), 953 xs_html_attr("href", v),
937 xs_html_text(v))); 954 xs_html_text(v)));
938 } 955 }
@@ -969,6 +986,23 @@ static xs_html *html_user_body(snac *user, int read_only)
969 snac_metadata); 986 snac_metadata);
970 } 987 }
971 988
989 const char *latitude = xs_dict_get_def(user->config, "latitude", "");
990 const char *longitude = xs_dict_get_def(user->config, "longitude", "");
991
992 if (*latitude && *longitude) {
993 xs *label = xs_fmt(L("%s,%s"), latitude, longitude);
994 xs *url = xs_fmt(L("https://openstreetmap.org/search?query=%s,%s"),
995 latitude, longitude);
996
997 xs_html_add(top_user,
998 xs_html_tag("p",
999 xs_html_text(L("Location: ")),
1000 xs_html_tag("a",
1001 xs_html_attr("href", url),
1002 xs_html_attr("target", "_blank"),
1003 xs_html_text(label))));
1004 }
1005
972 if (xs_is_true(xs_dict_get(user->config, "show_contact_metrics"))) { 1006 if (xs_is_true(xs_dict_get(user->config, "show_contact_metrics"))) {
973 xs *fwers = follower_list(user); 1007 xs *fwers = follower_list(user);
974 xs *fwing = following_list(user); 1008 xs *fwing = following_list(user);
@@ -1110,6 +1144,8 @@ xs_html *html_top_controls(snac *snac)
1110 const xs_val *coll_thrds = xs_dict_get(snac->config, "collapse_threads"); 1144 const xs_val *coll_thrds = xs_dict_get(snac->config, "collapse_threads");
1111 const xs_val *pending = xs_dict_get(snac->config, "approve_followers"); 1145 const xs_val *pending = xs_dict_get(snac->config, "approve_followers");
1112 const xs_val *show_foll = xs_dict_get(snac->config, "show_contact_metrics"); 1146 const xs_val *show_foll = xs_dict_get(snac->config, "show_contact_metrics");
1147 const char *latitude = xs_dict_get_def(snac->config, "latitude", "");
1148 const char *longitude = xs_dict_get_def(snac->config, "longitude", "");
1113 1149
1114 xs *metadata = NULL; 1150 xs *metadata = NULL;
1115 const xs_dict *md = xs_dict_get(snac->config, "metadata"); 1151 const xs_dict *md = xs_dict_get(snac->config, "metadata");
@@ -1300,6 +1336,20 @@ xs_html *html_top_controls(snac *snac)
1300 xs_html_attr("for", "show_contact_metrics"), 1336 xs_html_attr("for", "show_contact_metrics"),
1301 xs_html_text(L("Publish follower and following metrics")))), 1337 xs_html_text(L("Publish follower and following metrics")))),
1302 xs_html_tag("p", 1338 xs_html_tag("p",
1339 xs_html_text(L("Current location:")),
1340 xs_html_sctag("br", NULL),
1341 xs_html_sctag("input",
1342 xs_html_attr("type", "text"),
1343 xs_html_attr("name", "latitude"),
1344 xs_html_attr("value", latitude),
1345 xs_html_attr("placeholder", "latitude")),
1346 xs_html_text(" "),
1347 xs_html_sctag("input",
1348 xs_html_attr("type", "text"),
1349 xs_html_attr("name", "longitude"),
1350 xs_html_attr("value", longitude),
1351 xs_html_attr("placeholder", "longitude"))),
1352 xs_html_tag("p",
1303 xs_html_text(L("Profile metadata (key=value pairs in each line):")), 1353 xs_html_text(L("Profile metadata (key=value pairs in each line):")),
1304 xs_html_sctag("br", NULL), 1354 xs_html_sctag("br", NULL),
1305 xs_html_tag("textarea", 1355 xs_html_tag("textarea",
@@ -1328,7 +1378,41 @@ xs_html *html_top_controls(snac *snac)
1328 xs_html_sctag("input", 1378 xs_html_sctag("input",
1329 xs_html_attr("type", "submit"), 1379 xs_html_attr("type", "submit"),
1330 xs_html_attr("class", "button"), 1380 xs_html_attr("class", "button"),
1331 xs_html_attr("value", L("Update user info"))))))); 1381 xs_html_attr("value", L("Update user info"))),
1382
1383 xs_html_tag("p", NULL)))));
1384
1385 xs *followed_hashtags_action = xs_fmt("%s/admin/followed-hashtags", snac->actor);
1386 xs *followed_hashtags = xs_join(xs_dict_get_def(snac->config,
1387 "followed_hashtags", xs_stock(XSTYPE_LIST)), "\n");
1388
1389 xs_html_add(top_controls,
1390 xs_html_tag("details",
1391 xs_html_tag("summary",
1392 xs_html_text(L("Followed hashtags..."))),
1393 xs_html_tag("p",
1394 xs_html_text(L("One hashtag per line"))),
1395 xs_html_tag("div",
1396 xs_html_attr("class", "snac-followed-hashtags"),
1397 xs_html_tag("form",
1398 xs_html_attr("autocomplete", "off"),
1399 xs_html_attr("method", "post"),
1400 xs_html_attr("action", followed_hashtags_action),
1401 xs_html_attr("enctype", "multipart/form-data"),
1402
1403 xs_html_tag("textarea",
1404 xs_html_attr("name", "followed_hashtags"),
1405 xs_html_attr("cols", "40"),
1406 xs_html_attr("rows", "4"),
1407 xs_html_attr("placeholder", "#cats\n#windowfriday\n#classicalmusic"),
1408 xs_html_text(followed_hashtags)),
1409
1410 xs_html_tag("br", NULL),
1411
1412 xs_html_sctag("input",
1413 xs_html_attr("type", "submit"),
1414 xs_html_attr("class", "button"),
1415 xs_html_attr("value", L("Update hashtags")))))));
1332 1416
1333 return top_controls; 1417 return top_controls;
1334} 1418}
@@ -1781,13 +1865,15 @@ xs_html *html_entry(snac *user, xs_dict *msg, int read_only,
1781 } 1865 }
1782 } 1866 }
1783 } 1867 }
1784 else
1785 if (strcmp(type, "Note") == 0) {
1786 if (level == 0) {
1787 /* is the parent not here? */
1788 const char *parent = get_in_reply_to(msg);
1789 1868
1790 if (user && !xs_is_null(parent) && *parent && !timeline_here(user, parent)) { 1869 if (user && strcmp(type, "Note") == 0) {
1870 /* is the parent not here? */
1871 const char *parent = get_in_reply_to(msg);
1872
1873 if (!xs_is_null(parent) && *parent) {
1874 xs *md5 = xs_md5_hex(parent, strlen(parent));
1875
1876 if (!timeline_here(user, md5)) {
1791 xs_html_add(post_header, 1877 xs_html_add(post_header,
1792 xs_html_tag("div", 1878 xs_html_tag("div",
1793 xs_html_attr("class", "snac-origin"), 1879 xs_html_attr("class", "snac-origin"),
@@ -2199,6 +2285,135 @@ xs_html *html_entry(snac *user, xs_dict *msg, int read_only,
2199 au_tag); 2285 au_tag);
2200 } 2286 }
2201 2287
2288 /* does it have a location? */
2289 const xs_dict *location = xs_dict_get(msg, "location");
2290 if (xs_type(location) == XSTYPE_DICT) {
2291 const xs_number *latitude = xs_dict_get(location, "latitude");
2292 const xs_number *longitude = xs_dict_get(location, "longitude");
2293 const char *name = xs_dict_get(location, "name");
2294 const char *address = xs_dict_get(location, "address");
2295 xs *label_list = xs_list_new();
2296
2297 if (xs_type(name) == XSTYPE_STRING)
2298 label_list = xs_list_append(label_list, name);
2299 if (xs_type(address) == XSTYPE_STRING)
2300 label_list = xs_list_append(label_list, address);
2301
2302 if (xs_list_len(label_list)) {
2303 const char *url = xs_dict_get(location, "url");
2304 xs *label = xs_join(label_list, ", ");
2305
2306 if (xs_type(url) == XSTYPE_STRING) {
2307 xs_html_add(snac_content_wrap,
2308 xs_html_tag("p",
2309 xs_html_text(L("Location: ")),
2310 xs_html_tag("a",
2311 xs_html_attr("href", url),
2312 xs_html_attr("target", "_blank"),
2313 xs_html_text(label))));
2314 }
2315 else
2316 if (!xs_is_null(latitude) && !xs_is_null(longitude)) {
2317 xs *url = xs_fmt("https://openstreetmap.org/search/?query=%s,%s",
2318 xs_number_str(latitude), xs_number_str(longitude));
2319
2320 xs_html_add(snac_content_wrap,
2321 xs_html_tag("p",
2322 xs_html_text(L("Location: ")),
2323 xs_html_tag("a",
2324 xs_html_attr("href", url),
2325 xs_html_attr("target", "_blank"),
2326 xs_html_text(label))));
2327 }
2328 else
2329 xs_html_add(snac_content_wrap,
2330 xs_html_tag("p",
2331 xs_html_text(L("Location: ")),
2332 xs_html_text(label)));
2333 }
2334 }
2335
2336 if (strcmp(type, "Event") == 0) { /** Event start and end times **/
2337 const char *s_time = xs_dict_get(msg, "startTime");
2338
2339 if (xs_is_string(s_time) && strlen(s_time) > 20) {
2340 const char *e_time = xs_dict_get(msg, "endTime");
2341 const char *tz = xs_dict_get(msg, "timezone");
2342
2343 xs *s = xs_replace_i(xs_dup(s_time), "T", " ");
2344 xs *e = NULL;
2345
2346 if (xs_is_string(e_time) && strlen(e_time) > 20)
2347 e = xs_replace_i(xs_dup(e_time), "T", " ");
2348
2349 /* if the event has a timezone, crop the offsets */
2350 if (xs_is_string(tz)) {
2351 s = xs_crop_i(s, 0, 19);
2352
2353 if (e)
2354 e = xs_crop_i(e, 0, 19);
2355 }
2356 else
2357 tz = "";
2358
2359 /* if start and end share the same day, crop it from the end */
2360 if (e && memcmp(s, e, 11) == 0)
2361 e = xs_crop_i(e, 11, 0);
2362
2363 if (e)
2364 s = xs_str_cat(s, " / ", e);
2365
2366 if (*tz)
2367 s = xs_str_cat(s, " (", tz, ")");
2368
2369 /* replace ugly decimals */
2370 s = xs_replace_i(s, ".000", "");
2371
2372 xs_html_add(snac_content_wrap,
2373 xs_html_tag("p",
2374 xs_html_text(L("Time: ")),
2375 xs_html_text(s)));
2376 }
2377 }
2378
2379 /* show all hashtags that has not been shown previously in the content */
2380 const xs_list *tags = xs_dict_get(msg, "tag");
2381 const char *o_content = xs_dict_get_def(msg, "content", "");
2382
2383 if (xs_is_string(o_content) && xs_is_list(tags) && xs_list_len(tags)) {
2384 xs *content = xs_utf8_to_lower(o_content);
2385 const xs_dict *tag;
2386
2387 xs_html *add_hashtags = xs_html_tag("ul",
2388 xs_html_attr("class", "snac-more-hashtags"));
2389
2390 xs_list_foreach(tags, tag) {
2391 const char *type = xs_dict_get(tag, "type");
2392
2393 if (xs_is_string(type) && strcmp(type, "Hashtag") == 0) {
2394 const char *o_href = xs_dict_get(tag, "href");
2395 const char *name = xs_dict_get(tag, "name");
2396
2397 if (xs_is_string(o_href) && xs_is_string(name)) {
2398 xs *href = xs_utf8_to_lower(o_href);
2399
2400 if (xs_str_in(content, href) == -1 && xs_str_in(content, name) == -1) {
2401 /* not in the content: add here */
2402 xs_html_add(add_hashtags,
2403 xs_html_tag("li",
2404 xs_html_tag("a",
2405 xs_html_attr("href", href),
2406 xs_html_text(name),
2407 xs_html_text(" "))));
2408 }
2409 }
2410 }
2411 }
2412
2413 xs_html_add(snac_content_wrap,
2414 add_hashtags);
2415 }
2416
2202 /** controls **/ 2417 /** controls **/
2203 2418
2204 if (!read_only && user) { 2419 if (!read_only && user) {
@@ -2583,7 +2798,7 @@ xs_html *html_people_list(snac *snac, xs_list *list, char *header, char *t, cons
2583 xs_html_tag("div", 2798 xs_html_tag("div",
2584 xs_html_attr("class", "snac-post-header"), 2799 xs_html_attr("class", "snac-post-header"),
2585 html_actor_icon(snac, actor, xs_dict_get(actor, "published"), 2800 html_actor_icon(snac, actor, xs_dict_get(actor, "published"),
2586 NULL, NULL, 0, 1, proxy))); 2801 NULL, NULL, 0, 1, proxy, NULL)));
2587 2802
2588 /* content (user bio) */ 2803 /* content (user bio) */
2589 const char *c = xs_dict_get(actor, "summary"); 2804 const char *c = xs_dict_get(actor, "summary");
@@ -2762,9 +2977,15 @@ xs_str *html_notifications(snac *user, int skip, int show)
2762 xs_html_attr("class", "snac-posts")); 2977 xs_html_attr("class", "snac-posts"));
2763 xs_html_add(body, posts); 2978 xs_html_add(body, posts);
2764 2979
2765 xs_list *p = n_list; 2980 xs_set rep;
2981 xs_set_init(&rep);
2982
2983 /* dict to store previous notification labels */
2984 xs *admiration_labels = xs_dict_new();
2985
2766 const xs_str *v; 2986 const xs_str *v;
2767 while (xs_list_iter(&p, &v)) { 2987
2988 xs_list_foreach(n_list, v) {
2768 xs *noti = notify_get(user, v); 2989 xs *noti = notify_get(user, v);
2769 2990
2770 if (noti == NULL) 2991 if (noti == NULL)
@@ -2775,6 +2996,7 @@ xs_str *html_notifications(snac *user, int skip, int show)
2775 const char *utype = xs_dict_get(noti, "utype"); 2996 const char *utype = xs_dict_get(noti, "utype");
2776 const char *id = xs_dict_get(noti, "objid"); 2997 const char *id = xs_dict_get(noti, "objid");
2777 const char *date = xs_dict_get(noti, "date"); 2998 const char *date = xs_dict_get(noti, "date");
2999 const char *id2 = xs_dict_get_path(noti, "msg.id");
2778 xs *wrk = NULL; 3000 xs *wrk = NULL;
2779 3001
2780 if (xs_is_null(id)) 3002 if (xs_is_null(id))
@@ -2783,8 +3005,16 @@ xs_str *html_notifications(snac *user, int skip, int show)
2783 if (is_hidden(user, id)) 3005 if (is_hidden(user, id))
2784 continue; 3006 continue;
2785 3007
3008 if (xs_is_string(id2) && xs_set_add(&rep, id2) != 1)
3009 continue;
3010
2786 object_get(id, &obj); 3011 object_get(id, &obj);
2787 3012
3013 const char *msg_id = NULL;
3014
3015 if (xs_is_dict(obj))
3016 msg_id = xs_dict_get(obj, "id");
3017
2788 const char *actor_id = xs_dict_get(noti, "actor"); 3018 const char *actor_id = xs_dict_get(noti, "actor");
2789 xs *actor = NULL; 3019 xs *actor = NULL;
2790 3020
@@ -2817,9 +3047,7 @@ xs_str *html_notifications(snac *user, int skip, int show)
2817 3047
2818 xs *s_date = xs_crop_i(xs_dup(date), 0, 10); 3048 xs *s_date = xs_crop_i(xs_dup(date), 0, 10);
2819 3049
2820 xs_html *entry = xs_html_tag("div", 3050 xs_html *this_html_label = xs_html_container(
2821 xs_html_attr("class", "snac-post-with-desc"),
2822 xs_html_tag("p",
2823 xs_html_tag("b", 3051 xs_html_tag("b",
2824 xs_html_text(label), 3052 xs_html_text(label),
2825 xs_html_text(" by "), 3053 xs_html_text(" by "),
@@ -2830,13 +3058,45 @@ xs_str *html_notifications(snac *user, int skip, int show)
2830 xs_html_tag("time", 3058 xs_html_tag("time",
2831 xs_html_attr("class", "dt-published snac-pubdate"), 3059 xs_html_attr("class", "dt-published snac-pubdate"),
2832 xs_html_attr("title", date), 3060 xs_html_attr("title", date),
2833 xs_html_text(s_date)))); 3061 xs_html_text(s_date)));
3062
3063 xs_html *html_label = NULL;
3064
3065 if (xs_is_string(msg_id)) {
3066 const xs_val *prev_label = xs_dict_get(admiration_labels, msg_id);
3067
3068 if (xs_type(prev_label) == XSTYPE_DATA) {
3069 /* there is a previous list of admiration labels! */
3070 xs_data_get(&html_label, prev_label);
3071
3072 xs_html_add(html_label,
3073 xs_html_sctag("br", NULL),
3074 this_html_label);
3075
3076 continue;
3077 }
3078 }
3079
3080 xs_html *entry = NULL;
3081
3082 html_label = xs_html_tag("p",
3083 this_html_label);
3084
3085 /* store in the admiration labels dict */
3086 xs *pl = xs_data_new(&html_label, sizeof(html_label));
3087
3088 if (xs_is_string(msg_id))
3089 admiration_labels = xs_dict_set(admiration_labels, msg_id, pl);
3090
3091 entry = xs_html_tag("div",
3092 xs_html_attr("class", "snac-post-with-desc"),
3093 html_label);
2834 3094
2835 if (strcmp(type, "Follow") == 0 || strcmp(utype, "Follow") == 0 || strcmp(type, "Block") == 0) { 3095 if (strcmp(type, "Follow") == 0 || strcmp(utype, "Follow") == 0 || strcmp(type, "Block") == 0) {
2836 xs_html_add(entry, 3096 xs_html_add(entry,
2837 xs_html_tag("div", 3097 xs_html_tag("div",
2838 xs_html_attr("class", "snac-post"), 3098 xs_html_attr("class", "snac-post"),
2839 html_actor_icon(user, actor, NULL, NULL, NULL, 0, 0, proxy))); 3099 html_actor_icon(user, actor, NULL, NULL, NULL, 0, 0, proxy, NULL)));
2840 } 3100 }
2841 else 3101 else
2842 if (strcmp(type, "Move") == 0) { 3102 if (strcmp(type, "Move") == 0) {
@@ -2850,7 +3110,7 @@ xs_str *html_notifications(snac *user, int skip, int show)
2850 xs_html_add(entry, 3110 xs_html_add(entry,
2851 xs_html_tag("div", 3111 xs_html_tag("div",
2852 xs_html_attr("class", "snac-post"), 3112 xs_html_attr("class", "snac-post"),
2853 html_actor_icon(user, old_actor, NULL, NULL, NULL, 0, 0, proxy))); 3113 html_actor_icon(user, old_actor, NULL, NULL, NULL, 0, 0, proxy, NULL)));
2854 } 3114 }
2855 } 3115 }
2856 } 3116 }
@@ -2917,6 +3177,8 @@ xs_str *html_notifications(snac *user, int skip, int show)
2917 xs_html_text(L("More..."))))); 3177 xs_html_text(L("More...")))));
2918 } 3178 }
2919 3179
3180 xs_set_free(&rep);
3181
2920 xs_html_add(body, 3182 xs_html_add(body,
2921 html_footer()); 3183 html_footer());
2922 3184
@@ -2970,6 +3232,21 @@ int html_get_handler(const xs_dict *req, const char *q_path,
2970 else 3232 else
2971 return HTTP_STATUS_NOT_FOUND; 3233 return HTTP_STATUS_NOT_FOUND;
2972 } 3234 }
3235 else
3236 if (strcmp(v, "auth-int-bridge") == 0) {
3237 const char *login = xs_dict_get(q_vars, "login");
3238 const char *id = xs_dict_get(q_vars, "id");
3239 const char *action = xs_dict_get(q_vars, "action");
3240
3241 if (xs_is_string(login) && xs_is_string(id) && xs_is_string(action)) {
3242 *body = xs_fmt("%s/%s/authorize_interaction?action=%s&id=%s",
3243 srv_baseurl, login, action, id);
3244
3245 return HTTP_STATUS_SEE_OTHER;
3246 }
3247 else
3248 return HTTP_STATUS_NOT_FOUND;
3249 }
2973 3250
2974 uid = xs_dup(v); 3251 uid = xs_dup(v);
2975 3252
@@ -3542,6 +3819,52 @@ int html_get_handler(const xs_dict *req, const char *q_path,
3542 } 3819 }
3543 } 3820 }
3544 else 3821 else
3822 if (strcmp(p_path, "authorize_interaction") == 0) { /** follow, like or boost from Mastodon **/
3823 if (!login(&snac, req)) {
3824 *body = xs_dup(uid);
3825 status = HTTP_STATUS_UNAUTHORIZED;
3826 }
3827 else {
3828 status = HTTP_STATUS_NOT_FOUND;
3829
3830 const char *id = xs_dict_get(q_vars, "id");
3831 const char *action = xs_dict_get(q_vars, "action");
3832
3833 if (xs_is_string(id) && xs_is_string(action)) {
3834 if (strcmp(action, "Follow") == 0) {
3835 xs *msg = msg_follow(&snac, id);
3836
3837 if (msg != NULL) {
3838 const char *actor = xs_dict_get(msg, "object");
3839
3840 following_add(&snac, actor, msg);
3841
3842 enqueue_output_by_actor(&snac, msg, actor, 0);
3843
3844 status = HTTP_STATUS_SEE_OTHER;
3845 }
3846 }
3847 else
3848 if (xs_match(action, "Like|Boost|Announce")) {
3849 /* bring the post */
3850 xs *msg = msg_admiration(&snac, id, *action == 'L' ? "Like" : "Announce");
3851
3852 if (msg != NULL) {
3853 enqueue_message(&snac, msg);
3854 timeline_admire(&snac, xs_dict_get(msg, "object"), snac.actor, *action == 'L' ? 1 : 0);
3855
3856 status = HTTP_STATUS_SEE_OTHER;
3857 }
3858 }
3859 }
3860
3861 if (status == HTTP_STATUS_SEE_OTHER) {
3862 *body = xs_fmt("%s/admin", snac.actor);
3863 *b_size = strlen(*body);
3864 }
3865 }
3866 }
3867 else
3545 status = HTTP_STATUS_NOT_FOUND; 3868 status = HTTP_STATUS_NOT_FOUND;
3546 3869
3547 user_free(&snac); 3870 user_free(&snac);
@@ -4024,6 +4347,9 @@ int html_post_handler(const xs_dict *req, const char *q_path,
4024 else 4347 else
4025 snac.config = xs_dict_set(snac.config, "show_contact_metrics", xs_stock(XSTYPE_FALSE)); 4348 snac.config = xs_dict_set(snac.config, "show_contact_metrics", xs_stock(XSTYPE_FALSE));
4026 4349
4350 snac.config = xs_dict_set(snac.config, "latitude", xs_dict_get_def(p_vars, "latitude", ""));
4351 snac.config = xs_dict_set(snac.config, "longitude", xs_dict_get_def(p_vars, "longitude", ""));
4352
4027 if ((v = xs_dict_get(p_vars, "metadata")) != NULL) 4353 if ((v = xs_dict_get(p_vars, "metadata")) != NULL)
4028 snac.config = xs_dict_set(snac.config, "metadata", v); 4354 snac.config = xs_dict_set(snac.config, "metadata", v);
4029 4355
@@ -4140,6 +4466,35 @@ int html_post_handler(const xs_dict *req, const char *q_path,
4140 4466
4141 status = HTTP_STATUS_SEE_OTHER; 4467 status = HTTP_STATUS_SEE_OTHER;
4142 } 4468 }
4469 else
4470 if (p_path && strcmp(p_path, "admin/followed-hashtags") == 0) { /** **/
4471 const char *followed_hashtags = xs_dict_get(p_vars, "followed_hashtags");
4472
4473 if (xs_is_string(followed_hashtags)) {
4474 xs *new_hashtags = xs_list_new();
4475 xs *l = xs_split(followed_hashtags, "\n");
4476 const char *v;
4477
4478 xs_list_foreach(l, v) {
4479 xs *s1 = xs_strip_i(xs_dup(v));
4480 s1 = xs_replace_i(s1, " ", "");
4481
4482 if (*s1 == '\0')
4483 continue;
4484
4485 xs *s2 = xs_utf8_to_lower(s1);
4486 if (*s2 != '#')
4487 s2 = xs_str_prepend_i(s2, "#");
4488
4489 new_hashtags = xs_list_append(new_hashtags, s2);
4490 }
4491
4492 snac.config = xs_dict_set(snac.config, "followed_hashtags", new_hashtags);
4493 user_persist(&snac, 0);
4494 }
4495
4496 status = HTTP_STATUS_SEE_OTHER;
4497 }
4143 4498
4144 if (status == HTTP_STATUS_SEE_OTHER) { 4499 if (status == HTTP_STATUS_SEE_OTHER) {
4145 const char *redir = xs_dict_get(p_vars, "redir"); 4500 const char *redir = xs_dict_get(p_vars, "redir");
diff --git a/http.c b/http.c
index b21f1dc..611de4f 100644
--- a/http.c
+++ b/http.c
@@ -1,5 +1,5 @@
1/* snac - A simple, minimalistic ActivityPub instance */ 1/* snac - A simple, minimalistic ActivityPub instance */
2/* copyright (c) 2022 - 2024 grunfink et al. / MIT license */ 2/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */
3 3
4#include "xs.h" 4#include "xs.h"
5#include "xs_io.h" 5#include "xs_io.h"
diff --git a/httpd.c b/httpd.c
index 163f98a..e0a36b6 100644
--- a/httpd.c
+++ b/httpd.c
@@ -1,5 +1,5 @@
1/* snac - A simple, minimalistic ActivityPub instance */ 1/* snac - A simple, minimalistic ActivityPub instance */
2/* copyright (c) 2022 - 2024 grunfink et al. / MIT license */ 2/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */
3 3
4#include "xs.h" 4#include "xs.h"
5#include "xs_io.h" 5#include "xs_io.h"
@@ -138,7 +138,7 @@ static xs_str *greeting_html(void)
138 while (xs_list_iter(&p, &uid)) { 138 while (xs_list_iter(&p, &uid)) {
139 snac user; 139 snac user;
140 140
141 if (user_open(&user, uid)) { 141 if (strcmp(uid, "relay") && user_open(&user, uid)) {
142 xs_html_add(ul, 142 xs_html_add(ul,
143 xs_html_tag("li", 143 xs_html_tag("li",
144 xs_html_tag("a", 144 xs_html_tag("a",
@@ -182,6 +182,29 @@ const char *share_page = ""
182""; 182"";
183 183
184 184
185const char *authorize_interaction_page = ""
186"<!DOCTYPE html>\n"
187"<html>\n"
188"<head>\n"
189"<title>%s - snac</title>\n"
190"<meta content=\"width=device-width, initial-scale=1, minimum-scale=1, user-scalable=no\" name=\"viewport\">\n"
191"<link rel=\"stylesheet\" type=\"text/css\" href=\"%s/style.css\"/>\n"
192"<style>:root {color-scheme: light dark}</style>\n"
193"</head>\n"
194"<body><h1>%s authorize interaction</h1>\n"
195"<form method=\"get\" action=\"%s/auth-int-bridge\">\n"
196"<select name=\"action\">\n"
197"<option value=\"Follow\">Follow</option>\n"
198"<option value=\"Boost\">Boost</option>\n"
199"<option value=\"Like\">Like</option>\n"
200"</select> %s\n"
201"<input type=\"hidden\" name=\"id\" value=\"%s\">\n"
202"<p>Login: <input type=\"text\" name=\"login\" autocapitalize=\"off\" required=\"required\"></p>\n"
203"<input type=\"submit\" value=\"OK\">\n"
204"</form><p>%s</p></body></html>\n"
205"";
206
207
185int server_get_handler(xs_dict *req, const char *q_path, 208int server_get_handler(xs_dict *req, const char *q_path,
186 char **body, int *b_size, char **ctype) 209 char **body, int *b_size, char **ctype)
187/* basic server services */ 210/* basic server services */
@@ -189,7 +212,7 @@ int server_get_handler(xs_dict *req, const char *q_path,
189 int status = 0; 212 int status = 0;
190 213
191 /* is it the server root? */ 214 /* is it the server root? */
192 if (*q_path == '\0') { 215 if (*q_path == '\0' || strcmp(q_path, "/") == 0) {
193 const xs_dict *q_vars = xs_dict_get(req, "q_vars"); 216 const xs_dict *q_vars = xs_dict_get(req, "q_vars");
194 const char *t = NULL; 217 const char *t = NULL;
195 218
@@ -318,6 +341,25 @@ int server_get_handler(xs_dict *req, const char *q_path,
318 USER_AGENT 341 USER_AGENT
319 ); 342 );
320 } 343 }
344 else
345 if (strcmp(q_path, "/authorize_interaction") == 0) {
346 const xs_dict *q_vars = xs_dict_get(req, "q_vars");
347 const char *uri = xs_dict_get(q_vars, "uri");
348
349 if (xs_is_string(uri)) {
350 status = HTTP_STATUS_OK;
351 *ctype = "text/html; charset=utf-8";
352 *body = xs_fmt(authorize_interaction_page,
353 xs_dict_get(srv_config, "host"),
354 srv_baseurl,
355 xs_dict_get(srv_config, "host"),
356 srv_baseurl,
357 uri,
358 uri,
359 USER_AGENT
360 );
361 }
362 }
321 363
322 if (status != 0) 364 if (status != 0)
323 srv_debug(1, xs_fmt("server_get_handler serving '%s' %d", q_path, status)); 365 srv_debug(1, xs_fmt("server_get_handler serving '%s' %d", q_path, status));
@@ -459,13 +501,13 @@ void httpd_connection(FILE *f)
459 } 501 }
460 502
461 if (status == HTTP_STATUS_FORBIDDEN) 503 if (status == HTTP_STATUS_FORBIDDEN)
462 body = xs_str_new("<h1>403 Forbidden</h1>"); 504 body = xs_str_new("<h1>403 Forbidden (" USER_AGENT ")</h1>");
463 505
464 if (status == HTTP_STATUS_NOT_FOUND) 506 if (status == HTTP_STATUS_NOT_FOUND)
465 body = xs_str_new("<h1>404 Not Found</h1>"); 507 body = xs_str_new("<h1>404 Not Found (" USER_AGENT ")</h1>");
466 508
467 if (status == HTTP_STATUS_BAD_REQUEST && body != NULL) 509 if (status == HTTP_STATUS_BAD_REQUEST && body != NULL)
468 body = xs_str_new("<h1>400 Bad Request</h1>"); 510 body = xs_str_new("<h1>400 Bad Request (" USER_AGENT ")</h1>");
469 511
470 if (status == HTTP_STATUS_SEE_OTHER) 512 if (status == HTTP_STATUS_SEE_OTHER)
471 headers = xs_dict_append(headers, "location", body); 513 headers = xs_dict_append(headers, "location", body);
diff --git a/main.c b/main.c
index 7d23c84..a57adb5 100644
--- a/main.c
+++ b/main.c
@@ -1,11 +1,12 @@
1/* snac - A simple, minimalistic ActivityPub instance */ 1/* snac - A simple, minimalistic ActivityPub instance */
2/* copyright (c) 2022 - 2024 grunfink et al. / MIT license */ 2/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */
3 3
4#include "xs.h" 4#include "xs.h"
5#include "xs_io.h" 5#include "xs_io.h"
6#include "xs_json.h" 6#include "xs_json.h"
7#include "xs_time.h" 7#include "xs_time.h"
8#include "xs_openssl.h" 8#include "xs_openssl.h"
9#include "xs_match.h"
9 10
10#include "snac.h" 11#include "snac.h"
11 12
@@ -14,7 +15,7 @@
14int usage(void) 15int usage(void)
15{ 16{
16 printf("snac " VERSION " - A simple, minimalistic ActivityPub instance\n"); 17 printf("snac " VERSION " - A simple, minimalistic ActivityPub instance\n");
17 printf("Copyright (c) 2022 - 2024 grunfink et al. / MIT license\n"); 18 printf("Copyright (c) 2022 - 2025 grunfink et al. / MIT license\n");
18 printf("\n"); 19 printf("\n");
19 printf("Commands:\n"); 20 printf("Commands:\n");
20 printf("\n"); 21 printf("\n");
@@ -34,6 +35,7 @@ int usage(void)
34 printf("actor {basedir} [{uid}] {url} Requests an actor\n"); 35 printf("actor {basedir} [{uid}] {url} Requests an actor\n");
35 printf("note {basedir} {uid} {text} [files...] Sends a note with optional attachments\n"); 36 printf("note {basedir} {uid} {text} [files...] Sends a note with optional attachments\n");
36 printf("note_unlisted {basedir} {uid} {text} [files...] Sends an unlisted note with optional attachments\n"); 37 printf("note_unlisted {basedir} {uid} {text} [files...] Sends an unlisted note with optional attachments\n");
38 printf("note_mention {basedir} {uid} {text} [files...] Sends a note only to mentioned accounts\n");
37 printf("boost|announce {basedir} {uid} {url} Boosts (announces) a post\n"); 39 printf("boost|announce {basedir} {uid} {url} Boosts (announces) a post\n");
38 printf("unboost {basedir} {uid} {url} Unboosts a post\n"); 40 printf("unboost {basedir} {uid} {url} Unboosts a post\n");
39 printf("resetpwd {basedir} {uid} Resets the password of a user\n"); 41 printf("resetpwd {basedir} {uid} Resets the password of a user\n");
@@ -49,10 +51,10 @@ int usage(void)
49 printf("unlimit {basedir} {uid} {actor} Unlimits an actor\n"); 51 printf("unlimit {basedir} {uid} {actor} Unlimits an actor\n");
50 printf("verify_links {basedir} {uid} Verifies a user's links (in the metadata)\n"); 52 printf("verify_links {basedir} {uid} Verifies a user's links (in the metadata)\n");
51 printf("search {basedir} {uid} {regex} Searches posts by content\n"); 53 printf("search {basedir} {uid} {regex} Searches posts by content\n");
52 printf("export_csv {basedir} {uid} Exports data as CSV files into current directory\n"); 54 printf("export_csv {basedir} {uid} Exports data as CSV files\n");
53 printf("alias {basedir} {uid} {account} Sets account (@user@host or actor url) as an alias\n"); 55 printf("alias {basedir} {uid} {account} Sets account (@user@host or actor url) as an alias\n");
54 printf("migrate {basedir} {uid} Migrates to the account defined as the alias\n"); 56 printf("migrate {basedir} {uid} Migrates to the account defined as the alias\n");
55 printf("import_csv {basedir} {uid} Imports data from CSV files in the current directory\n"); 57 printf("import_csv {basedir} {uid} Imports data from CSV files\n");
56 printf("import_list {basedir} {uid} {file} Imports a Mastodon CSV list file\n"); 58 printf("import_list {basedir} {uid} {file} Imports a Mastodon CSV list file\n");
57 printf("import_block_list {basedir} {uid} {file} Imports a Mastodon CSV block list file\n"); 59 printf("import_block_list {basedir} {uid} {file} Imports a Mastodon CSV block list file\n");
58 60
@@ -94,19 +96,6 @@ int main(int argc, char *argv[])
94 return snac_init(basedir); 96 return snac_init(basedir);
95 } 97 }
96 98
97 if (strcmp(cmd, "upgrade") == 0) { /** **/
98 int ret;
99
100 /* upgrade */
101 if ((basedir = GET_ARGV()) == NULL)
102 return usage();
103
104 if ((ret = srv_open(basedir, 1)) == 1)
105 srv_log(xs_dup("OK"));
106
107 return ret;
108 }
109
110 if (strcmp(cmd, "markdown") == 0) { /** **/ 99 if (strcmp(cmd, "markdown") == 0) { /** **/
111 /* undocumented, for testing only */ 100 /* undocumented, for testing only */
112 xs *c = xs_readall(stdin); 101 xs *c = xs_readall(stdin);
@@ -116,8 +105,20 @@ int main(int argc, char *argv[])
116 return 0; 105 return 0;
117 } 106 }
118 107
119 if ((basedir = GET_ARGV()) == NULL) 108 if ((basedir = getenv("SNAC_BASEDIR")) == NULL) {
120 return usage(); 109 if ((basedir = GET_ARGV()) == NULL)
110 return usage();
111 }
112
113 if (strcmp(cmd, "upgrade") == 0) { /** **/
114 int ret;
115
116 /* upgrade */
117 if ((ret = srv_open(basedir, 1)) == 1)
118 srv_log(xs_dup("OK"));
119
120 return ret;
121 }
121 122
122 if (!srv_open(basedir, 0)) { 123 if (!srv_open(basedir, 0)) {
123 srv_log(xs_fmt("error opening data storage at %s", basedir)); 124 srv_log(xs_fmt("error opening data storage at %s", basedir));
@@ -351,6 +352,22 @@ int main(int argc, char *argv[])
351 return 0; 352 return 0;
352 } 353 }
353 354
355
356 if (strcmp(cmd, "assist") == 0) { /** **/
357 /* undocumented: experimental (do not use) */
358 xs *msg = msg_admiration(&snac, url, "Accept");
359
360 if (msg != NULL) {
361 enqueue_message(&snac, msg);
362
363 if (dbglevel) {
364 xs_json_dump(msg, 4, stdout);
365 }
366 }
367
368 return 0;
369 }
370
354 if (strcmp(cmd, "unboost") == 0) { /** **/ 371 if (strcmp(cmd, "unboost") == 0) { /** **/
355 xs *msg = msg_repulsion(&snac, url, "Announce"); 372 xs *msg = msg_repulsion(&snac, url, "Announce");
356 373
@@ -604,7 +621,9 @@ int main(int argc, char *argv[])
604 return 0; 621 return 0;
605 } 622 }
606 623
607 if (strcmp(cmd, "note") == 0 || strcmp(cmd, "note_unlisted") == 0) { /** **/ 624 if (strcmp(cmd, "note") == 0 || /** **/
625 strcmp(cmd, "note_unlisted") == 0 || /** **/
626 strcmp(cmd, "note_mention") == 0) { /** **/
608 xs *content = NULL; 627 xs *content = NULL;
609 xs *msg = NULL; 628 xs *msg = NULL;
610 xs *c_msg = NULL; 629 xs *c_msg = NULL;
@@ -668,15 +687,14 @@ int main(int argc, char *argv[])
668 else 687 else
669 content = xs_dup(url); 688 content = xs_dup(url);
670 689
671 msg = msg_note(&snac, content, NULL, NULL, attl, 0, getenv("LANG")); 690 int scope = 0;
691 if (strcmp(cmd, "note_mention") == 0)
692 scope = 1;
693 else
694 if (strcmp(cmd, "note_unlisted") == 0)
695 scope = 2;
672 696
673 if (strcmp(cmd, "note_unlisted") == 0) { 697 msg = msg_note(&snac, content, NULL, NULL, attl, scope, getenv("LANG"));
674 /* according to Mastodon, "unlisted" posts (now called "quiet public")
675 has the public address as a cc instead of to, so toggle it */
676 xs *to = xs_dup(xs_dict_get(msg, "to"));
677 msg = xs_dict_set(msg, "cc", to);
678 msg = xs_dict_set(msg, "to", xs_stock(XSTYPE_LIST));
679 }
680 698
681 c_msg = msg_create(&snac, msg); 699 c_msg = msg_create(&snac, msg);
682 700
diff --git a/mastoapi.c b/mastoapi.c
index 62108ad..54b4333 100644
--- a/mastoapi.c
+++ b/mastoapi.c
@@ -1,5 +1,5 @@
1/* snac - A simple, minimalistic ActivityPub instance */ 1/* snac - A simple, minimalistic ActivityPub instance */
2/* copyright (c) 2022 - 2024 grunfink et al. / MIT license */ 2/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */
3 3
4#ifndef NO_MASTODON_API 4#ifndef NO_MASTODON_API
5 5
@@ -1339,6 +1339,9 @@ xs_list *mastoapi_timeline(snac *user, const xs_dict *args, const char *index_fn
1339 const char *since_id = xs_dict_get(args, "since_id"); 1339 const char *since_id = xs_dict_get(args, "since_id");
1340 const char *min_id = xs_dict_get(args, "min_id"); /* unsupported old-to-new navigation */ 1340 const char *min_id = xs_dict_get(args, "min_id"); /* unsupported old-to-new navigation */
1341 const char *limit_s = xs_dict_get(args, "limit"); 1341 const char *limit_s = xs_dict_get(args, "limit");
1342 int (*iterator)(FILE *, char *);
1343 int initial_status = 0;
1344 int ascending = 0;
1342 int limit = 0; 1345 int limit = 0;
1343 int cnt = 0; 1346 int cnt = 0;
1344 1347
@@ -1348,27 +1351,40 @@ xs_list *mastoapi_timeline(snac *user, const xs_dict *args, const char *index_fn
1348 if (limit == 0) 1351 if (limit == 0)
1349 limit = 20; 1352 limit = 20;
1350 1353
1351 if (index_desc_first(f, md5, 0)) { 1354 if (min_id) {
1355 iterator = &index_asc_next;
1356 initial_status = index_asc_first(f, md5, MID_TO_MD5(min_id));
1357 ascending = 1;
1358 }
1359 else {
1360 iterator = &index_desc_next;
1361 initial_status = index_desc_first(f, md5, 0);
1362 }
1363
1364 if (initial_status) {
1352 do { 1365 do {
1353 xs *msg = NULL; 1366 xs *msg = NULL;
1354 1367
1355 /* only return entries older that max_id */ 1368 /* only return entries older that max_id */
1356 if (max_id) { 1369 if (max_id) {
1357 if (strcmp(md5, MID_TO_MD5(max_id)) == 0) 1370 if (strcmp(md5, MID_TO_MD5(max_id)) == 0) {
1358 max_id = NULL; 1371 max_id = NULL;
1359 1372 if (ascending)
1360 continue; 1373 break;
1374 }
1375 if (!ascending)
1376 continue;
1361 } 1377 }
1362 1378
1363 /* only returns entries newer than since_id */ 1379 /* only returns entries newer than since_id */
1364 if (since_id) { 1380 if (since_id) {
1365 if (strcmp(md5, MID_TO_MD5(since_id)) == 0) 1381 if (strcmp(md5, MID_TO_MD5(since_id)) == 0) {
1366 break; 1382 if (!ascending)
1367 } 1383 break;
1368 1384 since_id = NULL;
1369 if (min_id) { 1385 }
1370 if (strcmp(md5, MID_TO_MD5(min_id)) == 0) 1386 if (ascending)
1371 break; 1387 continue;
1372 } 1388 }
1373 1389
1374 /* get the entry */ 1390 /* get the entry */
@@ -1428,26 +1444,23 @@ xs_list *mastoapi_timeline(snac *user, const xs_dict *args, const char *index_fn
1428 continue; 1444 continue;
1429 } 1445 }
1430 1446
1431 /* if it has a name and it's not a Page or a Video, 1447 /* if it has a name and it's not an object that may have one,
1432 it's a poll vote, so discard it */ 1448 it's a poll vote, so discard it */
1433 if (!xs_is_null(xs_dict_get(msg, "name")) && !xs_match(type, "Page|Video")) 1449 if (!xs_is_null(xs_dict_get(msg, "name")) && !xs_match(type, "Page|Video|Audio|Event"))
1434 continue; 1450 continue;
1435 1451
1436 /* convert the Note into a Mastodon status */ 1452 /* convert the Note into a Mastodon status */
1437 xs *st = mastoapi_status(user, msg); 1453 xs *st = mastoapi_status(user, msg);
1438 1454
1439 if (st != NULL) { 1455 if (st != NULL) {
1440 out = xs_list_append(out, st); 1456 if (ascending)
1457 out = xs_list_insert(out, 0, st);
1458 else
1459 out = xs_list_append(out, st);
1441 cnt++; 1460 cnt++;
1442 } 1461 }
1443 if (min_id) {
1444 while (cnt > limit) {
1445 out = xs_list_del(out, 0);
1446 cnt--;
1447 }
1448 }
1449 1462
1450 } while ((min_id || (cnt < limit)) && index_desc_next(f, md5)); 1463 } while ((cnt < limit) && (*iterator)(f, md5));
1451 } 1464 }
1452 1465
1453 int more = index_desc_next(f, md5); 1466 int more = index_desc_next(f, md5);
@@ -1816,6 +1829,11 @@ int mastoapi_get_handler(const xs_dict *req, const char *q_path,
1816 const xs_list *excl = xs_dict_get(args, "exclude_types[]"); 1829 const xs_list *excl = xs_dict_get(args, "exclude_types[]");
1817 const char *min_id = xs_dict_get(args, "min_id"); 1830 const char *min_id = xs_dict_get(args, "min_id");
1818 const char *max_id = xs_dict_get(args, "max_id"); 1831 const char *max_id = xs_dict_get(args, "max_id");
1832 const char *limit = xs_dict_get(args, "limit");
1833 int limit_count = 0;
1834 if (!xs_is_null(limit)) {
1835 limit_count = atoi(limit);
1836 }
1819 1837
1820 if (dbglevel) { 1838 if (dbglevel) {
1821 xs *js = xs_json_dumps(args, 0); 1839 xs *js = xs_json_dumps(args, 0);
@@ -1903,6 +1921,10 @@ int mastoapi_get_handler(const xs_dict *req, const char *q_path,
1903 } 1921 }
1904 1922
1905 out = xs_list_append(out, mn); 1923 out = xs_list_append(out, mn);
1924 if (!xs_is_null(limit)) {
1925 if (--limit_count <= 0)
1926 break;
1927 }
1906 } 1928 }
1907 1929
1908 srv_debug(1, xs_fmt("mastoapi_notifications count %d", xs_list_len(out))); 1930 srv_debug(1, xs_fmt("mastoapi_notifications count %d", xs_list_len(out)));
@@ -2650,8 +2672,14 @@ int mastoapi_post_handler(const xs_dict *req, const char *q_path,
2650 } 2672 }
2651 2673
2652 /* prepare the message */ 2674 /* prepare the message */
2653 xs *msg = msg_note(&snac, content, NULL, irt, attach_list, 2675 int scope = 1;
2654 strcmp(visibility, "public") == 0 ? 0 : 1, language); 2676 if (strcmp(visibility, "unlisted") == 0)
2677 scope = 2;
2678 else
2679 if (strcmp(visibility, "public") == 0)
2680 scope = 0;
2681
2682 xs *msg = msg_note(&snac, content, NULL, irt, attach_list, scope, language);
2655 2683
2656 if (!xs_is_null(summary) && *summary) { 2684 if (!xs_is_null(summary) && *summary) {
2657 msg = xs_dict_set(msg, "sensitive", xs_stock(XSTYPE_TRUE)); 2685 msg = xs_dict_set(msg, "sensitive", xs_stock(XSTYPE_TRUE));
diff --git a/sandbox.c b/sandbox.c
index cbe0043..0fc48ad 100644
--- a/sandbox.c
+++ b/sandbox.c
@@ -71,15 +71,22 @@ LL_BEGIN(sbox_enter_linux_, const char* basedir, const char *address, int smail)
71 LANDLOCK_ACCESS_FS_REFER_COMPAT, 71 LANDLOCK_ACCESS_FS_REFER_COMPAT,
72 s = LANDLOCK_ACCESS_FS_MAKE_SOCK, 72 s = LANDLOCK_ACCESS_FS_MAKE_SOCK,
73 x = LANDLOCK_ACCESS_FS_EXECUTE; 73 x = LANDLOCK_ACCESS_FS_EXECUTE;
74 char *resolved_path = NULL;
74 75
75 LL_PATH(basedir, rf|rd|w|c); 76 LL_PATH(basedir, rf|rd|w|c);
76 LL_PATH("/tmp", rf|rd|w|c); 77 LL_PATH("/tmp", rf|rd|w|c);
77#ifndef WITHOUT_SHM 78#ifndef WITHOUT_SHM
78 LL_PATH("/dev/shm", rf|w|c ); 79 LL_PATH("/dev/shm", rf|w|c );
79#endif 80#endif
81 LL_PATH("/dev/urandom", rf );
80 LL_PATH("/etc/resolv.conf", rf ); 82 LL_PATH("/etc/resolv.conf", rf );
81 LL_PATH("/etc/hosts", rf ); 83 LL_PATH("/etc/hosts", rf );
82 LL_PATH("/etc/ssl", rf ); 84 LL_PATH("/etc/ssl", rf|rd );
85 if ((resolved_path = realpath("/etc/ssl/cert.pem", NULL))) {
86 /* some distros like cert.pem to be a symlink */
87 LL_PATH(resolved_path, rf );
88 free(resolved_path);
89 }
83 LL_PATH("/usr/share/zoneinfo", rf ); 90 LL_PATH("/usr/share/zoneinfo", rf );
84 91
85 if (mtime("/etc/pki") > 0) 92 if (mtime("/etc/pki") > 0)
diff --git a/snac.c b/snac.c
index 1ce563b..80c7349 100644
--- a/snac.c
+++ b/snac.c
@@ -1,5 +1,5 @@
1/* snac - A simple, minimalistic ActivityPub instance */ 1/* snac - A simple, minimalistic ActivityPub instance */
2/* copyright (c) 2022 - 2024 grunfink et al. / MIT license */ 2/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */
3 3
4#define XS_IMPLEMENTATION 4#define XS_IMPLEMENTATION
5 5
diff --git a/snac.h b/snac.h
index ec3ee5c..344dbaa 100644
--- a/snac.h
+++ b/snac.h
@@ -1,7 +1,7 @@
1/* snac - A simple, minimalistic ActivityPub instance */ 1/* snac - A simple, minimalistic ActivityPub instance */
2/* copyright (c) 2022 - 2024 grunfink et al. / MIT license */ 2/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */
3 3
4#define VERSION "2.68-dev" 4#define VERSION "2.70-dev"
5 5
6#define USER_AGENT "snac/" VERSION 6#define USER_AGENT "snac/" VERSION
7 7
@@ -108,6 +108,8 @@ int index_len(const char *fn);
108xs_list *index_list(const char *fn, int max); 108xs_list *index_list(const char *fn, int max);
109int index_desc_next(FILE *f, char md5[MD5_HEX_SIZE]); 109int index_desc_next(FILE *f, char md5[MD5_HEX_SIZE]);
110int index_desc_first(FILE *f, char md5[MD5_HEX_SIZE], int skip); 110int index_desc_first(FILE *f, char md5[MD5_HEX_SIZE], int skip);
111int index_asc_next(FILE *f, char md5[MD5_HEX_SIZE]);
112int index_asc_first(FILE *f, char md5[MD5_HEX_SIZE], const char *seek_md5);
111xs_list *index_list_desc(const char *fn, int skip, int show); 113xs_list *index_list_desc(const char *fn, int skip, int show);
112 114
113int object_add(const char *id, const xs_dict *obj); 115int object_add(const char *id, const xs_dict *obj);
@@ -317,7 +319,7 @@ xs_dict *msg_follow(snac *snac, const char *actor);
317 319
318xs_dict *msg_note(snac *snac, const xs_str *content, const xs_val *rcpts, 320xs_dict *msg_note(snac *snac, const xs_str *content, const xs_val *rcpts,
319 const xs_str *in_reply_to, const xs_list *attach, 321 const xs_str *in_reply_to, const xs_list *attach,
320 int priv, const char *lang); 322 int scope, const char *lang);
321 323
322xs_dict *msg_undo(snac *snac, const xs_val *object); 324xs_dict *msg_undo(snac *snac, const xs_val *object);
323xs_dict *msg_delete(snac *snac, const char *id); 325xs_dict *msg_delete(snac *snac, const char *id);
diff --git a/upgrade.c b/upgrade.c
index 847c62e..87ddfc8 100644
--- a/upgrade.c
+++ b/upgrade.c
@@ -1,5 +1,5 @@
1/* snac - A simple, minimalistic ActivityPub instance */ 1/* snac - A simple, minimalistic ActivityPub instance */
2/* copyright (c) 2022 - 2024 grunfink et al. / MIT license */ 2/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */
3 3
4#include "xs.h" 4#include "xs.h"
5#include "xs_io.h" 5#include "xs_io.h"
diff --git a/utils.c b/utils.c
index 0f97a2f..4429437 100644
--- a/utils.c
+++ b/utils.c
@@ -1,5 +1,5 @@
1/* snac - A simple, minimalistic ActivityPub instance */ 1/* snac - A simple, minimalistic ActivityPub instance */
2/* copyright (c) 2022 - 2024 grunfink et al. / MIT license */ 2/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */
3 3
4#include "xs.h" 4#include "xs.h"
5#include "xs_io.h" 5#include "xs_io.h"
@@ -98,7 +98,7 @@ static const char *greeting_html =
98 "<html><head>\n" 98 "<html><head>\n"
99 "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"/>\n" 99 "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"/>\n"
100 "<link rel=\"icon\" type=\"image/x-icon\" href=\"https://%host%/favicon.ico\"/>\n" 100 "<link rel=\"icon\" type=\"image/x-icon\" href=\"https://%host%/favicon.ico\"/>\n"
101 "<title>Welcome to %host%</title>\n" 101 "<title>Welcome to %host%</title>\n</head>\n"
102 "<body style=\"margin: auto; max-width: 50em\">\n" 102 "<body style=\"margin: auto; max-width: 50em\">\n"
103 "%blurb%" 103 "%blurb%"
104 "<p>The following users are part of this community:</p>\n" 104 "<p>The following users are part of this community:</p>\n"
@@ -319,6 +319,10 @@ int adduser(const char *uid)
319 mkdirx(d); 319 mkdirx(d);
320 } 320 }
321 321
322 /* add a specially short data retention time for the relay */
323 if (strcmp(uid, "relay") == 0)
324 config = xs_dict_set(config, "purge_days", xs_stock(1));
325
322 xs *cfn = xs_fmt("%s/user.json", basedir); 326 xs *cfn = xs_fmt("%s/user.json", basedir);
323 327
324 if ((f = fopen(cfn, "w")) == NULL) { 328 if ((f = fopen(cfn, "w")) == NULL) {
@@ -331,7 +335,7 @@ int adduser(const char *uid)
331 } 335 }
332 336
333 printf("\nCreating RSA key...\n"); 337 printf("\nCreating RSA key...\n");
334 key = xs_evp_genkey(4096); 338 key = xs_evp_genkey(2048);
335 printf("Done.\n"); 339 printf("Done.\n");
336 340
337 xs *kfn = xs_fmt("%s/key.json", basedir); 341 xs *kfn = xs_fmt("%s/key.json", basedir);
diff --git a/webfinger.c b/webfinger.c
index 85123bc..5db9a97 100644
--- a/webfinger.c
+++ b/webfinger.c
@@ -1,5 +1,5 @@
1/* snac - A simple, minimalistic ActivityPub instance */ 1/* snac - A simple, minimalistic ActivityPub instance */
2/* copyright (c) 2022 - 2024 grunfink et al. / MIT license */ 2/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */
3 3
4#include "xs.h" 4#include "xs.h"
5#include "xs_json.h" 5#include "xs_json.h"
diff --git a/xs.h b/xs.h
index 39b3b64..05d84f5 100644
--- a/xs.h
+++ b/xs.h
@@ -1,4 +1,4 @@
1/* copyright (c) 2022 - 2024 grunfink et al. / MIT license */ 1/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */
2 2
3#ifndef _XS_H 3#ifndef _XS_H
4 4
@@ -157,6 +157,9 @@ unsigned int xs_hash_func(const char *data, int size);
157#define xs_is_true(v) (xs_type((v)) == XSTYPE_TRUE) 157#define xs_is_true(v) (xs_type((v)) == XSTYPE_TRUE)
158#define xs_is_false(v) (xs_type((v)) == XSTYPE_FALSE) 158#define xs_is_false(v) (xs_type((v)) == XSTYPE_FALSE)
159#define xs_not(v) xs_stock(xs_is_true((v)) ? XSTYPE_FALSE : XSTYPE_TRUE) 159#define xs_not(v) xs_stock(xs_is_true((v)) ? XSTYPE_FALSE : XSTYPE_TRUE)
160#define xs_is_string(v) (xs_type((v)) == XSTYPE_STRING)
161#define xs_is_list(v) (xs_type((v)) == XSTYPE_LIST)
162#define xs_is_dict(v) (xs_type((v)) == XSTYPE_DICT)
160 163
161#define xs_list_foreach(l, v) for (int ct_##__LINE__ = 0; xs_list_next(l, &v, &ct_##__LINE__); ) 164#define xs_list_foreach(l, v) for (int ct_##__LINE__ = 0; xs_list_next(l, &v, &ct_##__LINE__); )
162#define xs_dict_foreach(l, k, v) for (int ct_##__LINE__ = 0; xs_dict_next(l, &k, &v, &ct_##__LINE__); ) 165#define xs_dict_foreach(l, k, v) for (int ct_##__LINE__ = 0; xs_dict_next(l, &k, &v, &ct_##__LINE__); )
@@ -623,15 +626,14 @@ int xs_between(const char *prefix, const char *str, const char *suffix)
623xs_str *xs_crop_i(xs_str *str, int start, int end) 626xs_str *xs_crop_i(xs_str *str, int start, int end)
624/* crops the string to be only from start to end */ 627/* crops the string to be only from start to end */
625{ 628{
626 XS_ASSERT_TYPE(str, XSTYPE_STRING);
627
628 int sz = strlen(str); 629 int sz = strlen(str);
629 630
630 if (end <= 0) 631 if (end <= 0)
631 end = sz + end; 632 end = sz + end;
632 633
633 /* crop from the top */ 634 /* crop from the top */
634 str[end] = '\0'; 635 if (end > 0 && end < sz)
636 str[end] = '\0';
635 637
636 /* crop from the bottom */ 638 /* crop from the bottom */
637 str = xs_collapse(str, 0, start); 639 str = xs_collapse(str, 0, start);
@@ -1061,14 +1063,15 @@ xs_keyval *xs_keyval_make(xs_keyval *keyval, const xs_str *key, const xs_val *va
1061 1063
1062typedef struct { 1064typedef struct {
1063 int value_offset; /* offset to value (from dict start) */ 1065 int value_offset; /* offset to value (from dict start) */
1064 int next; /* next node in sequential search */ 1066 int next; /* next node in sequential scanning */
1065 int child[4]; /* child nodes in hashed search */ 1067 int child[4]; /* child nodes in hashed search */
1066 char key[]; /* C string key */ 1068 char key[]; /* C string key */
1067} ditem_hdr; 1069} ditem_hdr;
1068 1070
1069typedef struct { 1071typedef struct {
1070 int size; /* size of full dict (_XS_TYPE_SIZE) */ 1072 int size; /* size of full dict (_XS_TYPE_SIZE) */
1071 int first; /* first node for sequential search */ 1073 int first; /* first node for sequential scanning */
1074 int last; /* last node for sequential scanning */
1072 int root; /* root node for hashed search */ 1075 int root; /* root node for hashed search */
1073 /* a bunch of ditem_hdr and value follows */ 1076 /* a bunch of ditem_hdr and value follows */
1074} dict_hdr; 1077} dict_hdr;
@@ -1153,8 +1156,15 @@ xs_dict *xs_dict_set(xs_dict *dict, const xs_str *key, const xs_val *value)
1153 memcpy(dict + di->value_offset, value, vsz); 1156 memcpy(dict + di->value_offset, value, vsz);
1154 1157
1155 /* chain to the sequential list */ 1158 /* chain to the sequential list */
1156 di->next = dh->first; 1159 if (dh->first == 0)
1157 dh->first = end; 1160 dh->first = end;
1161 else {
1162 /* chain this new element to the last one */
1163 ditem_hdr *dil = (ditem_hdr *)(dict + dh->last);
1164 dil->next = end;
1165 }
1166
1167 dh->last = end;
1158 } 1168 }
1159 else { 1169 else {
1160 /* ditem already exists */ 1170 /* ditem already exists */
diff --git a/xs_curl.h b/xs_curl.h
index 9b4c229..f0cfd98 100644
--- a/xs_curl.h
+++ b/xs_curl.h
@@ -1,4 +1,4 @@
1/* copyright (c) 2022 - 2024 grunfink et al. / MIT license */ 1/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */
2 2
3#ifndef _XS_CURL_H 3#ifndef _XS_CURL_H
4 4
diff --git a/xs_fcgi.h b/xs_fcgi.h
index c6ffe1f..870e3e8 100644
--- a/xs_fcgi.h
+++ b/xs_fcgi.h
@@ -1,4 +1,4 @@
1/* copyright (c) 2022 - 2024 grunfink et al. / MIT license */ 1/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */
2 2
3/* 3/*
4 This is an intentionally-dead-simple FastCGI implementation; 4 This is an intentionally-dead-simple FastCGI implementation;
diff --git a/xs_glob.h b/xs_glob.h
index 15cb49a..6d87ca1 100644
--- a/xs_glob.h
+++ b/xs_glob.h
@@ -1,4 +1,4 @@
1/* copyright (c) 2022 - 2024 grunfink et al. / MIT license */ 1/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */
2 2
3#ifndef _XS_GLOB_H 3#ifndef _XS_GLOB_H
4 4
diff --git a/xs_hex.h b/xs_hex.h
index 745ac93..2507d5d 100644
--- a/xs_hex.h
+++ b/xs_hex.h
@@ -1,4 +1,4 @@
1/* copyright (c) 2022 - 2024 grunfink et al. / MIT license */ 1/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */
2 2
3#ifndef _XS_HEX_H 3#ifndef _XS_HEX_H
4 4
diff --git a/xs_html.h b/xs_html.h
index a95d45a..8331f93 100644
--- a/xs_html.h
+++ b/xs_html.h
@@ -1,4 +1,4 @@
1/* copyright (c) 2022 - 2024 grunfink et al. / MIT license */ 1/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */
2 2
3#ifndef _XS_HTML_H 3#ifndef _XS_HTML_H
4 4
diff --git a/xs_httpd.h b/xs_httpd.h
index 860ae05..cf0d811 100644
--- a/xs_httpd.h
+++ b/xs_httpd.h
@@ -1,4 +1,4 @@
1/* copyright (c) 2022 - 2024 grunfink et al. / MIT license */ 1/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */
2 2
3#ifndef _XS_HTTPD_H 3#ifndef _XS_HTTPD_H
4 4
diff --git a/xs_io.h b/xs_io.h
index f63ae5b..110b0eb 100644
--- a/xs_io.h
+++ b/xs_io.h
@@ -1,4 +1,4 @@
1/* copyright (c) 2022 - 2024 grunfink et al. / MIT license */ 1/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */
2 2
3#ifndef _XS_IO_H 3#ifndef _XS_IO_H
4 4
diff --git a/xs_json.h b/xs_json.h
index 69249b1..660c277 100644
--- a/xs_json.h
+++ b/xs_json.h
@@ -1,4 +1,4 @@
1/* copyright (c) 2022 - 2024 grunfink et al. / MIT license */ 1/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */
2 2
3#ifndef _XS_JSON_H 3#ifndef _XS_JSON_H
4 4
diff --git a/xs_match.h b/xs_match.h
index 668429a..0b89ac8 100644
--- a/xs_match.h
+++ b/xs_match.h
@@ -1,4 +1,4 @@
1/* copyright (c) 2022 - 2024 grunfink et al. / MIT license */ 1/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */
2 2
3#ifndef _XS_MATCH_H 3#ifndef _XS_MATCH_H
4 4
diff --git a/xs_mime.h b/xs_mime.h
index 5c0a6fd..6f65382 100644
--- a/xs_mime.h
+++ b/xs_mime.h
@@ -1,4 +1,4 @@
1/* copyright (c) 2022 - 2024 grunfink et al. / MIT license */ 1/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */
2 2
3#ifndef _XS_MIME_H 3#ifndef _XS_MIME_H
4 4
diff --git a/xs_openssl.h b/xs_openssl.h
index f339ac6..9388691 100644
--- a/xs_openssl.h
+++ b/xs_openssl.h
@@ -1,4 +1,4 @@
1/* copyright (c) 2022 - 2024 grunfink et al. / MIT license */ 1/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */
2 2
3#ifndef _XS_OPENSSL_H 3#ifndef _XS_OPENSSL_H
4 4
diff --git a/xs_random.h b/xs_random.h
index 78de32a..f936099 100644
--- a/xs_random.h
+++ b/xs_random.h
@@ -1,4 +1,4 @@
1/* copyright (c) 2022 - 2024 grunfink et al. / MIT license */ 1/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */
2 2
3#ifndef _XS_RANDOM_H 3#ifndef _XS_RANDOM_H
4 4
diff --git a/xs_regex.h b/xs_regex.h
index 3ba504b..a4db447 100644
--- a/xs_regex.h
+++ b/xs_regex.h
@@ -1,4 +1,4 @@
1/* copyright (c) 2022 - 2024 grunfink et al. / MIT license */ 1/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */
2 2
3#ifndef _XS_REGEX_H 3#ifndef _XS_REGEX_H
4 4
diff --git a/xs_set.h b/xs_set.h
index f6b65aa..3eaefdf 100644
--- a/xs_set.h
+++ b/xs_set.h
@@ -1,4 +1,4 @@
1/* copyright (c) 2022 - 2024 grunfink et al. / MIT license */ 1/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */
2 2
3#ifndef _XS_SET_H 3#ifndef _XS_SET_H
4 4
diff --git a/xs_socket.h b/xs_socket.h
index fb67b9d..6e618ba 100644
--- a/xs_socket.h
+++ b/xs_socket.h
@@ -1,4 +1,4 @@
1/* copyright (c) 2022 - 2024 grunfink et al. / MIT license */ 1/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */
2 2
3#ifndef _XS_SOCKET_H 3#ifndef _XS_SOCKET_H
4 4
diff --git a/xs_time.h b/xs_time.h
index 1df2fef..0e004dc 100644
--- a/xs_time.h
+++ b/xs_time.h
@@ -1,4 +1,4 @@
1/* copyright (c) 2022 - 2024 grunfink et al. / MIT license */ 1/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */
2 2
3#ifndef _XS_TIME_H 3#ifndef _XS_TIME_H
4 4
diff --git a/xs_unicode.h b/xs_unicode.h
index cfcd8ff..ef18fea 100644
--- a/xs_unicode.h
+++ b/xs_unicode.h
@@ -1,4 +1,4 @@
1/* copyright (c) 2022 - 2024 grunfink et al. / MIT license */ 1/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */
2 2
3#ifndef _XS_UNICODE_H 3#ifndef _XS_UNICODE_H
4 4
diff --git a/xs_unix_socket.h b/xs_unix_socket.h
index 3eb2307..462a5b3 100644
--- a/xs_unix_socket.h
+++ b/xs_unix_socket.h
@@ -1,4 +1,4 @@
1/* copyright (c) 2022 - 2024 grunfink et al. / MIT license */ 1/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */
2 2
3#ifndef _XS_UNIX_SOCKET_H 3#ifndef _XS_UNIX_SOCKET_H
4 4
diff --git a/xs_url.h b/xs_url.h
index ac43585..3c24736 100644
--- a/xs_url.h
+++ b/xs_url.h
@@ -1,10 +1,11 @@
1/* copyright (c) 2022 - 2024 grunfink et al. / MIT license */ 1/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */
2 2
3#ifndef _XS_URL_H 3#ifndef _XS_URL_H
4 4
5#define _XS_URL_H 5#define _XS_URL_H
6 6
7xs_str *xs_url_dec(const char *str); 7xs_str *xs_url_dec(const char *str);
8xs_str *xs_url_enc(const char *str);
8xs_dict *xs_url_vars(const char *str); 9xs_dict *xs_url_vars(const char *str);
9xs_dict *xs_multipart_form_data(const char *payload, int p_size, const char *header); 10xs_dict *xs_multipart_form_data(const char *payload, int p_size, const char *header);
10 11
@@ -39,6 +40,28 @@ xs_str *xs_url_dec(const char *str)
39} 40}
40 41
41 42
43xs_str *xs_url_enc(const char *str)
44/* URL-encodes a string (RFC 3986) */
45{
46 xs_str *s = xs_str_new(NULL);
47
48 while (*str) {
49 if (isalnum(*str) || strchr("-._~", *str)) {
50 s = xs_append_m(s, str, 1);
51 }
52 else {
53 char tmp[8];
54 snprintf(tmp, sizeof(tmp), "%%%02X", (unsigned char)*str);
55 s = xs_append_m(s, tmp, 3);
56 }
57
58 str++;
59 }
60
61 return s;
62}
63
64
42xs_dict *xs_url_vars(const char *str) 65xs_dict *xs_url_vars(const char *str)
43/* parse url variables */ 66/* parse url variables */
44{ 67{
diff --git a/xs_version.h b/xs_version.h
index 770366a..12f713a 100644
--- a/xs_version.h
+++ b/xs_version.h
@@ -1 +1 @@
/* 297f71e198be7819213e9122e1e78c3b963111bc 2024-11-24T18:48:42+01:00 */ /* b865e89769aedfdbc61251e94451e9d37579f52e 2025-01-12T16:17:47+01:00 */