commit f6044d3

ltning  ·  2025-01-27 18:07:00 +0000 UTC
parents 4528029, 5d26796
Merge branch 'master' into master
43 files changed,  +1020, -198
M data.c
M html.c
M http.c
M main.c
M snac.c
M snac.h
M xs.h
+1, -1
1@@ -1,6 +1,6 @@
2 MIT License
3 
4-Copyright (c) 2022 - 2024 grunfink et al. (Fediverse: @grunfink@comam.es)
5+Copyright (c) 2022 - 2025 grunfink et al. (Fediverse: @grunfink@comam.es)
6 
7 Permission 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:
8 
+2, -1
 1@@ -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 \
 2 format.o: format.c xs.h xs_regex.h xs_mime.h xs_html.h xs_json.h \
 3  xs_time.h xs_match.h snac.h http_codes.h
 4 html.o: html.c xs.h xs_io.h xs_json.h xs_regex.h xs_set.h xs_openssl.h \
 5- xs_time.h xs_mime.h xs_match.h xs_html.h xs_curl.h snac.h http_codes.h
 6+ xs_time.h xs_mime.h xs_match.h xs_html.h xs_curl.h xs_unicode.h snac.h \
 7+ http_codes.h
 8 http.o: http.c xs.h xs_io.h xs_openssl.h xs_curl.h xs_time.h xs_json.h \
 9  snac.h http_codes.h
10 httpd.o: httpd.c xs.h xs_io.h xs_json.h xs_socket.h xs_unix_socket.h \
+3, -2
 1@@ -5,7 +5,7 @@ LDFLAGS=-lrt
 2 
 3 all: snac
 4 
 5-snac: snac.o main.o data.o http.o httpd.o webfinger.o \
 6+snac: snac.o main.o sandbox.o data.o http.o httpd.o webfinger.o \
 7     activitypub.o html.o utils.o format.o upgrade.o mastoapi.o
 8 	$(CC) $(CFLAGS) -L/usr/pkg/lib *.o -lcurl -lcrypto -pthread $(LDFLAGS) -Wl,-rpath,/usr/lib -Wl,-rpath,/usr/pkg/lib -o $@
 9 
10@@ -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 \
11 format.o: format.c xs.h xs_regex.h xs_mime.h xs_html.h xs_json.h \
12  xs_time.h xs_match.h snac.h http_codes.h
13 html.o: html.c xs.h xs_io.h xs_json.h xs_regex.h xs_set.h xs_openssl.h \
14- xs_time.h xs_mime.h xs_match.h xs_html.h xs_curl.h snac.h http_codes.h
15+ xs_time.h xs_mime.h xs_match.h xs_html.h xs_curl.h xs_unicode.h snac.h \
16+ http_codes.h
17 http.o: http.c xs.h xs_io.h xs_openssl.h xs_curl.h xs_time.h xs_json.h \
18  snac.h http_codes.h
19 httpd.o: httpd.c xs.h xs_io.h xs_json.h xs_socket.h xs_unix_socket.h \
+29, -1
 1@@ -1,6 +1,34 @@
 2 # Release Notes
 3 
 4-## UNRELEASED
 5+## 2.69 "Yin/Yang of Love"
 6+
 7+Added 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.
 8+
 9+Added support for following hashtags. This is only useful if your instance is subscribed to relays (see above).
10+
11+Added 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)`).
12+
13+Some fixes to accept `Event` objects properly (like those coming from implementations like https://gancio.org/ or https://mobilizon.fr).
14+
15+Added some caching for local `Actor` objects.
16+
17+Hashtags that are not explicitly linked in a post's content are shown below it.
18+
19+Fixed broken NetBSD build (missing dependency in Makefile.NetBSD).
20+
21+The user profile can now include longitude and latitude data for your current location.
22+
23+Mastodon 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).
24+
25+Reduced 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.
26+
27+If 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.
28+
29+Fixed a bug in the generation of the top page (contributed by an-im-dugud).
30+
31+Added support for Markdown headers and underlining (contributed by an-im-dugud).
32+
33+## 2.68
34 
35 Fixed regression in link verification code (contributed by nowster).
36 
+3, -3
 1@@ -22,7 +22,7 @@ Mastoapi: implement /v1/conversations.
 2 
 3 Implement following of hashtags (this is not trivial).
 4 
 5-Track 'Event' data types standardization; how to add plan-to-attend and similar activities (more info: https://event-federation.eu/)
 6+Track '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).
 7 
 8 Implement "FEP-3b86: Activity Intents" https://codeberg.org/fediverse/fep/src/branch/main/fep/3b86/fep-3b86.md
 9 
10@@ -32,8 +32,6 @@ Integrate "Added handling for International Domain Names" PR https://codeberg.or
11 
12 Do something about Akkoma and Misskey's quoted replies (they use the `quoteUrl` field instead of `inReplyTo`).
13 
14-Add support for /authorize_interaction (whatever it is).
15-
16 Add a list of hashtags to drop.
17 
18 Take 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).
19@@ -365,3 +363,5 @@ Unfollowing lemmy groups gets rejected with an http status of 400 (it seems to w
20 CSV import/export does not work with OpenBSD security on; document it or fix it (2025-01-04T19:35:09+0100).
21 
22 Add support for /share?text=tt&website=url (whatever it is, see https://mastodonshare.com/ for details) (2025-01-06T18:43:52+0100).
23+
24+Add support for /authorize_interaction (whatever it is) (2025-01-16T14:45:28+0100).
+221, -62
  1@@ -1,5 +1,5 @@
  2 /* snac - A simple, minimalistic ActivityPub instance */
  3-/* copyright (c) 2022 - 2024 grunfink et al. / MIT license */
  4+/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */
  5 
  6 #include "xs.h"
  7 #include "xs_json.h"
  8@@ -587,6 +587,70 @@ int is_msg_from_private_user(const xs_dict *msg)
  9 }
 10 
 11 
 12+int followed_hashtag_check(snac *user, const xs_dict *msg)
 13+/* returns true if this message contains a hashtag followed by me */
 14+{
 15+    const xs_list *fw_tags = xs_dict_get(user->config, "followed_hashtags");
 16+
 17+    if (xs_is_list(fw_tags)) {
 18+        const xs_list *tags_in_msg = xs_dict_get(msg, "tag");
 19+
 20+        if (xs_is_list(tags_in_msg)) {
 21+            const xs_dict *te;
 22+
 23+            /* iterate the tags in the message */
 24+            xs_list_foreach(tags_in_msg, te) {
 25+                if (xs_is_dict(te)) {
 26+                    const char *type = xs_dict_get(te, "type");
 27+                    const char *name = xs_dict_get(te, "name");
 28+
 29+                    if (xs_is_string(type) && xs_is_string(name)) {
 30+                        if (strcmp(type, "Hashtag") == 0) {
 31+                            xs *lc_name = xs_utf8_to_lower(name);
 32+
 33+                            if (xs_list_in(fw_tags, lc_name) != -1)
 34+                                return 1;
 35+                        }
 36+                    }
 37+                }
 38+            }
 39+        }
 40+    }
 41+
 42+    return 0;
 43+}
 44+
 45+
 46+void followed_hashtag_distribute(const xs_dict *msg)
 47+/* distribute this post to all users following the included hashtags */
 48+{
 49+    const char *id = xs_dict_get(msg, "id");
 50+    const xs_list *tags_in_msg = xs_dict_get(msg, "tag");
 51+
 52+    if (!xs_is_string(id) || !xs_is_list(tags_in_msg) || xs_list_len(tags_in_msg) == 0)
 53+        return;
 54+
 55+    srv_debug(1, xs_fmt("followed_hashtag_distribute check for %s", id));
 56+
 57+    xs *users = user_list();
 58+    const char *uid;
 59+
 60+    xs_list_foreach(users, uid) {
 61+        snac user;
 62+
 63+        if (user_open(&user, uid)) {
 64+            if (followed_hashtag_check(&user, msg)) {
 65+                timeline_add(&user, id, msg);
 66+
 67+                snac_log(&user, xs_fmt("followed hashtag in %s", id));
 68+            }
 69+
 70+            user_free(&user);
 71+        }
 72+    }
 73+}
 74+
 75+
 76 int is_msg_for_me(snac *snac, const xs_dict *c_msg)
 77 /* checks if this message is for me */
 78 {
 79@@ -602,19 +666,32 @@ int is_msg_for_me(snac *snac, const xs_dict *c_msg)
 80     if (xs_match(type, "Like|Announce|EmojiReact")) {
 81         const char *object = xs_dict_get(c_msg, "object");
 82 
 83-        if (xs_type(object) == XSTYPE_DICT)
 84+        if (xs_is_dict(object))
 85             object = xs_dict_get(object, "id");
 86 
 87         /* bad object id? reject */
 88-        if (xs_type(object) != XSTYPE_STRING)
 89+        if (!xs_is_string(object))
 90             return 0;
 91 
 92         /* if it's about one of our posts, accept it */
 93         if (xs_startswith(object, snac->actor))
 94             return 2;
 95 
 96-        /* if it's by someone we don't follow, reject */
 97-        return following_check(snac, actor);
 98+        /* if it's by someone we follow, accept it */
 99+        if (following_check(snac, actor))
100+            return 1;
101+
102+        /* do we follow any hashtag? */
103+        if (xs_is_list(xs_dict_get(snac->config, "followed_hashtags"))) {
104+            xs *obj = NULL;
105+
106+            /* if the admired object contains any followed hashtag, accept it */
107+            if (valid_status(object_get(object, &obj)) &&
108+                followed_hashtag_check(snac, obj))
109+                return 7;
110+        }
111+
112+        return 0;
113     }
114 
115     /* if it's an Undo, it must be from someone related to us */
116@@ -675,7 +752,7 @@ int is_msg_for_me(snac *snac, const xs_dict *c_msg)
117 
118         if (pub_msg) {
119             /* a public message for someone we follow? (probably cc'ed) accept */
120-            if (following_check(snac, v))
121+            if (strcmp(v, public_address) != 0 && following_check(snac, v))
122                 return 5;
123         }
124         else
125@@ -708,30 +785,8 @@ int is_msg_for_me(snac *snac, const xs_dict *c_msg)
126     }
127 
128     /* does this message contain a tag we are following? */
129-    const xs_list *fw_tags = xs_dict_get(snac->config, "followed_hashtags");
130-    if (pub_msg && xs_type(fw_tags) == XSTYPE_LIST) {
131-        const xs_list *tags_in_msg = xs_dict_get(msg, "tag");
132-        if (xs_type(tags_in_msg) == XSTYPE_LIST) {
133-            const xs_dict *te;
134-
135-            /* iterate the tags in the message */
136-            xs_list_foreach(tags_in_msg, te) {
137-                if (xs_type(te) == XSTYPE_DICT) {
138-                    const char *type = xs_dict_get(te, "type");
139-                    const char *name = xs_dict_get(te, "name");
140-
141-                    if (xs_type(type) == XSTYPE_STRING && xs_type(name) == XSTYPE_STRING) {
142-                        if (strcmp(type, "Hashtag") == 0) {
143-                            xs *lc_name = xs_utf8_to_lower(name);
144-
145-                            if (xs_list_in(fw_tags, lc_name) != -1)
146-                                return 7;
147-                        }
148-                    }
149-                }
150-            }
151-        }
152-    }
153+    if (pub_msg && followed_hashtag_check(snac, msg))
154+        return 7;
155 
156     return 0;
157 }
158@@ -889,6 +944,11 @@ void notify(snac *snac, const char *type, const char *utype, const char *actor,
159         /* if it's not an admiration about something by us, done */
160         if (xs_is_null(objid) || !xs_startswith(objid, snac->actor))
161             return;
162+
163+        /* if it's an announce by our own relay, done */
164+        xs *relay_id = xs_fmt("%s/relay", srv_baseurl);
165+        if (xs_startswith(id, relay_id))
166+            return;
167     }
168 
169     /* updated poll? */
170@@ -1184,6 +1244,28 @@ xs_dict *msg_repulsion(snac *user, const char *id, const char *type)
171 }
172 
173 
174+xs_dict *msg_actor_place(snac *user, const char *label)
175+/* creates a Place object, if the user has a location defined */
176+{
177+    xs_dict *place = NULL;
178+    const char *latitude = xs_dict_get_def(user->config, "latitude", "");
179+    const char *longitude = xs_dict_get_def(user->config, "longitude", "");
180+
181+    if (*latitude && *longitude) {
182+        xs *d_la = xs_number_new(atof(latitude));
183+        xs *d_lo = xs_number_new(atof(longitude));
184+
185+        place = msg_base(user, "Place", NULL, user->actor, NULL, NULL);
186+
187+        place = xs_dict_set(place, "name", label);
188+        place = xs_dict_set(place, "latitude", d_la);
189+        place = xs_dict_set(place, "longitude", d_lo);
190+    }
191+
192+    return place;
193+}
194+
195+
196 xs_dict *msg_actor(snac *snac)
197 /* create a Person message for this actor */
198 {
199@@ -1194,10 +1276,20 @@ xs_dict *msg_actor(snac *snac)
200     xs *avtr     = NULL;
201     xs *kid      = NULL;
202     xs *f_bio    = NULL;
203-    xs_dict *msg = msg_base(snac, "Person", snac->actor, NULL, NULL, NULL);
204+    xs_dict *msg = NULL;
205     const char *p;
206     int n;
207 
208+    /* everybody loves some caching */
209+    if (time(NULL) - object_mtime(snac->actor) < 3 * 3600 &&
210+        valid_status(object_get(snac->actor, &msg))) {
211+        snac_debug(snac, 2, xs_fmt("Returning cached actor %s", snac->actor));
212+
213+        return msg;
214+    }
215+
216+    msg = msg_base(snac, "Person", snac->actor, NULL, NULL, NULL);
217+
218     /* change the @context (is this really necessary?) */
219     ctxt = xs_list_append(ctxt, "https:/" "/www.w3.org/ns/activitystreams");
220     ctxt = xs_list_append(ctxt, "https:/" "/w3id.org/security/v1");
221@@ -1242,6 +1334,10 @@ xs_dict *msg_actor(snac *snac)
222     if (xs_type(xs_dict_get(snac->config, "bot")) == XSTYPE_TRUE)
223         msg = xs_dict_set(msg, "type", "Service");
224 
225+    /* if it's named "relay", then identify as an "Application" */
226+    if (strcmp(snac->uid, "relay") == 0)
227+        msg = xs_dict_set(msg, "type", "Application");
228+
229     /* add the header image, if there is one defined */
230     const char *header = xs_dict_get(snac->config, "header");
231     if (!xs_is_null(header)) {
232@@ -1307,7 +1403,7 @@ xs_dict *msg_actor(snac *snac)
233     }
234 
235     /* use shared inboxes? */
236-    if (xs_type(xs_dict_get(srv_config, "shared_inboxes")) == XSTYPE_TRUE) {
237+    if (xs_is_true(xs_dict_get(srv_config, "shared_inboxes")) || strcmp(snac->uid, "relay") == 0) {
238         xs *d = xs_dict_new();
239         xs *si = xs_fmt("%s/shared-inbox", srv_baseurl);
240         d = xs_dict_append(d, "sharedInbox", si);
241@@ -1326,6 +1422,15 @@ xs_dict *msg_actor(snac *snac)
242     msg = xs_dict_set(msg, "manuallyApprovesFollowers",
243         xs_stock(xs_is_true(manually) ? XSTYPE_TRUE : XSTYPE_FALSE));
244 
245+    /* if there are location coords, create a Place object */
246+    xs *location = msg_actor_place(snac, "Home");
247+    if (xs_type(location) == XSTYPE_DICT)
248+        msg = xs_dict_set(msg, "location", location);
249+
250+    /* cache it */
251+    snac_debug(snac, 1, xs_fmt("Caching actor %s", snac->actor));
252+    object_add_ow(snac->actor, msg);
253+
254     return msg;
255 }
256 
257@@ -1423,8 +1528,9 @@ xs_dict *msg_follow(snac *snac, const char *q)
258 
259 xs_dict *msg_note(snac *snac, const xs_str *content, const xs_val *rcpts,
260                   const xs_str *in_reply_to, const xs_list *attach,
261-                  int priv, const char *lang_str)
262+                  int scope, const char *lang_str)
263 /* creates a 'Note' message */
264+/* scope: 0, public; 1, private (mentioned only); 2, "quiet public"; 3, followers only */
265 {
266     xs *ntid = tid(0);
267     xs *id   = xs_fmt("%s/p/%s", snac->actor, ntid);
268@@ -1440,6 +1546,9 @@ xs_dict *msg_note(snac *snac, const xs_str *content, const xs_val *rcpts,
269     xs_list *p;
270     const xs_val *v;
271 
272+    /* FIXME: implement scope 3 */
273+    int priv = scope == 1;
274+
275     if (rcpts == NULL)
276         to = xs_list_new();
277     else {
278@@ -1557,6 +1666,12 @@ xs_dict *msg_note(snac *snac, const xs_str *content, const xs_val *rcpts,
279         }
280     }
281 
282+    if (scope == 2) {
283+        /* Mastodon's "quiet public": add public address to cc */
284+        if (xs_list_in(cc, public_address) == -1)
285+            cc = xs_list_append(cc, public_address);
286+    }
287+    else
288     /* no recipients? must be for everybody */
289     if (!priv && xs_list_len(to) == 0)
290         to = xs_list_append(to, public_address);
291@@ -1845,6 +1960,17 @@ int process_input_message(snac *snac, const xs_dict *msg, const xs_dict *req)
292     /* reject uninteresting messages right now */
293     if (xs_match(type, "Add|View|Reject|Read|Remove")) {
294         srv_debug(0, xs_fmt("Ignored message of type '%s'", type));
295+
296+        /* archive the ignored activity */
297+        xs *ntid = tid(0);
298+        xs *fn = xs_fmt("%s/ignored/%s.json", srv_basedir, ntid);
299+        FILE *f;
300+
301+        if ((f = fopen(fn, "w")) != NULL) {
302+            xs_json_dump(msg, 4, f);
303+            fclose(f);
304+        }
305+
306         return -1;
307     }
308 
309@@ -2118,14 +2244,14 @@ int process_input_message(snac *snac, const xs_dict *msg, const xs_dict *req)
310                 snac_log(snac, xs_fmt("new 'Question' %s %s", actor, id));
311         }
312         else
313-        if (strcmp(utype, "Video") == 0) { /** **/
314+        if (xs_match(utype, "Audio|Video|Event")) { /** **/
315             const char *id = xs_dict_get(object, "id");
316 
317             if (xs_is_null(id))
318                 snac_log(snac, xs_fmt("malformed message: no 'id' field"));
319             else
320             if (timeline_add(snac, id, object))
321-                snac_log(snac, xs_fmt("new 'Video' %s %s", actor, id));
322+                snac_log(snac, xs_fmt("new '%s' %s %s", utype, actor, id));
323         }
324         else
325             snac_debug(snac, 1, xs_fmt("ignored 'Create' for object type '%s'", utype));
326@@ -2203,15 +2329,23 @@ int process_input_message(snac *snac, const xs_dict *msg, const xs_dict *req)
327                     xs *who_o = NULL;
328 
329                     if (valid_status(actor_request(snac, who, &who_o))) {
330-                        if (timeline_admire(snac, object, actor, 0) == HTTP_STATUS_CREATED)
331-                            snac_log(snac, xs_fmt("new 'Announce' %s %s", actor, object));
332-                        else
333-                            snac_log(snac, xs_fmt("repeated 'Announce' from %s to %s",
334-                                actor, object));
335+                        /* don't account as such announces by our own relay */
336+                        xs *this_relay = xs_fmt("%s/relay", srv_baseurl);
337+
338+                        if (strcmp(actor, this_relay) != 0) {
339+                            if (timeline_admire(snac, object, actor, 0) == HTTP_STATUS_CREATED)
340+                                snac_log(snac, xs_fmt("new 'Announce' %s %s", actor, object));
341+                            else
342+                                snac_log(snac, xs_fmt("repeated 'Announce' from %s to %s",
343+                                    actor, object));
344+                        }
345 
346                         /* distribute the post with the actor as 'proxy' */
347                         list_distribute(snac, actor, a_msg);
348 
349+                        /* distribute the post to users following these hashtags */
350+                        followed_hashtag_distribute(a_msg);
351+
352                         do_notify = 1;
353                     }
354                     else
355@@ -2226,14 +2360,14 @@ int process_input_message(snac *snac, const xs_dict *msg, const xs_dict *req)
356     }
357     else
358     if (strcmp(type, "Update") == 0) { /** **/
359-        if (xs_match(utype, "Person|Service")) { /** **/
360+        if (xs_match(utype, "Person|Service|Application")) { /** **/
361             actor_add(actor, xs_dict_get(msg, "object"));
362             timeline_touch(snac);
363 
364             snac_log(snac, xs_fmt("updated actor %s", actor));
365         }
366         else
367-        if (xs_match(utype, "Note|Page|Article|Video")) { /** **/
368+        if (xs_match(utype, "Note|Page|Article|Video|Audio|Event")) { /** **/
369             const char *id = xs_dict_get(object, "id");
370 
371             if (xs_is_null(id))
372@@ -2419,7 +2553,7 @@ int send_email(const char *msg)
373 }
374 
375 
376-void process_user_queue_item(snac *snac, xs_dict *q_item)
377+void process_user_queue_item(snac *user, xs_dict *q_item)
378 /* processes an item from the user queue */
379 {
380     const char *type;
381@@ -2430,7 +2564,7 @@ void process_user_queue_item(snac *snac, xs_dict *q_item)
382 
383     if (strcmp(type, "message") == 0) {
384         const xs_dict *msg = xs_dict_get(q_item, "message");
385-        xs *rcpts = recipient_list(snac, msg, 1);
386+        xs *rcpts = recipient_list(user, msg, 1);
387         xs_set inboxes;
388         const xs_str *actor;
389 
390@@ -2439,7 +2573,7 @@ void process_user_queue_item(snac *snac, xs_dict *q_item)
391         /* add this shared inbox first */
392         xs *this_shared_inbox = xs_fmt("%s/shared-inbox", srv_baseurl);
393         xs_set_add(&inboxes, this_shared_inbox);
394-        enqueue_output(snac, msg, this_shared_inbox, 0, 0);
395+        enqueue_output(user, msg, this_shared_inbox, 0, 0);
396 
397         /* iterate the recipients */
398         xs_list_foreach(rcpts, actor) {
399@@ -2450,10 +2584,10 @@ void process_user_queue_item(snac *snac, xs_dict *q_item)
400                 if (inbox != NULL) {
401                     /* add to the set and, if it's not there, send message */
402                     if (xs_set_add(&inboxes, inbox) == 1)
403-                        enqueue_output(snac, msg, inbox, 0, 0);
404+                        enqueue_output(user, msg, inbox, 0, 0);
405                 }
406                 else
407-                    snac_log(snac, xs_fmt("cannot find inbox for %s", actor));
408+                    snac_log(user, xs_fmt("cannot find inbox for %s", actor));
409             }
410         }
411 
412@@ -2465,12 +2599,36 @@ void process_user_queue_item(snac *snac, xs_dict *q_item)
413 
414                 xs_list_foreach(shibx, inbox) {
415                     if (xs_set_add(&inboxes, inbox) == 1)
416-                        enqueue_output(snac, msg, inbox, 0, 0);
417+                        enqueue_output(user, msg, inbox, 0, 0);
418                 }
419             }
420         }
421 
422         xs_set_free(&inboxes);
423+
424+        /* relay this note */
425+        if (is_msg_public(msg) && strcmp(user->uid, "relay") != 0) { /* avoid loops */
426+            snac relay;
427+            if (user_open(&relay, "relay")) {
428+                /* a 'relay' user exists */
429+                const char *type = xs_dict_get(msg, "type");
430+
431+                if (xs_is_string(type) && strcmp(type, "Create") == 0) {
432+                    const xs_val *object = xs_dict_get(msg, "object");
433+
434+                    if (xs_is_dict(object)) {
435+                        object = xs_dict_get(object, "id");
436+
437+                        snac_debug(&relay, 1, xs_fmt("relaying message %s", object));
438+
439+                        xs *boost = msg_admiration(&relay, object, "Announce");
440+                        enqueue_message(&relay, boost);
441+                    }
442+                }
443+
444+                user_free(&relay);
445+            }
446+        }
447     }
448     else
449     if (strcmp(type, "input") == 0) {
450@@ -2482,13 +2640,13 @@ void process_user_queue_item(snac *snac, xs_dict *q_item)
451         if (xs_is_null(msg))
452             return;
453 
454-        if (!process_input_message(snac, msg, req)) {
455+        if (!process_input_message(user, msg, req)) {
456             if (retries > queue_retry_max)
457-                snac_log(snac, xs_fmt("input giving up"));
458+                snac_log(user, xs_fmt("input giving up"));
459             else {
460                 /* reenqueue */
461-                enqueue_input(snac, msg, req, retries + 1);
462-                snac_log(snac, xs_fmt("input requeue #%d", retries + 1));
463+                enqueue_input(user, msg, req, retries + 1);
464+                snac_log(user, xs_fmt("input requeue #%d", retries + 1));
465             }
466         }
467     }
468@@ -2498,7 +2656,7 @@ void process_user_queue_item(snac *snac, xs_dict *q_item)
469         const char *id = xs_dict_get(q_item, "message");
470 
471         if (!xs_is_null(id))
472-            update_question(snac, id);
473+            update_question(user, id);
474     }
475     else
476     if (strcmp(type, "object_request") == 0) {
477@@ -2508,17 +2666,17 @@ void process_user_queue_item(snac *snac, xs_dict *q_item)
478             int status;
479             xs *data = NULL;
480 
481-            status = activitypub_request(snac, id, &data);
482+            status = activitypub_request(user, id, &data);
483 
484             if (valid_status(status))
485                 object_add_ow(id, data);
486 
487-            snac_debug(snac, 1, xs_fmt("object_request %s %d", id, status));
488+            snac_debug(user, 1, xs_fmt("object_request %s %d", id, status));
489         }
490     }
491     else
492     if (strcmp(type, "verify_links") == 0) {
493-        verify_links(snac);
494+        verify_links(user);
495     }
496     else
497     if (strcmp(type, "actor_refresh") == 0) {
498@@ -2530,16 +2688,16 @@ void process_user_queue_item(snac *snac, xs_dict *q_item)
499             xs *actor_o = NULL;
500             int status;
501 
502-            if (valid_status((status = activitypub_request(snac, actor, &actor_o))))
503+            if (valid_status((status = activitypub_request(user, actor, &actor_o))))
504                 actor_add(actor, actor_o);
505             else
506                 object_touch(actor);
507 
508-            snac_log(snac, xs_fmt("actor_refresh %s %d", actor, status));
509+            snac_log(user, xs_fmt("actor_refresh %s %d", actor, status));
510         }
511     }
512     else
513-        snac_log(snac, xs_fmt("unexpected user q_item type '%s'", type));
514+        snac_log(user, xs_fmt("unexpected user q_item type '%s'", type));
515 }
516 
517 
518@@ -2640,7 +2798,7 @@ void process_queue_item(xs_dict *q_item)
519                 || status == HTTP_STATUS_UNPROCESSABLE_CONTENT
520                 || status < 0)
521                 /* explicit error: discard */
522-                srv_log(xs_fmt("output message: fatal error %s %d", inbox, status));
523+                srv_log(xs_fmt("output message: error %s %d", inbox, status));
524             else
525             if (retries > queue_retry_max)
526                 srv_log(xs_fmt("output message: giving up %s %d", inbox, status));
527@@ -2769,11 +2927,12 @@ void process_queue_item(xs_dict *q_item)
528                 snac user;
529 
530                 if (user_open(&user, v)) {
531-                    if (is_msg_for_me(&user, msg)) {
532+                    int rsn = is_msg_for_me(&user, msg);
533+                    if (rsn) {
534                         xs *fn = xs_fmt("%s/queue/%s.json", user.basedir, ntid);
535 
536                         snac_debug(&user, 1,
537-                            xs_fmt("enqueue_input (from shared inbox) %s", xs_dict_get(msg, "id")));
538+                            xs_fmt("enqueue_input (from shared inbox) %s [%d]", xs_dict_get(msg, "id"), rsn));
539 
540                         if (link(tmpfn, fn) < 0)
541                             srv_log(xs_fmt("link(%s, %s) error", tmpfn, fn));
M data.c
+55, -8
  1@@ -1,5 +1,5 @@
  2 /* snac - A simple, minimalistic ActivityPub instance */
  3-/* copyright (c) 2022 - 2024 grunfink et al. / MIT license */
  4+/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */
  5 
  6 #include "xs.h"
  7 #include "xs_hex.h"
  8@@ -319,7 +319,8 @@ int user_persist(snac *snac, int publish)
  9 
 10             if (old != NULL) {
 11                 int nw = 0;
 12-                const char *fields[] = { "header", "avatar", "name", "bio", "metadata", NULL };
 13+                const char *fields[] = { "header", "avatar", "name", "bio",
 14+                                         "metadata", "latitude", "longitude", NULL };
 15 
 16                 for (int n = 0; fields[n]; n++) {
 17                     const char *of = xs_dict_get(old, fields[n]);
 18@@ -336,6 +337,10 @@ int user_persist(snac *snac, int publish)
 19 
 20                 if (!nw)
 21                     publish = 0;
 22+                else {
 23+                    /* uncache the actor object */
 24+                    object_del(snac->actor);
 25+                }
 26             }
 27         }
 28     }
 29@@ -674,6 +679,37 @@ int index_desc_first(FILE *f, char md5[MD5_HEX_SIZE], int skip)
 30     return 1;
 31 }
 32 
 33+int index_asc_first(FILE *f,char md5[MD5_HEX_SIZE], const char *seek_md5)
 34+/* reads the first entry of an ascending index, starting from a given md5 */
 35+{
 36+    fseek(f, SEEK_SET, 0);
 37+    while (fread(md5, MD5_HEX_SIZE, 1, f)) {
 38+        md5[MD5_HEX_SIZE - 1] = '\0';
 39+        if (strcmp(md5,seek_md5) == 0) {
 40+            return index_asc_next(f, md5);
 41+        }
 42+    }
 43+    return 0;
 44+}
 45+
 46+int index_asc_next(FILE *f, char md5[MD5_HEX_SIZE])
 47+/* reads the next entry of an ascending index */
 48+{
 49+    for (;;) {
 50+        /* read an md5 */
 51+        if (!fread(md5, MD5_HEX_SIZE, 1, f))
 52+            return 0;
 53+
 54+        /* deleted, skip */
 55+        if (md5[0] != '-')
 56+            break;
 57+    }
 58+
 59+    md5[MD5_HEX_SIZE - 1] = '\0';
 60+
 61+    return 1;
 62+}
 63+
 64 
 65 xs_list *index_list_desc(const char *fn, int skip, int show)
 66 /* returns an index as a list, in reverse order */
 67@@ -1363,11 +1399,13 @@ void timeline_update_indexes(snac *snac, const char *id)
 68         if (valid_status(object_get(id, &msg))) {
 69             /* if its ours and is public, also store in public */
 70             if (is_msg_public(msg)) {
 71-                object_user_cache_add(snac, id, "public");
 72-
 73-                /* also add it to the instance public timeline */
 74-                xs *ipt = xs_fmt("%s/public.idx", srv_basedir);
 75-                index_add(ipt, id);
 76+                if (object_user_cache_add(snac, id, "public") >= 0) {
 77+                    /* also add it to the instance public timeline */
 78+                    xs *ipt = xs_fmt("%s/public.idx", srv_basedir);
 79+                    index_add(ipt, id);
 80+                }
 81+                else
 82+                    srv_debug(1, xs_fmt("Not added to public instance index %s", id));
 83             }
 84         }
 85     }
 86@@ -1488,8 +1526,17 @@ xs_list *timeline_instance_list(int skip, int show)
 87 /* returns the timeline for the full instance */
 88 {
 89     xs *idx = instance_index_fn();
 90+    xs *lst = index_list_desc(idx, skip, show);
 91 
 92-    return index_list_desc(idx, skip, show);
 93+    /* make the list unique */
 94+    xs_set rep;
 95+    xs_set_init(&rep);
 96+    const char *md5;
 97+
 98+    xs_list_foreach(lst, md5)
 99+        xs_set_add(&rep, md5);
100+
101+    return xs_set_result(&rep);
102 }
103 
104 
+8, -1
 1@@ -256,7 +256,7 @@ it's - (a lonely hyphen), the post content will be read from stdin.
 2 The rest of command line arguments are treated as media files to be
 3 attached to the post.
 4 .It Cm note_unlisted Ar basedir Ar uid Ar text Op file file ...
 5-Like the previous one, but creates an "unlisted" (or "quiet public") one.
 6+Like the previous one, but creates an "unlisted" (or "quiet public") post.
 7 .It Cm block Ar basedir Ar instance_url
 8 Blocks a full instance, given its URL or domain name. All subsequent
 9 incoming activities with identifiers from that instance will be immediately
10@@ -377,6 +377,13 @@ https://$SNAC_HOST/oauth/x-snac-get-token
11 .Pp
12 .Sh ENVIRONMENT
13 .Bl -tag -width Ds
14+.It SNAC_BASEDIR
15+This optional environment variable can be set to the base directory of
16+your installation; if set, you don't have to add the base directory as an
17+argument to command-line operations. This may prove useful if you only
18+have one
19+.Nm
20+instance in you system (which is probably your case).
21 .It Ev DEBUG
22 Overrides the debugging level from the server 'dbglevel' configuration
23 variable. Set it to an integer value. The higher, the deeper in meaningless
+6, -1
 1@@ -24,9 +24,11 @@ A special subset of Markdown is allowed, including:
 2 .It bold
 3 **text between two pairs of asterisks**
 4 .It italic
 5-*text between a pair of asterisks*
 6+*text between a pair of asterisks* or _between a pair of underscores_
 7 .It strikethrough text
 8 ~~text between a pair of tildes~~
 9+.It underlined text
10+__text between two pairs of underscores__
11 .It code
12 Text `between backticks` is formatted as code.
13 .Bd -literal
14@@ -53,6 +55,9 @@ Horizonal rules can be inserted by typing three minus symbols
15 alone in a line.
16 .It quoted text
17 Lines starting with >.
18+.It headers
19+One, two or three # at the beginning of a line plus a space plus
20+some text are converted to HTML headers.
21 .It user mentions
22 Strings in the format @user@host are requested using the Webfinger
23 protocol and converted to links and mentions if something reasonable
+40, -0
 1@@ -585,6 +585,31 @@ to pass the remote connection address in the
 2 .Ic X-Forwarded-For
 3 HTTP header (unless you use the FastCGI interface; if that's the case, you don't have
 4 to do anything).
 5+.Pp
 6+.Ss Subscribing to Fediverse Relays
 7+Since version 2.69, a
 8+.Nm
 9+instance can subscribe to LitePub (Pleroma-style) Fediverse Relays. Doing this improves
10+visibility and allows following hashtags. To do this, you must create a special user named
11+relay and, from it, follow the relay actor(s) like you do with regular actor URLs. This
12+special user will start receiving boosts from the relay server of posts from other instances
13+also following it. If any other user of the same
14+.Nm
15+instance follows any of the hashtags included in these boosted posts coming from the relay,
16+they will be received as if they were for them.
17+.Pp
18+Example:
19+.Bd -literal -offset indent
20+snac adduser $SNAC_BASEDIR relay # only needed once
21+snac follow $SNAC_BASEDIR relay https://relay.example.com/actor
22+.Ed
23+.Pp
24+Users on your instance do NOT need to follow the local relay user to benefit from following
25+hashtags.
26+.Pp
27+Please take note that subscribing to relays can increase the traffic towards your instance
28+significantly. In any case, lowering the "Maximum days to keep posts" value for the relay
29+special user is recommended (e.g. setting to just 1 day).
30 .Sh ENVIRONMENT
31 .Bl -tag -width Ds
32 .It Ev DEBUG
33@@ -685,6 +710,12 @@ location /share {
34     proxy_set_header Host $http_host;
35     proxy_set_header X-Forwarded-For $remote_addr;
36 }
37+# optional (Mastodon-like "authorize interaction" entrypoint)
38+location /authorize_interaction {
39+    proxy_pass http://localhost:8001;
40+    proxy_set_header Host $http_host;
41+    proxy_set_header X-Forwarded-For $remote_addr;
42+}
43 .Ed
44 .Pp
45 Restart the nginx daemon and connect to
46@@ -738,6 +769,11 @@ ProxyPreserveHost On
47 <Location /share>
48     ProxyPass http://127.0.0.1:8001/share
49 </Location>
50+
51+# optional (Mastodon-like "authorize interaction" entrypoint)
52+<Location /authorize_interaction>
53+    ProxyPass http://127.0.0.1:8001/share
54+</Location>
55 .Ed
56 .Pp
57 Since version 2.43,
58@@ -797,6 +833,10 @@ location "/.well-known/host-meta" {
59 location "/share" {
60     fastcgi socket tcp "127.0.0.1" 8001
61 }
62+
63+location "/authorize_interaction" {
64+    fastcgi socket tcp "127.0.0.1" 8001
65+}
66 .Ed
67 .Sh SEE ALSO
68 .Xr snac 1 ,
+47, -2
 1@@ -1,5 +1,5 @@
 2 /* snac - A simple, minimalistic ActivityPub instance */
 3-/* copyright (c) 2022 - 2024 grunfink et al. / MIT license */
 4+/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */
 5 
 6 #include "xs.h"
 7 #include "xs_regex.h"
 8@@ -92,6 +92,8 @@ static xs_str *format_line(const char *line, xs_list **attach)
 9             "`[^`]+`"                           "|"
10             "~~[^~]+~~"                         "|"
11             "\\*\\*?\\*?[^\\*]+\\*?\\*?\\*"     "|"
12+            "_[^_]+_"                           "|" //anzu
13+            "__[^_]+__"                         "|" //anzu
14             "!\\[[^]]+\\]\\([^\\)]+\\)"         "|"
15             "\\[[^]]+\\]\\([^\\)]+\\)"          "|"
16             "[a-z]+:/" "/[^[:space:]]+"         "|"
17@@ -127,6 +129,20 @@ static xs_str *format_line(const char *line, xs_list **attach)
18                 xs *s2 = xs_fmt("<i>%s</i>", s1);
19                 s = xs_str_cat(s, s2);
20             }
21+            //anzu - begin
22+            else
23+            if (xs_startswith(v, "__")) {
24+                xs *s1 = xs_strip_chars_i(xs_dup(v), "_");
25+                xs *s2 = xs_fmt("<u>%s</u>", s1);
26+                s = xs_str_cat(s, s2);
27+            }
28+            else
29+            if (xs_startswith(v, "_")) {
30+                xs *s1 = xs_strip_chars_i(xs_dup(v), "_");
31+                xs *s2 = xs_fmt("<i>%s</i>", s1);
32+                s = xs_str_cat(s, s2);
33+            }
34+            //anzu - end
35             else
36             if (xs_startswith(v, "~~")) {
37                 xs *s1 = xs_strip_chars_i(xs_dup(v), "~");
38@@ -303,6 +319,31 @@ xs_str *not_really_markdown(const char *content, xs_list **attach, xs_list **tag
39             continue;
40         }
41 
42+        //anzu - begin
43+        // h1 reserved for snac?
44+        if (xs_startswith(ss, "# ")) {
45+            ss = xs_strip_i(xs_crop_i(ss, 2, 0));
46+            s = xs_str_cat(s, "<h2>");
47+            s = xs_str_cat(s, ss);
48+            s = xs_str_cat(s, "</h2>");
49+            continue;
50+        }
51+        if (xs_startswith(ss, "## ")) {
52+            ss = xs_strip_i(xs_crop_i(ss, 3, 0));
53+            s = xs_str_cat(s, "<h2>");
54+            s = xs_str_cat(s, ss);
55+            s = xs_str_cat(s, "</h2>");
56+            continue;
57+        }
58+        if (xs_startswith(ss, "### ")) {
59+            ss = xs_strip_i(xs_crop_i(ss, 4, 0));
60+            s = xs_str_cat(s, "<h3>");
61+            s = xs_str_cat(s, ss);
62+            s = xs_str_cat(s, "</h3>");
63+            continue;
64+        }
65+        //anzu - end
66+
67         if (xs_startswith(ss, ">")) {
68             /* delete the > and subsequent spaces */
69             ss = xs_strip_i(xs_crop_i(ss, 1, 0));
70@@ -336,6 +377,8 @@ xs_str *not_really_markdown(const char *content, xs_list **attach, xs_list **tag
71     s = xs_replace_i(s, "<br><br><blockquote>", "<br><blockquote>");
72     s = xs_replace_i(s, "</blockquote><br>", "</blockquote>");
73     s = xs_replace_i(s, "</pre><br>", "</pre>");
74+    s = xs_replace_i(s, "</h2><br>", "</h2>"); //anzu ???
75+    s = xs_replace_i(s, "</h3><br>", "</h3>"); //anzu ???
76 
77     {
78         /* traditional emoticons */
79@@ -378,7 +421,9 @@ xs_str *not_really_markdown(const char *content, xs_list **attach, xs_list **tag
80 
81 const char *valid_tags[] = {
82     "a", "p", "br", "br/", "blockquote", "ul", "ol", "li", "cite", "small",
83-    "span", "i", "b", "u", "s", "pre", "code", "em", "strong", "hr", "img", "del", "bdi", NULL
84+    "span", "i", "b", "u", "s", "pre", "code", "em", "strong", "hr", "img", "del", "bdi",
85+    "h2","h3", //anzu
86+    NULL
87 };
88 
89 xs_str *sanitize(const char *content)
M html.c
+374, -19
  1@@ -1,5 +1,5 @@
  2 /* snac - A simple, minimalistic ActivityPub instance */
  3-/* copyright (c) 2022 - 2024 grunfink et al. / MIT license */
  4+/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */
  5 
  6 #include "xs.h"
  7 #include "xs_io.h"
  8@@ -12,6 +12,7 @@
  9 #include "xs_match.h"
 10 #include "xs_html.h"
 11 #include "xs_curl.h"
 12+#include "xs_unicode.h"
 13 
 14 #include "snac.h"
 15 
 16@@ -113,7 +114,8 @@ xs_str *actor_name(xs_dict *actor, const char *proxy)
 17 
 18 
 19 xs_html *html_actor_icon(snac *user, xs_dict *actor, const char *date,
 20-                        const char *udate, const char *url, int priv, int in_people, const char *proxy)
 21+                        const char *udate, const char *url, int priv,
 22+                        int in_people, const char *proxy, const char *lang)
 23 {
 24     xs_html *actor_icon = xs_html_tag("p", NULL);
 25 
 26@@ -219,6 +221,9 @@ xs_html *html_actor_icon(snac *user, xs_dict *actor, const char *date,
 27             date_title = xs_str_cat(date_title, " / ", udate);
 28         }
 29 
 30+        if (xs_is_string(lang))
 31+            date_title = xs_str_cat(date_title, " (", lang, ")");
 32+
 33         xs_html_add(actor_icon,
 34             xs_html_text(" "),
 35             xs_html_tag("time",
 36@@ -265,6 +270,7 @@ xs_html *html_msg_icon(snac *user, const char *actor_id, const xs_dict *msg, con
 37         const char *date  = NULL;
 38         const char *udate = NULL;
 39         const char *url   = NULL;
 40+        const char *lang  = NULL;
 41         int priv    = 0;
 42         const char *type = xs_dict_get(msg, "type");
 43 
 44@@ -276,7 +282,17 @@ xs_html *html_msg_icon(snac *user, const char *actor_id, const xs_dict *msg, con
 45         date  = xs_dict_get(msg, "published");
 46         udate = xs_dict_get(msg, "updated");
 47 
 48-        actor_icon = html_actor_icon(user, actor, date, udate, url, priv, 0, proxy);
 49+        lang = xs_dict_get(msg, "contentMap");
 50+        if (xs_is_dict(lang)) {
 51+            const char *v;
 52+            int c = 0;
 53+
 54+            xs_dict_next(lang, &lang, &v, &c);
 55+        }
 56+        else
 57+            lang = NULL;
 58+
 59+        actor_icon = html_actor_icon(user, actor, date, udate, url, priv, 0, proxy, lang);
 60     }
 61 
 62     return actor_icon;
 63@@ -933,6 +949,7 @@ static xs_html *html_user_body(snac *user, int read_only)
 64                             xs_html_raw("&#10004; "),
 65                             xs_html_tag("a",
 66                                 xs_html_attr("rel", "me"),
 67+                                xs_html_attr("target", "_blank"),
 68                                 xs_html_attr("href", v),
 69                                 xs_html_text(v)));
 70                     }
 71@@ -969,6 +986,23 @@ static xs_html *html_user_body(snac *user, int read_only)
 72                 snac_metadata);
 73         }
 74 
 75+        const char *latitude = xs_dict_get_def(user->config, "latitude", "");
 76+        const char *longitude = xs_dict_get_def(user->config, "longitude", "");
 77+
 78+        if (*latitude && *longitude) {
 79+            xs *label = xs_fmt(L("%s,%s"), latitude, longitude);
 80+            xs *url   = xs_fmt(L("https://openstreetmap.org/search?query=%s,%s"),
 81+                        latitude, longitude);
 82+
 83+            xs_html_add(top_user,
 84+                xs_html_tag("p",
 85+                    xs_html_text(L("Location: ")),
 86+                    xs_html_tag("a",
 87+                        xs_html_attr("href", url),
 88+                        xs_html_attr("target", "_blank"),
 89+                        xs_html_text(label))));
 90+        }
 91+
 92         if (xs_is_true(xs_dict_get(user->config, "show_contact_metrics"))) {
 93             xs *fwers = follower_list(user);
 94             xs *fwing = following_list(user);
 95@@ -1110,6 +1144,8 @@ xs_html *html_top_controls(snac *snac)
 96     const xs_val *coll_thrds = xs_dict_get(snac->config, "collapse_threads");
 97     const xs_val *pending    = xs_dict_get(snac->config, "approve_followers");
 98     const xs_val *show_foll  = xs_dict_get(snac->config, "show_contact_metrics");
 99+    const char *latitude     = xs_dict_get_def(snac->config, "latitude", "");
100+    const char *longitude    = xs_dict_get_def(snac->config, "longitude", "");
101 
102     xs *metadata = NULL;
103     const xs_dict *md = xs_dict_get(snac->config, "metadata");
104@@ -1299,6 +1335,20 @@ xs_html *html_top_controls(snac *snac)
105                     xs_html_tag("label",
106                         xs_html_attr("for", "show_contact_metrics"),
107                         xs_html_text(L("Publish follower and following metrics")))),
108+                xs_html_tag("p",
109+                    xs_html_text(L("Current location:")),
110+                    xs_html_sctag("br", NULL),
111+                    xs_html_sctag("input",
112+                        xs_html_attr("type", "text"),
113+                        xs_html_attr("name", "latitude"),
114+                        xs_html_attr("value", latitude),
115+                        xs_html_attr("placeholder", "latitude")),
116+                    xs_html_text(" "),
117+                    xs_html_sctag("input",
118+                        xs_html_attr("type", "text"),
119+                        xs_html_attr("name", "longitude"),
120+                        xs_html_attr("value", longitude),
121+                        xs_html_attr("placeholder", "longitude"))),
122                 xs_html_tag("p",
123                     xs_html_text(L("Profile metadata (key=value pairs in each line):")),
124                     xs_html_sctag("br", NULL),
125@@ -1328,7 +1378,41 @@ xs_html *html_top_controls(snac *snac)
126                 xs_html_sctag("input",
127                     xs_html_attr("type", "submit"),
128                     xs_html_attr("class", "button"),
129-                    xs_html_attr("value", L("Update user info")))))));
130+                    xs_html_attr("value", L("Update user info"))),
131+
132+                xs_html_tag("p", NULL)))));
133+
134+    xs *followed_hashtags_action = xs_fmt("%s/admin/followed-hashtags", snac->actor);
135+    xs *followed_hashtags = xs_join(xs_dict_get_def(snac->config,
136+                        "followed_hashtags", xs_stock(XSTYPE_LIST)), "\n");
137+
138+    xs_html_add(top_controls,
139+        xs_html_tag("details",
140+        xs_html_tag("summary",
141+            xs_html_text(L("Followed hashtags..."))),
142+        xs_html_tag("p",
143+            xs_html_text(L("One hashtag per line"))),
144+        xs_html_tag("div",
145+            xs_html_attr("class", "snac-followed-hashtags"),
146+            xs_html_tag("form",
147+                xs_html_attr("autocomplete", "off"),
148+                xs_html_attr("method", "post"),
149+                xs_html_attr("action", followed_hashtags_action),
150+                xs_html_attr("enctype", "multipart/form-data"),
151+
152+                xs_html_tag("textarea",
153+                    xs_html_attr("name", "followed_hashtags"),
154+                    xs_html_attr("cols", "40"),
155+                    xs_html_attr("rows", "4"),
156+                    xs_html_attr("placeholder", "#cats\n#windowfriday\n#classicalmusic"),
157+                    xs_html_text(followed_hashtags)),
158+
159+                xs_html_tag("br", NULL),
160+
161+                xs_html_sctag("input",
162+                    xs_html_attr("type", "submit"),
163+                    xs_html_attr("class", "button"),
164+                    xs_html_attr("value", L("Update hashtags")))))));
165 
166     return top_controls;
167 }
168@@ -1781,13 +1865,15 @@ xs_html *html_entry(snac *user, xs_dict *msg, int read_only,
169             }
170         }
171     }
172-    else
173-    if (strcmp(type, "Note") == 0) {
174-        if (level == 0) {
175-            /* is the parent not here? */
176-            const char *parent = get_in_reply_to(msg);
177 
178-            if (user && !xs_is_null(parent) && *parent && !timeline_here(user, parent)) {
179+    if (user && strcmp(type, "Note") == 0) {
180+        /* is the parent not here? */
181+        const char *parent = get_in_reply_to(msg);
182+
183+        if (!xs_is_null(parent) && *parent) {
184+            xs *md5 = xs_md5_hex(parent, strlen(parent));
185+
186+            if (!timeline_here(user, md5)) {
187                 xs_html_add(post_header,
188                     xs_html_tag("div",
189                         xs_html_attr("class", "snac-origin"),
190@@ -2199,6 +2285,135 @@ xs_html *html_entry(snac *user, xs_dict *msg, int read_only,
191             au_tag);
192     }
193 
194+    /* does it have a location? */
195+    const xs_dict *location = xs_dict_get(msg, "location");
196+    if (xs_type(location) == XSTYPE_DICT) {
197+        const xs_number *latitude = xs_dict_get(location, "latitude");
198+        const xs_number *longitude = xs_dict_get(location, "longitude");
199+        const char *name = xs_dict_get(location, "name");
200+        const char *address = xs_dict_get(location, "address");
201+        xs *label_list = xs_list_new();
202+
203+        if (xs_type(name) == XSTYPE_STRING)
204+            label_list = xs_list_append(label_list, name);
205+        if (xs_type(address) == XSTYPE_STRING)
206+            label_list = xs_list_append(label_list, address);
207+
208+        if (xs_list_len(label_list)) {
209+            const char *url = xs_dict_get(location, "url");
210+            xs *label = xs_join(label_list, ", ");
211+
212+            if (xs_type(url) == XSTYPE_STRING) {
213+                xs_html_add(snac_content_wrap,
214+                    xs_html_tag("p",
215+                        xs_html_text(L("Location: ")),
216+                        xs_html_tag("a",
217+                            xs_html_attr("href", url),
218+                            xs_html_attr("target", "_blank"),
219+                            xs_html_text(label))));
220+            }
221+            else
222+            if (!xs_is_null(latitude) && !xs_is_null(longitude)) {
223+                xs *url = xs_fmt("https://openstreetmap.org/search/?query=%s,%s",
224+                    xs_number_str(latitude), xs_number_str(longitude));
225+
226+                xs_html_add(snac_content_wrap,
227+                    xs_html_tag("p",
228+                        xs_html_text(L("Location: ")),
229+                        xs_html_tag("a",
230+                            xs_html_attr("href", url),
231+                            xs_html_attr("target", "_blank"),
232+                            xs_html_text(label))));
233+            }
234+            else
235+                xs_html_add(snac_content_wrap,
236+                    xs_html_tag("p",
237+                        xs_html_text(L("Location: ")),
238+                        xs_html_text(label)));
239+        }
240+    }
241+
242+    if (strcmp(type, "Event") == 0) { /** Event start and end times **/
243+        const char *s_time = xs_dict_get(msg, "startTime");
244+
245+        if (xs_is_string(s_time) && strlen(s_time) > 20) {
246+            const char *e_time = xs_dict_get(msg, "endTime");
247+            const char *tz     = xs_dict_get(msg, "timezone");
248+
249+            xs *s = xs_replace_i(xs_dup(s_time), "T", " ");
250+            xs *e = NULL;
251+
252+            if (xs_is_string(e_time) && strlen(e_time) > 20)
253+                e = xs_replace_i(xs_dup(e_time), "T", " ");
254+
255+            /* if the event has a timezone, crop the offsets */
256+            if (xs_is_string(tz)) {
257+                s = xs_crop_i(s, 0, 19);
258+
259+                if (e)
260+                    e = xs_crop_i(e, 0, 19);
261+            }
262+            else
263+                tz = "";
264+
265+            /* if start and end share the same day, crop it from the end */
266+            if (e && memcmp(s, e, 11) == 0)
267+                e = xs_crop_i(e, 11, 0);
268+
269+            if (e)
270+                s = xs_str_cat(s, " / ", e);
271+
272+            if (*tz)
273+                s = xs_str_cat(s, " (", tz, ")");
274+
275+            /* replace ugly decimals */
276+            s = xs_replace_i(s, ".000", "");
277+
278+            xs_html_add(snac_content_wrap,
279+                xs_html_tag("p",
280+                    xs_html_text(L("Time: ")),
281+                    xs_html_text(s)));
282+        }
283+    }
284+
285+    /* show all hashtags that has not been shown previously in the content */
286+    const xs_list *tags = xs_dict_get(msg, "tag");
287+    const char *o_content = xs_dict_get_def(msg, "content", "");
288+
289+    if (xs_is_string(o_content) && xs_is_list(tags) && xs_list_len(tags)) {
290+        xs *content = xs_utf8_to_lower(o_content);
291+        const xs_dict *tag;
292+
293+        xs_html *add_hashtags = xs_html_tag("ul",
294+            xs_html_attr("class", "snac-more-hashtags"));
295+
296+        xs_list_foreach(tags, tag) {
297+            const char *type = xs_dict_get(tag, "type");
298+
299+            if (xs_is_string(type) && strcmp(type, "Hashtag") == 0) {
300+                const char *o_href = xs_dict_get(tag, "href");
301+                const char *name   = xs_dict_get(tag, "name");
302+
303+                if (xs_is_string(o_href) && xs_is_string(name)) {
304+                    xs *href = xs_utf8_to_lower(o_href);
305+
306+                    if (xs_str_in(content, href) == -1 && xs_str_in(content, name) == -1) {
307+                        /* not in the content: add here */
308+                        xs_html_add(add_hashtags,
309+                            xs_html_tag("li",
310+                                xs_html_tag("a",
311+                                    xs_html_attr("href", href),
312+                                    xs_html_text(name),
313+                                    xs_html_text(" "))));
314+                    }
315+                }
316+            }
317+        }
318+
319+        xs_html_add(snac_content_wrap,
320+            add_hashtags);
321+    }
322+
323     /** controls **/
324 
325     if (!read_only && user) {
326@@ -2583,7 +2798,7 @@ xs_html *html_people_list(snac *snac, xs_list *list, char *header, char *t, cons
327                 xs_html_tag("div",
328                     xs_html_attr("class", "snac-post-header"),
329                     html_actor_icon(snac, actor, xs_dict_get(actor, "published"),
330-                                    NULL, NULL, 0, 1, proxy)));
331+                                    NULL, NULL, 0, 1, proxy, NULL)));
332 
333             /* content (user bio) */
334             const char *c = xs_dict_get(actor, "summary");
335@@ -2762,9 +2977,15 @@ xs_str *html_notifications(snac *user, int skip, int show)
336         xs_html_attr("class", "snac-posts"));
337     xs_html_add(body, posts);
338 
339-    xs_list *p = n_list;
340+    xs_set rep;
341+    xs_set_init(&rep);
342+
343+    /* dict to store previous notification labels */
344+    xs *admiration_labels = xs_dict_new();
345+
346     const xs_str *v;
347-    while (xs_list_iter(&p, &v)) {
348+
349+    xs_list_foreach(n_list, v) {
350         xs *noti = notify_get(user, v);
351 
352         if (noti == NULL)
353@@ -2775,6 +2996,7 @@ xs_str *html_notifications(snac *user, int skip, int show)
354         const char *utype = xs_dict_get(noti, "utype");
355         const char *id    = xs_dict_get(noti, "objid");
356         const char *date  = xs_dict_get(noti, "date");
357+        const char *id2   = xs_dict_get_path(noti, "msg.id");
358         xs *wrk = NULL;
359 
360         if (xs_is_null(id))
361@@ -2783,8 +3005,16 @@ xs_str *html_notifications(snac *user, int skip, int show)
362         if (is_hidden(user, id))
363             continue;
364 
365+        if (xs_is_string(id2) && xs_set_add(&rep, id2) != 1)
366+            continue;
367+
368         object_get(id, &obj);
369 
370+        const char *msg_id = NULL;
371+
372+        if (xs_is_dict(obj))
373+            msg_id = xs_dict_get(obj, "id");
374+
375         const char *actor_id = xs_dict_get(noti, "actor");
376         xs *actor = NULL;
377 
378@@ -2817,9 +3047,7 @@ xs_str *html_notifications(snac *user, int skip, int show)
379 
380         xs *s_date = xs_crop_i(xs_dup(date), 0, 10);
381 
382-        xs_html *entry = xs_html_tag("div",
383-            xs_html_attr("class", "snac-post-with-desc"),
384-            xs_html_tag("p",
385+        xs_html *this_html_label = xs_html_container(
386                 xs_html_tag("b",
387                     xs_html_text(label),
388                     xs_html_text(" by "),
389@@ -2830,13 +3058,45 @@ xs_str *html_notifications(snac *user, int skip, int show)
390                 xs_html_tag("time",
391                     xs_html_attr("class", "dt-published snac-pubdate"),
392                     xs_html_attr("title", date),
393-                    xs_html_text(s_date))));
394+                    xs_html_text(s_date)));
395+
396+        xs_html *html_label = NULL;
397+
398+        if (xs_is_string(msg_id)) {
399+            const xs_val *prev_label = xs_dict_get(admiration_labels, msg_id);
400+
401+            if (xs_type(prev_label) == XSTYPE_DATA) {
402+                /* there is a previous list of admiration labels! */
403+                xs_data_get(&html_label, prev_label);
404+
405+                xs_html_add(html_label,
406+                    xs_html_sctag("br", NULL),
407+                    this_html_label);
408+
409+                continue;
410+            }
411+        }
412+
413+        xs_html *entry = NULL;
414+
415+        html_label = xs_html_tag("p",
416+            this_html_label);
417+
418+        /* store in the admiration labels dict */
419+        xs *pl = xs_data_new(&html_label, sizeof(html_label));
420+
421+        if (xs_is_string(msg_id))
422+            admiration_labels = xs_dict_set(admiration_labels, msg_id, pl);
423+
424+        entry = xs_html_tag("div",
425+            xs_html_attr("class", "snac-post-with-desc"),
426+            html_label);
427 
428         if (strcmp(type, "Follow") == 0 || strcmp(utype, "Follow") == 0 || strcmp(type, "Block") == 0) {
429             xs_html_add(entry,
430                 xs_html_tag("div",
431                     xs_html_attr("class", "snac-post"),
432-                    html_actor_icon(user, actor, NULL, NULL, NULL, 0, 0, proxy)));
433+                    html_actor_icon(user, actor, NULL, NULL, NULL, 0, 0, proxy, NULL)));
434         }
435         else
436         if (strcmp(type, "Move") == 0) {
437@@ -2850,7 +3110,7 @@ xs_str *html_notifications(snac *user, int skip, int show)
438                     xs_html_add(entry,
439                         xs_html_tag("div",
440                             xs_html_attr("class", "snac-post"),
441-                            html_actor_icon(user, old_actor, NULL, NULL, NULL, 0, 0, proxy)));
442+                            html_actor_icon(user, old_actor, NULL, NULL, NULL, 0, 0, proxy, NULL)));
443                 }
444             }
445         }
446@@ -2917,6 +3177,8 @@ xs_str *html_notifications(snac *user, int skip, int show)
447                     xs_html_text(L("More...")))));
448     }
449 
450+    xs_set_free(&rep);
451+
452     xs_html_add(body,
453         html_footer());
454 
455@@ -2970,6 +3232,21 @@ int html_get_handler(const xs_dict *req, const char *q_path,
456         else
457             return HTTP_STATUS_NOT_FOUND;
458     }
459+    else
460+    if (strcmp(v, "auth-int-bridge") == 0) {
461+        const char *login  = xs_dict_get(q_vars, "login");
462+        const char *id     = xs_dict_get(q_vars, "id");
463+        const char *action = xs_dict_get(q_vars, "action");
464+
465+        if (xs_is_string(login) && xs_is_string(id) && xs_is_string(action)) {
466+            *body = xs_fmt("%s/%s/authorize_interaction?action=%s&id=%s",
467+                srv_baseurl, login, action, id);
468+
469+            return HTTP_STATUS_SEE_OTHER;
470+        }
471+        else
472+            return HTTP_STATUS_NOT_FOUND;
473+    }
474 
475     uid = xs_dup(v);
476 
477@@ -3541,6 +3818,52 @@ int html_get_handler(const xs_dict *req, const char *q_path,
478             status  = HTTP_STATUS_SEE_OTHER;
479         }
480     }
481+    else
482+    if (strcmp(p_path, "authorize_interaction") == 0) { /** follow, like or boost from Mastodon **/
483+        if (!login(&snac, req)) {
484+            *body  = xs_dup(uid);
485+            status = HTTP_STATUS_UNAUTHORIZED;
486+        }
487+        else {
488+            status = HTTP_STATUS_NOT_FOUND;
489+
490+            const char *id     = xs_dict_get(q_vars, "id");
491+            const char *action = xs_dict_get(q_vars, "action");
492+
493+            if (xs_is_string(id) && xs_is_string(action)) {
494+                if (strcmp(action, "Follow") == 0) {
495+                    xs *msg = msg_follow(&snac, id);
496+
497+                    if (msg != NULL) {
498+                        const char *actor = xs_dict_get(msg, "object");
499+
500+                        following_add(&snac, actor, msg);
501+
502+                        enqueue_output_by_actor(&snac, msg, actor, 0);
503+
504+                        status = HTTP_STATUS_SEE_OTHER;
505+                    }
506+                }
507+                else
508+                if (xs_match(action, "Like|Boost|Announce")) {
509+                    /* bring the post */
510+                    xs *msg = msg_admiration(&snac, id, *action == 'L' ? "Like" : "Announce");
511+
512+                    if (msg != NULL) {
513+                        enqueue_message(&snac, msg);
514+                        timeline_admire(&snac, xs_dict_get(msg, "object"), snac.actor, *action == 'L' ? 1 : 0);
515+
516+                        status = HTTP_STATUS_SEE_OTHER;
517+                    }
518+                }
519+            }
520+
521+            if (status == HTTP_STATUS_SEE_OTHER) {
522+                *body   = xs_fmt("%s/admin", snac.actor);
523+                *b_size = strlen(*body);
524+            }
525+        }
526+    }
527     else
528         status = HTTP_STATUS_NOT_FOUND;
529 
530@@ -4024,6 +4347,9 @@ int html_post_handler(const xs_dict *req, const char *q_path,
531         else
532             snac.config = xs_dict_set(snac.config, "show_contact_metrics", xs_stock(XSTYPE_FALSE));
533 
534+        snac.config = xs_dict_set(snac.config, "latitude", xs_dict_get_def(p_vars, "latitude", ""));
535+        snac.config = xs_dict_set(snac.config, "longitude", xs_dict_get_def(p_vars, "longitude", ""));
536+
537         if ((v = xs_dict_get(p_vars, "metadata")) != NULL)
538             snac.config = xs_dict_set(snac.config, "metadata", v);
539 
540@@ -4140,6 +4466,35 @@ int html_post_handler(const xs_dict *req, const char *q_path,
541 
542         status = HTTP_STATUS_SEE_OTHER;
543     }
544+    else
545+    if (p_path && strcmp(p_path, "admin/followed-hashtags") == 0) { /** **/
546+        const char *followed_hashtags = xs_dict_get(p_vars, "followed_hashtags");
547+
548+        if (xs_is_string(followed_hashtags)) {
549+            xs *new_hashtags = xs_list_new();
550+            xs *l = xs_split(followed_hashtags, "\n");
551+            const char *v;
552+
553+            xs_list_foreach(l, v) {
554+                xs *s1 = xs_strip_i(xs_dup(v));
555+                s1 = xs_replace_i(s1, " ", "");
556+
557+                if (*s1 == '\0')
558+                    continue;
559+
560+                xs *s2 = xs_utf8_to_lower(s1);
561+                if (*s2 != '#')
562+                    s2 = xs_str_prepend_i(s2, "#");
563+
564+                new_hashtags = xs_list_append(new_hashtags, s2);
565+            }
566+
567+            snac.config = xs_dict_set(snac.config, "followed_hashtags", new_hashtags);
568+            user_persist(&snac, 0);
569+        }
570+
571+        status = HTTP_STATUS_SEE_OTHER;
572+    }
573 
574     if (status == HTTP_STATUS_SEE_OTHER) {
575         const char *redir = xs_dict_get(p_vars, "redir");
M http.c
+1, -1
1@@ -1,5 +1,5 @@
2 /* snac - A simple, minimalistic ActivityPub instance */
3-/* copyright (c) 2022 - 2024 grunfink et al. / MIT license */
4+/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */
5 
6 #include "xs.h"
7 #include "xs_io.h"
+48, -6
 1@@ -1,5 +1,5 @@
 2 /* snac - A simple, minimalistic ActivityPub instance */
 3-/* copyright (c) 2022 - 2024 grunfink et al. / MIT license */
 4+/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */
 5 
 6 #include "xs.h"
 7 #include "xs_io.h"
 8@@ -138,7 +138,7 @@ static xs_str *greeting_html(void)
 9             while (xs_list_iter(&p, &uid)) {
10                 snac user;
11 
12-                if (user_open(&user, uid)) {
13+                if (strcmp(uid, "relay") && user_open(&user, uid)) {
14                     xs_html_add(ul,
15                         xs_html_tag("li",
16                             xs_html_tag("a",
17@@ -182,6 +182,29 @@ const char *share_page = ""
18 "";
19 
20 
21+const char *authorize_interaction_page = ""
22+"<!DOCTYPE html>\n"
23+"<html>\n"
24+"<head>\n"
25+"<title>%s - snac</title>\n"
26+"<meta content=\"width=device-width, initial-scale=1, minimum-scale=1, user-scalable=no\" name=\"viewport\">\n"
27+"<link rel=\"stylesheet\" type=\"text/css\" href=\"%s/style.css\"/>\n"
28+"<style>:root {color-scheme: light dark}</style>\n"
29+"</head>\n"
30+"<body><h1>%s authorize interaction</h1>\n"
31+"<form method=\"get\" action=\"%s/auth-int-bridge\">\n"
32+"<select name=\"action\">\n"
33+"<option value=\"Follow\">Follow</option>\n"
34+"<option value=\"Boost\">Boost</option>\n"
35+"<option value=\"Like\">Like</option>\n"
36+"</select> %s\n"
37+"<input type=\"hidden\" name=\"id\" value=\"%s\">\n"
38+"<p>Login: <input type=\"text\" name=\"login\" autocapitalize=\"off\" required=\"required\"></p>\n"
39+"<input type=\"submit\" value=\"OK\">\n"
40+"</form><p>%s</p></body></html>\n"
41+"";
42+
43+
44 int server_get_handler(xs_dict *req, const char *q_path,
45                        char **body, int *b_size, char **ctype)
46 /* basic server services */
47@@ -189,7 +212,7 @@ int server_get_handler(xs_dict *req, const char *q_path,
48     int status = 0;
49 
50     /* is it the server root? */
51-    if (*q_path == '\0') {
52+    if (*q_path == '\0' || strcmp(q_path, "/") == 0) {
53         const xs_dict *q_vars = xs_dict_get(req, "q_vars");
54         const char *t = NULL;
55 
56@@ -318,6 +341,25 @@ int server_get_handler(xs_dict *req, const char *q_path,
57             USER_AGENT
58         );
59     }
60+    else
61+    if (strcmp(q_path, "/authorize_interaction") == 0) {
62+        const xs_dict *q_vars = xs_dict_get(req, "q_vars");
63+        const char *uri  = xs_dict_get(q_vars, "uri");
64+
65+        if (xs_is_string(uri)) {
66+            status = HTTP_STATUS_OK;
67+            *ctype = "text/html; charset=utf-8";
68+            *body  = xs_fmt(authorize_interaction_page,
69+                xs_dict_get(srv_config, "host"),
70+                srv_baseurl,
71+                xs_dict_get(srv_config, "host"),
72+                srv_baseurl,
73+                uri,
74+                uri,
75+                USER_AGENT
76+            );
77+        }
78+    }
79 
80     if (status != 0)
81         srv_debug(1, xs_fmt("server_get_handler serving '%s' %d", q_path, status));
82@@ -459,13 +501,13 @@ void httpd_connection(FILE *f)
83     }
84 
85     if (status == HTTP_STATUS_FORBIDDEN)
86-        body = xs_str_new("<h1>403 Forbidden</h1>");
87+        body = xs_str_new("<h1>403 Forbidden (" USER_AGENT ")</h1>");
88 
89     if (status == HTTP_STATUS_NOT_FOUND)
90-        body = xs_str_new("<h1>404 Not Found</h1>");
91+        body = xs_str_new("<h1>404 Not Found (" USER_AGENT ")</h1>");
92 
93     if (status == HTTP_STATUS_BAD_REQUEST && body != NULL)
94-        body = xs_str_new("<h1>400 Bad Request</h1>");
95+        body = xs_str_new("<h1>400 Bad Request (" USER_AGENT ")</h1>");
96 
97     if (status == HTTP_STATUS_SEE_OTHER)
98         headers = xs_dict_append(headers, "location", body);
M main.c
+46, -28
  1@@ -1,11 +1,12 @@
  2 /* snac - A simple, minimalistic ActivityPub instance */
  3-/* copyright (c) 2022 - 2024 grunfink et al. / MIT license */
  4+/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */
  5 
  6 #include "xs.h"
  7 #include "xs_io.h"
  8 #include "xs_json.h"
  9 #include "xs_time.h"
 10 #include "xs_openssl.h"
 11+#include "xs_match.h"
 12 
 13 #include "snac.h"
 14 
 15@@ -14,7 +15,7 @@
 16 int usage(void)
 17 {
 18     printf("snac " VERSION " - A simple, minimalistic ActivityPub instance\n");
 19-    printf("Copyright (c) 2022 - 2024 grunfink et al. / MIT license\n");
 20+    printf("Copyright (c) 2022 - 2025 grunfink et al. / MIT license\n");
 21     printf("\n");
 22     printf("Commands:\n");
 23     printf("\n");
 24@@ -34,6 +35,7 @@ int usage(void)
 25     printf("actor {basedir} [{uid}] {url}        Requests an actor\n");
 26     printf("note {basedir} {uid} {text} [files...] Sends a note with optional attachments\n");
 27     printf("note_unlisted {basedir} {uid} {text} [files...] Sends an unlisted note with optional attachments\n");
 28+    printf("note_mention {basedir} {uid} {text} [files...] Sends a note only to mentioned accounts\n");
 29     printf("boost|announce {basedir} {uid} {url} Boosts (announces) a post\n");
 30     printf("unboost {basedir} {uid} {url}        Unboosts a post\n");
 31     printf("resetpwd {basedir} {uid}             Resets the password of a user\n");
 32@@ -49,10 +51,10 @@ int usage(void)
 33     printf("unlimit {basedir} {uid} {actor}      Unlimits an actor\n");
 34     printf("verify_links {basedir} {uid}         Verifies a user's links (in the metadata)\n");
 35     printf("search {basedir} {uid} {regex}       Searches posts by content\n");
 36-    printf("export_csv {basedir} {uid}           Exports data as CSV files into current directory\n");
 37+    printf("export_csv {basedir} {uid}           Exports data as CSV files\n");
 38     printf("alias {basedir} {uid} {account}      Sets account (@user@host or actor url) as an alias\n");
 39     printf("migrate {basedir} {uid}              Migrates to the account defined as the alias\n");
 40-    printf("import_csv {basedir} {uid}           Imports data from CSV files in the current directory\n");
 41+    printf("import_csv {basedir} {uid}           Imports data from CSV files\n");
 42     printf("import_list {basedir} {uid} {file}   Imports a Mastodon CSV list file\n");
 43     printf("import_block_list {basedir} {uid} {file} Imports a Mastodon CSV block list file\n");
 44 
 45@@ -94,19 +96,6 @@ int main(int argc, char *argv[])
 46         return snac_init(basedir);
 47     }
 48 
 49-    if (strcmp(cmd, "upgrade") == 0) { /** **/
 50-        int ret;
 51-
 52-        /* upgrade */
 53-        if ((basedir = GET_ARGV()) == NULL)
 54-            return usage();
 55-
 56-        if ((ret = srv_open(basedir, 1)) == 1)
 57-            srv_log(xs_dup("OK"));
 58-
 59-        return ret;
 60-    }
 61-
 62     if (strcmp(cmd, "markdown") == 0) { /** **/
 63         /* undocumented, for testing only */
 64         xs *c = xs_readall(stdin);
 65@@ -116,8 +105,20 @@ int main(int argc, char *argv[])
 66         return 0;
 67     }
 68 
 69-    if ((basedir = GET_ARGV()) == NULL)
 70-        return usage();
 71+    if ((basedir = getenv("SNAC_BASEDIR")) == NULL) {
 72+        if ((basedir = GET_ARGV()) == NULL)
 73+            return usage();
 74+    }
 75+
 76+    if (strcmp(cmd, "upgrade") == 0) { /** **/
 77+        int ret;
 78+
 79+        /* upgrade */
 80+        if ((ret = srv_open(basedir, 1)) == 1)
 81+            srv_log(xs_dup("OK"));
 82+
 83+        return ret;
 84+    }
 85 
 86     if (!srv_open(basedir, 0)) {
 87         srv_log(xs_fmt("error opening data storage at %s", basedir));
 88@@ -351,6 +352,22 @@ int main(int argc, char *argv[])
 89         return 0;
 90     }
 91 
 92+
 93+    if (strcmp(cmd, "assist") == 0) { /** **/
 94+        /* undocumented: experimental (do not use) */
 95+        xs *msg = msg_admiration(&snac, url, "Accept");
 96+
 97+        if (msg != NULL) {
 98+            enqueue_message(&snac, msg);
 99+
100+            if (dbglevel) {
101+                xs_json_dump(msg, 4, stdout);
102+            }
103+        }
104+
105+        return 0;
106+    }
107+
108     if (strcmp(cmd, "unboost") == 0) { /** **/
109         xs *msg = msg_repulsion(&snac, url, "Announce");
110 
111@@ -604,7 +621,9 @@ int main(int argc, char *argv[])
112         return 0;
113     }
114 
115-    if (strcmp(cmd, "note") == 0 || strcmp(cmd, "note_unlisted") == 0) { /** **/
116+    if (strcmp(cmd, "note") == 0 ||             /** **/
117+        strcmp(cmd, "note_unlisted") == 0 ||    /** **/
118+        strcmp(cmd, "note_mention") == 0) {     /** **/
119         xs *content = NULL;
120         xs *msg = NULL;
121         xs *c_msg = NULL;
122@@ -668,15 +687,14 @@ int main(int argc, char *argv[])
123         else
124             content = xs_dup(url);
125 
126-        msg = msg_note(&snac, content, NULL, NULL, attl, 0, getenv("LANG"));
127+        int scope = 0;
128+        if (strcmp(cmd, "note_mention") == 0)
129+            scope = 1;
130+        else
131+        if (strcmp(cmd, "note_unlisted") == 0)
132+            scope = 2;
133 
134-        if (strcmp(cmd, "note_unlisted") == 0) {
135-            /* according to Mastodon, "unlisted" posts (now called "quiet public")
136-               has the public address as a cc instead of to, so toggle it */
137-            xs *to = xs_dup(xs_dict_get(msg, "to"));
138-            msg = xs_dict_set(msg, "cc", to);
139-            msg = xs_dict_set(msg, "to", xs_stock(XSTYPE_LIST));
140-        }
141+        msg = msg_note(&snac, content, NULL, NULL, attl, scope, getenv("LANG"));
142 
143         c_msg = msg_create(&snac, msg);
144 
+52, -24
  1@@ -1,5 +1,5 @@
  2 /* snac - A simple, minimalistic ActivityPub instance */
  3-/* copyright (c) 2022 - 2024 grunfink et al. / MIT license */
  4+/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */
  5 
  6 #ifndef NO_MASTODON_API
  7 
  8@@ -1339,6 +1339,9 @@ xs_list *mastoapi_timeline(snac *user, const xs_dict *args, const char *index_fn
  9     const char *since_id = xs_dict_get(args, "since_id");
 10     const char *min_id   = xs_dict_get(args, "min_id"); /* unsupported old-to-new navigation */
 11     const char *limit_s  = xs_dict_get(args, "limit");
 12+    int (*iterator)(FILE *, char *);
 13+    int initial_status = 0;
 14+    int ascending = 0;
 15     int limit = 0;
 16     int cnt   = 0;
 17 
 18@@ -1348,27 +1351,40 @@ xs_list *mastoapi_timeline(snac *user, const xs_dict *args, const char *index_fn
 19     if (limit == 0)
 20         limit = 20;
 21 
 22-    if (index_desc_first(f, md5, 0)) {
 23+    if (min_id) {
 24+        iterator = &index_asc_next;
 25+        initial_status = index_asc_first(f, md5, MID_TO_MD5(min_id));
 26+        ascending = 1;
 27+    }
 28+    else {
 29+        iterator = &index_desc_next;
 30+        initial_status = index_desc_first(f, md5, 0);
 31+    }
 32+
 33+    if (initial_status) {
 34         do {
 35             xs *msg = NULL;
 36 
 37             /* only return entries older that max_id */
 38             if (max_id) {
 39-                if (strcmp(md5, MID_TO_MD5(max_id)) == 0)
 40+                if (strcmp(md5, MID_TO_MD5(max_id)) == 0) {
 41                     max_id = NULL;
 42-
 43-                continue;
 44+                    if (ascending)
 45+                        break;
 46+                }
 47+                if (!ascending)
 48+                    continue;
 49             }
 50 
 51             /* only returns entries newer than since_id */
 52             if (since_id) {
 53-                if (strcmp(md5, MID_TO_MD5(since_id)) == 0)
 54-                    break;
 55-            }
 56-
 57-            if (min_id) {
 58-                if (strcmp(md5, MID_TO_MD5(min_id)) == 0)
 59-                    break;
 60+                if (strcmp(md5, MID_TO_MD5(since_id)) == 0) {
 61+                    if (!ascending)
 62+                        break;
 63+                    since_id = NULL;
 64+                }
 65+                if (ascending)
 66+                    continue;
 67             }
 68 
 69             /* get the entry */
 70@@ -1428,26 +1444,23 @@ xs_list *mastoapi_timeline(snac *user, const xs_dict *args, const char *index_fn
 71                     continue;
 72             }
 73 
 74-            /* if it has a name and it's not a Page or a Video,
 75+            /* if it has a name and it's not an object that may have one,
 76                it's a poll vote, so discard it */
 77-            if (!xs_is_null(xs_dict_get(msg, "name")) && !xs_match(type, "Page|Video"))
 78+            if (!xs_is_null(xs_dict_get(msg, "name")) && !xs_match(type, "Page|Video|Audio|Event"))
 79                 continue;
 80 
 81             /* convert the Note into a Mastodon status */
 82             xs *st = mastoapi_status(user, msg);
 83 
 84             if (st != NULL) {
 85-                out = xs_list_append(out, st);
 86+                if (ascending)
 87+                    out = xs_list_insert(out, 0, st);
 88+                else
 89+                    out = xs_list_append(out, st);
 90                 cnt++;
 91             }
 92-            if (min_id) {
 93-                while (cnt > limit) {
 94-                    out = xs_list_del(out, 0);
 95-                    cnt--;
 96-                }
 97-            }
 98 
 99-        } while ((min_id || (cnt < limit)) && index_desc_next(f, md5));
100+        } while ((cnt < limit) && (*iterator)(f, md5));
101     }
102 
103     int more = index_desc_next(f, md5);
104@@ -1816,6 +1829,11 @@ int mastoapi_get_handler(const xs_dict *req, const char *q_path,
105             const xs_list *excl = xs_dict_get(args, "exclude_types[]");
106             const char *min_id = xs_dict_get(args, "min_id");
107             const char *max_id = xs_dict_get(args, "max_id");
108+            const char *limit = xs_dict_get(args, "limit");
109+            int limit_count = 0;
110+            if (!xs_is_null(limit)) {
111+                limit_count = atoi(limit);
112+            }
113 
114             if (dbglevel) {
115                 xs *js = xs_json_dumps(args, 0);
116@@ -1903,6 +1921,10 @@ int mastoapi_get_handler(const xs_dict *req, const char *q_path,
117                 }
118 
119                 out = xs_list_append(out, mn);
120+                if (!xs_is_null(limit)) {
121+                    if (--limit_count <= 0)
122+                        break;
123+                }
124             }
125 
126             srv_debug(1, xs_fmt("mastoapi_notifications count %d", xs_list_len(out)));
127@@ -2650,8 +2672,14 @@ int mastoapi_post_handler(const xs_dict *req, const char *q_path,
128             }
129 
130             /* prepare the message */
131-            xs *msg = msg_note(&snac, content, NULL, irt, attach_list,
132-                        strcmp(visibility, "public") == 0 ? 0 : 1, language);
133+            int scope = 1;
134+            if (strcmp(visibility, "unlisted") == 0)
135+                scope = 2;
136+            else
137+            if (strcmp(visibility, "public") == 0)
138+                scope = 0;
139+
140+            xs *msg = msg_note(&snac, content, NULL, irt, attach_list, scope, language);
141 
142             if (!xs_is_null(summary) && *summary) {
143                 msg = xs_dict_set(msg, "sensitive", xs_stock(XSTYPE_TRUE));
+8, -1
 1@@ -71,15 +71,22 @@ LL_BEGIN(sbox_enter_linux_, const char* basedir, const char *address, int smail)
 2              LANDLOCK_ACCESS_FS_REFER_COMPAT,
 3         s  = LANDLOCK_ACCESS_FS_MAKE_SOCK,
 4         x  = LANDLOCK_ACCESS_FS_EXECUTE;
 5+    char *resolved_path = NULL;
 6 
 7     LL_PATH(basedir,                rf|rd|w|c);
 8     LL_PATH("/tmp",                 rf|rd|w|c);
 9 #ifndef WITHOUT_SHM
10     LL_PATH("/dev/shm",             rf|w|c   );
11 #endif
12+    LL_PATH("/dev/urandom",         rf       );
13     LL_PATH("/etc/resolv.conf",     rf       );
14     LL_PATH("/etc/hosts",           rf       );
15-    LL_PATH("/etc/ssl",             rf       );
16+    LL_PATH("/etc/ssl",             rf|rd    );
17+    if ((resolved_path = realpath("/etc/ssl/cert.pem", NULL))) {
18+        /* some distros like cert.pem to be a symlink */
19+        LL_PATH(resolved_path,      rf       );
20+        free(resolved_path);
21+    }
22     LL_PATH("/usr/share/zoneinfo",  rf       );
23 
24     if (mtime("/etc/pki") > 0)
M snac.c
+1, -1
1@@ -1,5 +1,5 @@
2 /* snac - A simple, minimalistic ActivityPub instance */
3-/* copyright (c) 2022 - 2024 grunfink et al. / MIT license */
4+/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */
5 
6 #define XS_IMPLEMENTATION
7 
M snac.h
+5, -3
 1@@ -1,7 +1,7 @@
 2 /* snac - A simple, minimalistic ActivityPub instance */
 3-/* copyright (c) 2022 - 2024 grunfink et al. / MIT license */
 4+/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */
 5 
 6-#define VERSION "2.68-dev"
 7+#define VERSION "2.70-dev"
 8 
 9 #define USER_AGENT "snac/" VERSION
10 
11@@ -108,6 +108,8 @@ int index_len(const char *fn);
12 xs_list *index_list(const char *fn, int max);
13 int index_desc_next(FILE *f, char md5[MD5_HEX_SIZE]);
14 int index_desc_first(FILE *f, char md5[MD5_HEX_SIZE], int skip);
15+int index_asc_next(FILE *f, char md5[MD5_HEX_SIZE]);
16+int index_asc_first(FILE *f, char md5[MD5_HEX_SIZE], const char *seek_md5);
17 xs_list *index_list_desc(const char *fn, int skip, int show);
18 
19 int object_add(const char *id, const xs_dict *obj);
20@@ -317,7 +319,7 @@ xs_dict *msg_follow(snac *snac, const char *actor);
21 
22 xs_dict *msg_note(snac *snac, const xs_str *content, const xs_val *rcpts,
23                   const xs_str *in_reply_to, const xs_list *attach,
24-                  int priv, const char *lang);
25+                  int scope, const char *lang);
26 
27 xs_dict *msg_undo(snac *snac, const xs_val *object);
28 xs_dict *msg_delete(snac *snac, const char *id);
+1, -1
1@@ -1,5 +1,5 @@
2 /* snac - A simple, minimalistic ActivityPub instance */
3-/* copyright (c) 2022 - 2024 grunfink et al. / MIT license */
4+/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */
5 
6 #include "xs.h"
7 #include "xs_io.h"
+7, -3
 1@@ -1,5 +1,5 @@
 2 /* snac - A simple, minimalistic ActivityPub instance */
 3-/* copyright (c) 2022 - 2024 grunfink et al. / MIT license */
 4+/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */
 5 
 6 #include "xs.h"
 7 #include "xs_io.h"
 8@@ -98,7 +98,7 @@ static const char *greeting_html =
 9     "<html><head>\n"
10     "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"/>\n"
11     "<link rel=\"icon\" type=\"image/x-icon\" href=\"https://%host%/favicon.ico\"/>\n"
12-    "<title>Welcome to %host%</title>\n"
13+    "<title>Welcome to %host%</title>\n</head>\n"
14     "<body style=\"margin: auto; max-width: 50em\">\n"
15     "%blurb%"
16     "<p>The following users are part of this community:</p>\n"
17@@ -319,6 +319,10 @@ int adduser(const char *uid)
18         mkdirx(d);
19     }
20 
21+    /* add a specially short data retention time for the relay */
22+    if (strcmp(uid, "relay") == 0)
23+        config = xs_dict_set(config, "purge_days", xs_stock(1));
24+
25     xs *cfn = xs_fmt("%s/user.json", basedir);
26 
27     if ((f = fopen(cfn, "w")) == NULL) {
28@@ -331,7 +335,7 @@ int adduser(const char *uid)
29     }
30 
31     printf("\nCreating RSA key...\n");
32-    key = xs_evp_genkey(4096);
33+    key = xs_evp_genkey(2048);
34     printf("Done.\n");
35 
36     xs *kfn = xs_fmt("%s/key.json", basedir);
+1, -1
1@@ -1,5 +1,5 @@
2 /* snac - A simple, minimalistic ActivityPub instance */
3-/* copyright (c) 2022 - 2024 grunfink et al. / MIT license */
4+/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */
5 
6 #include "xs.h"
7 #include "xs_json.h"
M xs.h
+18, -8
 1@@ -1,4 +1,4 @@
 2-/* copyright (c) 2022 - 2024 grunfink et al. / MIT license */
 3+/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */
 4 
 5 #ifndef _XS_H
 6 
 7@@ -157,6 +157,9 @@ unsigned int xs_hash_func(const char *data, int size);
 8 #define xs_is_true(v) (xs_type((v)) == XSTYPE_TRUE)
 9 #define xs_is_false(v) (xs_type((v)) == XSTYPE_FALSE)
10 #define xs_not(v) xs_stock(xs_is_true((v)) ? XSTYPE_FALSE : XSTYPE_TRUE)
11+#define xs_is_string(v) (xs_type((v)) == XSTYPE_STRING)
12+#define xs_is_list(v) (xs_type((v)) == XSTYPE_LIST)
13+#define xs_is_dict(v) (xs_type((v)) == XSTYPE_DICT)
14 
15 #define xs_list_foreach(l, v) for (int ct_##__LINE__ = 0; xs_list_next(l, &v, &ct_##__LINE__); )
16 #define xs_dict_foreach(l, k, v) for (int ct_##__LINE__ = 0; xs_dict_next(l, &k, &v, &ct_##__LINE__); )
17@@ -623,15 +626,14 @@ int xs_between(const char *prefix, const char *str, const char *suffix)
18 xs_str *xs_crop_i(xs_str *str, int start, int end)
19 /* crops the string to be only from start to end */
20 {
21-    XS_ASSERT_TYPE(str, XSTYPE_STRING);
22-
23     int sz = strlen(str);
24 
25     if (end <= 0)
26         end = sz + end;
27 
28     /* crop from the top */
29-    str[end] = '\0';
30+    if (end > 0 && end < sz)
31+        str[end] = '\0';
32 
33     /* crop from the bottom */
34     str = xs_collapse(str, 0, start);
35@@ -1061,14 +1063,15 @@ xs_keyval *xs_keyval_make(xs_keyval *keyval, const xs_str *key, const xs_val *va
36 
37 typedef struct {
38     int value_offset;   /* offset to value (from dict start) */
39-    int next;           /* next node in sequential search */
40+    int next;           /* next node in sequential scanning */
41     int child[4];       /* child nodes in hashed search */
42     char key[];         /* C string key */
43 } ditem_hdr;
44 
45 typedef struct {
46     int size;           /* size of full dict (_XS_TYPE_SIZE) */
47-    int first;          /* first node for sequential search */
48+    int first;          /* first node for sequential scanning */
49+    int last;           /* last node for sequential scanning */
50     int root;           /* root node for hashed search */
51     /* a bunch of ditem_hdr and value follows */
52 } dict_hdr;
53@@ -1153,8 +1156,15 @@ xs_dict *xs_dict_set(xs_dict *dict, const xs_str *key, const xs_val *value)
54             memcpy(dict + di->value_offset, value, vsz);
55 
56             /* chain to the sequential list */
57-            di->next = dh->first;
58-            dh->first = end;
59+            if (dh->first == 0)
60+                dh->first = end;
61+            else {
62+                /* chain this new element to the last one */
63+                ditem_hdr *dil = (ditem_hdr *)(dict + dh->last);
64+                dil->next = end;
65+            }
66+
67+            dh->last = end;
68         }
69         else {
70             /* ditem already exists */
+1, -1
1@@ -1,4 +1,4 @@
2-/* copyright (c) 2022 - 2024 grunfink et al. / MIT license */
3+/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */
4 
5 #ifndef _XS_CURL_H
6 
+1, -1
1@@ -1,4 +1,4 @@
2-/* copyright (c) 2022 - 2024 grunfink et al. / MIT license */
3+/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */
4 
5 /*
6     This is an intentionally-dead-simple FastCGI implementation;
+1, -1
1@@ -1,4 +1,4 @@
2-/* copyright (c) 2022 - 2024 grunfink et al. / MIT license */
3+/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */
4 
5 #ifndef _XS_GLOB_H
6 
+1, -1
1@@ -1,4 +1,4 @@
2-/* copyright (c) 2022 - 2024 grunfink et al. / MIT license */
3+/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */
4 
5 #ifndef _XS_HEX_H
6 
+1, -1
1@@ -1,4 +1,4 @@
2-/* copyright (c) 2022 - 2024 grunfink et al. / MIT license */
3+/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */
4 
5 #ifndef _XS_HTML_H
6 
+1, -1
1@@ -1,4 +1,4 @@
2-/* copyright (c) 2022 - 2024 grunfink et al. / MIT license */
3+/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */
4 
5 #ifndef _XS_HTTPD_H
6 
+1, -1
1@@ -1,4 +1,4 @@
2-/* copyright (c) 2022 - 2024 grunfink et al. / MIT license */
3+/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */
4 
5 #ifndef _XS_IO_H
6 
+1, -1
1@@ -1,4 +1,4 @@
2-/* copyright (c) 2022 - 2024 grunfink et al. / MIT license */
3+/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */
4 
5 #ifndef _XS_JSON_H
6 
+1, -1
1@@ -1,4 +1,4 @@
2-/* copyright (c) 2022 - 2024 grunfink et al. / MIT license */
3+/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */
4 
5 #ifndef _XS_MATCH_H
6 
+1, -1
1@@ -1,4 +1,4 @@
2-/* copyright (c) 2022 - 2024 grunfink et al. / MIT license */
3+/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */
4 
5 #ifndef _XS_MIME_H
6 
+1, -1
1@@ -1,4 +1,4 @@
2-/* copyright (c) 2022 - 2024 grunfink et al. / MIT license */
3+/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */
4 
5 #ifndef _XS_OPENSSL_H
6 
+1, -1
1@@ -1,4 +1,4 @@
2-/* copyright (c) 2022 - 2024 grunfink et al. / MIT license */
3+/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */
4 
5 #ifndef _XS_RANDOM_H
6 
+1, -1
1@@ -1,4 +1,4 @@
2-/* copyright (c) 2022 - 2024 grunfink et al. / MIT license */
3+/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */
4 
5 #ifndef _XS_REGEX_H
6 
+1, -1
1@@ -1,4 +1,4 @@
2-/* copyright (c) 2022 - 2024 grunfink et al. / MIT license */
3+/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */
4 
5 #ifndef _XS_SET_H
6 
+1, -1
1@@ -1,4 +1,4 @@
2-/* copyright (c) 2022 - 2024 grunfink et al. / MIT license */
3+/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */
4 
5 #ifndef _XS_SOCKET_H
6 
+1, -1
1@@ -1,4 +1,4 @@
2-/* copyright (c) 2022 - 2024 grunfink et al. / MIT license */
3+/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */
4 
5 #ifndef _XS_TIME_H
6 
+1, -1
1@@ -1,4 +1,4 @@
2-/* copyright (c) 2022 - 2024 grunfink et al. / MIT license */
3+/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */
4 
5 #ifndef _XS_UNICODE_H
6 
+1, -1
1@@ -1,4 +1,4 @@
2-/* copyright (c) 2022 - 2024 grunfink et al. / MIT license */
3+/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */
4 
5 #ifndef _XS_UNIX_SOCKET_H
6 
+24, -1
 1@@ -1,10 +1,11 @@
 2-/* copyright (c) 2022 - 2024 grunfink et al. / MIT license */
 3+/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */
 4 
 5 #ifndef _XS_URL_H
 6 
 7 #define _XS_URL_H
 8 
 9 xs_str *xs_url_dec(const char *str);
10+xs_str *xs_url_enc(const char *str);
11 xs_dict *xs_url_vars(const char *str);
12 xs_dict *xs_multipart_form_data(const char *payload, int p_size, const char *header);
13 
14@@ -39,6 +40,28 @@ xs_str *xs_url_dec(const char *str)
15 }
16 
17 
18+xs_str *xs_url_enc(const char *str)
19+/* URL-encodes a string (RFC 3986) */
20+{
21+    xs_str *s = xs_str_new(NULL);
22+
23+    while (*str) {
24+        if (isalnum(*str) || strchr("-._~", *str)) {
25+            s = xs_append_m(s, str, 1);
26+        }
27+        else {
28+            char tmp[8];
29+            snprintf(tmp, sizeof(tmp), "%%%02X", (unsigned char)*str);
30+            s = xs_append_m(s, tmp, 3);
31+        }
32+
33+        str++;
34+    }
35+
36+    return s;
37+}
38+
39+
40 xs_dict *xs_url_vars(const char *str)
41 /* parse url variables */
42 {
+1, -1
1@@ -1 +1 @@
2-/* 297f71e198be7819213e9122e1e78c3b963111bc 2024-11-24T18:48:42+01:00 */
3+/* b865e89769aedfdbc61251e94451e9d37579f52e 2025-01-12T16:17:47+01:00 */