Browse Source

Merge branch 'master' into master

ltning 2 months ago
parent
commit
f6044d3aa0
43 changed files with 1020 additions and 198 deletions
  1. 1 1
      LICENSE
  2. 2 1
      Makefile
  3. 3 2
      Makefile.NetBSD
  4. 29 1
      RELEASE_NOTES.md
  5. 3 3
      TODO.md
  6. 221 62
      activitypub.c
  7. 55 8
      data.c
  8. 8 1
      doc/snac.1
  9. 6 1
      doc/snac.5
  10. 40 0
      doc/snac.8
  11. 47 2
      format.c
  12. 374 19
      html.c
  13. 1 1
      http.c
  14. 48 6
      httpd.c
  15. 46 28
      main.c
  16. 52 24
      mastoapi.c
  17. 8 1
      sandbox.c
  18. 1 1
      snac.c
  19. 5 3
      snac.h
  20. 1 1
      upgrade.c
  21. 7 3
      utils.c
  22. 1 1
      webfinger.c
  23. 18 8
      xs.h
  24. 1 1
      xs_curl.h
  25. 1 1
      xs_fcgi.h
  26. 1 1
      xs_glob.h
  27. 1 1
      xs_hex.h
  28. 1 1
      xs_html.h
  29. 1 1
      xs_httpd.h
  30. 1 1
      xs_io.h
  31. 1 1
      xs_json.h
  32. 1 1
      xs_match.h
  33. 1 1
      xs_mime.h
  34. 1 1
      xs_openssl.h
  35. 1 1
      xs_random.h
  36. 1 1
      xs_regex.h
  37. 1 1
      xs_set.h
  38. 1 1
      xs_socket.h
  39. 1 1
      xs_time.h
  40. 1 1
      xs_unicode.h
  41. 1 1
      xs_unix_socket.h
  42. 24 1
      xs_url.h
  43. 1 1
      xs_version.h

+ 1 - 1
LICENSE

@@ -1,6 +1,6 @@
 MIT License
 
-Copyright (c) 2022 - 2024 grunfink et al. (Fediverse: @grunfink@comam.es)
+Copyright (c) 2022 - 2025 grunfink et al. (Fediverse: @grunfink@comam.es)
 
 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:
 

+ 2 - 1
Makefile

@@ -42,7 +42,8 @@ data.o: data.c xs.h xs_hex.h xs_io.h xs_json.h xs_openssl.h xs_glob.h \
 format.o: format.c xs.h xs_regex.h xs_mime.h xs_html.h xs_json.h \
  xs_time.h xs_match.h snac.h http_codes.h
 html.o: html.c xs.h xs_io.h xs_json.h xs_regex.h xs_set.h xs_openssl.h \
- xs_time.h xs_mime.h xs_match.h xs_html.h xs_curl.h snac.h http_codes.h
+ xs_time.h xs_mime.h xs_match.h xs_html.h xs_curl.h xs_unicode.h snac.h \
+ http_codes.h
 http.o: http.c xs.h xs_io.h xs_openssl.h xs_curl.h xs_time.h xs_json.h \
  snac.h http_codes.h
 httpd.o: httpd.c xs.h xs_io.h xs_json.h xs_socket.h xs_unix_socket.h \

+ 3 - 2
Makefile.NetBSD

@@ -5,7 +5,7 @@ LDFLAGS=-lrt
 
 all: snac
 
-snac: snac.o main.o data.o http.o httpd.o webfinger.o \
+snac: snac.o main.o sandbox.o data.o http.o httpd.o webfinger.o \
     activitypub.o html.o utils.o format.o upgrade.o mastoapi.o
 	$(CC) $(CFLAGS) -L/usr/pkg/lib *.o -lcurl -lcrypto -pthread $(LDFLAGS) -Wl,-rpath,/usr/lib -Wl,-rpath,/usr/pkg/lib -o $@
 
@@ -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 \
 format.o: format.c xs.h xs_regex.h xs_mime.h xs_html.h xs_json.h \
  xs_time.h xs_match.h snac.h http_codes.h
 html.o: html.c xs.h xs_io.h xs_json.h xs_regex.h xs_set.h xs_openssl.h \
- xs_time.h xs_mime.h xs_match.h xs_html.h xs_curl.h snac.h http_codes.h
+ xs_time.h xs_mime.h xs_match.h xs_html.h xs_curl.h xs_unicode.h snac.h \
+ http_codes.h
 http.o: http.c xs.h xs_io.h xs_openssl.h xs_curl.h xs_time.h xs_json.h \
  snac.h http_codes.h
 httpd.o: httpd.c xs.h xs_io.h xs_json.h xs_socket.h xs_unix_socket.h \

+ 29 - 1
RELEASE_NOTES.md

@@ -1,6 +1,34 @@
 # Release Notes
 
-## UNRELEASED
+## 2.69 "Yin/Yang of Love"
+
+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.
+
+Added support for following hashtags. This is only useful if your instance is subscribed to relays (see above).
+
+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)`).
+
+Some fixes to accept `Event` objects properly (like those coming from implementations like https://gancio.org/ or https://mobilizon.fr).
+
+Added some caching for local `Actor` objects.
+
+Hashtags that are not explicitly linked in a post's content are shown below it.
+
+Fixed broken NetBSD build (missing dependency in Makefile.NetBSD).
+
+The user profile can now include longitude and latitude data for your current location.
+
+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).
+
+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.
+
+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.
+
+Fixed a bug in the generation of the top page (contributed by an-im-dugud).
+
+Added support for Markdown headers and underlining (contributed by an-im-dugud).
+
+## 2.68
 
 Fixed regression in link verification code (contributed by nowster).
 

+ 3 - 3
TODO.md

@@ -22,7 +22,7 @@ Mastoapi: implement /v1/conversations.
 
 Implement following of hashtags (this is not trivial).
 
-Track 'Event' data types standardization; how to add plan-to-attend and similar activities (more info: https://event-federation.eu/)
+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).
 
 Implement "FEP-3b86: Activity Intents" https://codeberg.org/fediverse/fep/src/branch/main/fep/3b86/fep-3b86.md
 
@@ -32,8 +32,6 @@ Integrate "Added handling for International Domain Names" PR https://codeberg.or
 
 Do something about Akkoma and Misskey's quoted replies (they use the `quoteUrl` field instead of `inReplyTo`).
 
-Add support for /authorize_interaction (whatever it is).
-
 Add a list of hashtags to drop.
 
 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).
@@ -365,3 +363,5 @@ Unfollowing lemmy groups gets rejected with an http status of 400 (it seems to w
 CSV import/export does not work with OpenBSD security on; document it or fix it (2025-01-04T19:35:09+0100).
 
 Add support for /share?text=tt&website=url (whatever it is, see https://mastodonshare.com/ for details) (2025-01-06T18:43:52+0100).
+
+Add support for /authorize_interaction (whatever it is) (2025-01-16T14:45:28+0100).

+ 221 - 62
activitypub.c

@@ -1,5 +1,5 @@
 /* snac - A simple, minimalistic ActivityPub instance */
-/* copyright (c) 2022 - 2024 grunfink et al. / MIT license */
+/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */
 
 #include "xs.h"
 #include "xs_json.h"
@@ -587,6 +587,70 @@ int is_msg_from_private_user(const xs_dict *msg)
 }
 
 
+int followed_hashtag_check(snac *user, const xs_dict *msg)
+/* returns true if this message contains a hashtag followed by me */
+{
+    const xs_list *fw_tags = xs_dict_get(user->config, "followed_hashtags");
+
+    if (xs_is_list(fw_tags)) {
+        const xs_list *tags_in_msg = xs_dict_get(msg, "tag");
+
+        if (xs_is_list(tags_in_msg)) {
+            const xs_dict *te;
+
+            /* iterate the tags in the message */
+            xs_list_foreach(tags_in_msg, te) {
+                if (xs_is_dict(te)) {
+                    const char *type = xs_dict_get(te, "type");
+                    const char *name = xs_dict_get(te, "name");
+
+                    if (xs_is_string(type) && xs_is_string(name)) {
+                        if (strcmp(type, "Hashtag") == 0) {
+                            xs *lc_name = xs_utf8_to_lower(name);
+
+                            if (xs_list_in(fw_tags, lc_name) != -1)
+                                return 1;
+                        }
+                    }
+                }
+            }
+        }
+    }
+
+    return 0;
+}
+
+
+void followed_hashtag_distribute(const xs_dict *msg)
+/* distribute this post to all users following the included hashtags */
+{
+    const char *id = xs_dict_get(msg, "id");
+    const xs_list *tags_in_msg = xs_dict_get(msg, "tag");
+
+    if (!xs_is_string(id) || !xs_is_list(tags_in_msg) || xs_list_len(tags_in_msg) == 0)
+        return;
+
+    srv_debug(1, xs_fmt("followed_hashtag_distribute check for %s", id));
+
+    xs *users = user_list();
+    const char *uid;
+
+    xs_list_foreach(users, uid) {
+        snac user;
+
+        if (user_open(&user, uid)) {
+            if (followed_hashtag_check(&user, msg)) {
+                timeline_add(&user, id, msg);
+
+                snac_log(&user, xs_fmt("followed hashtag in %s", id));
+            }
+
+            user_free(&user);
+        }
+    }
+}
+
+
 int is_msg_for_me(snac *snac, const xs_dict *c_msg)
 /* checks if this message is for me */
 {
@@ -602,19 +666,32 @@ int is_msg_for_me(snac *snac, const xs_dict *c_msg)
     if (xs_match(type, "Like|Announce|EmojiReact")) {
         const char *object = xs_dict_get(c_msg, "object");
 
-        if (xs_type(object) == XSTYPE_DICT)
+        if (xs_is_dict(object))
             object = xs_dict_get(object, "id");
 
         /* bad object id? reject */
-        if (xs_type(object) != XSTYPE_STRING)
+        if (!xs_is_string(object))
             return 0;
 
         /* if it's about one of our posts, accept it */
         if (xs_startswith(object, snac->actor))
             return 2;
 
-        /* if it's by someone we don't follow, reject */
-        return following_check(snac, actor);
+        /* if it's by someone we follow, accept it */
+        if (following_check(snac, actor))
+            return 1;
+
+        /* do we follow any hashtag? */
+        if (xs_is_list(xs_dict_get(snac->config, "followed_hashtags"))) {
+            xs *obj = NULL;
+
+            /* if the admired object contains any followed hashtag, accept it */
+            if (valid_status(object_get(object, &obj)) &&
+                followed_hashtag_check(snac, obj))
+                return 7;
+        }
+
+        return 0;
     }
 
     /* if it's an Undo, it must be from someone related to us */
@@ -675,7 +752,7 @@ int is_msg_for_me(snac *snac, const xs_dict *c_msg)
 
         if (pub_msg) {
             /* a public message for someone we follow? (probably cc'ed) accept */
-            if (following_check(snac, v))
+            if (strcmp(v, public_address) != 0 && following_check(snac, v))
                 return 5;
         }
         else
@@ -708,30 +785,8 @@ int is_msg_for_me(snac *snac, const xs_dict *c_msg)
     }
 
     /* does this message contain a tag we are following? */
-    const xs_list *fw_tags = xs_dict_get(snac->config, "followed_hashtags");
-    if (pub_msg && xs_type(fw_tags) == XSTYPE_LIST) {
-        const xs_list *tags_in_msg = xs_dict_get(msg, "tag");
-        if (xs_type(tags_in_msg) == XSTYPE_LIST) {
-            const xs_dict *te;
-
-            /* iterate the tags in the message */
-            xs_list_foreach(tags_in_msg, te) {
-                if (xs_type(te) == XSTYPE_DICT) {
-                    const char *type = xs_dict_get(te, "type");
-                    const char *name = xs_dict_get(te, "name");
-
-                    if (xs_type(type) == XSTYPE_STRING && xs_type(name) == XSTYPE_STRING) {
-                        if (strcmp(type, "Hashtag") == 0) {
-                            xs *lc_name = xs_utf8_to_lower(name);
-
-                            if (xs_list_in(fw_tags, lc_name) != -1)
-                                return 7;
-                        }
-                    }
-                }
-            }
-        }
-    }
+    if (pub_msg && followed_hashtag_check(snac, msg))
+        return 7;
 
     return 0;
 }
@@ -889,6 +944,11 @@ void notify(snac *snac, const char *type, const char *utype, const char *actor,
         /* if it's not an admiration about something by us, done */
         if (xs_is_null(objid) || !xs_startswith(objid, snac->actor))
             return;
+
+        /* if it's an announce by our own relay, done */
+        xs *relay_id = xs_fmt("%s/relay", srv_baseurl);
+        if (xs_startswith(id, relay_id))
+            return;
     }
 
     /* updated poll? */
@@ -1184,6 +1244,28 @@ xs_dict *msg_repulsion(snac *user, const char *id, const char *type)
 }
 
 
+xs_dict *msg_actor_place(snac *user, const char *label)
+/* creates a Place object, if the user has a location defined */
+{
+    xs_dict *place = NULL;
+    const char *latitude = xs_dict_get_def(user->config, "latitude", "");
+    const char *longitude = xs_dict_get_def(user->config, "longitude", "");
+
+    if (*latitude && *longitude) {
+        xs *d_la = xs_number_new(atof(latitude));
+        xs *d_lo = xs_number_new(atof(longitude));
+
+        place = msg_base(user, "Place", NULL, user->actor, NULL, NULL);
+
+        place = xs_dict_set(place, "name", label);
+        place = xs_dict_set(place, "latitude", d_la);
+        place = xs_dict_set(place, "longitude", d_lo);
+    }
+
+    return place;
+}
+
+
 xs_dict *msg_actor(snac *snac)
 /* create a Person message for this actor */
 {
@@ -1194,10 +1276,20 @@ xs_dict *msg_actor(snac *snac)
     xs *avtr     = NULL;
     xs *kid      = NULL;
     xs *f_bio    = NULL;
-    xs_dict *msg = msg_base(snac, "Person", snac->actor, NULL, NULL, NULL);
+    xs_dict *msg = NULL;
     const char *p;
     int n;
 
+    /* everybody loves some caching */
+    if (time(NULL) - object_mtime(snac->actor) < 3 * 3600 &&
+        valid_status(object_get(snac->actor, &msg))) {
+        snac_debug(snac, 2, xs_fmt("Returning cached actor %s", snac->actor));
+
+        return msg;
+    }
+
+    msg = msg_base(snac, "Person", snac->actor, NULL, NULL, NULL);
+
     /* change the @context (is this really necessary?) */
     ctxt = xs_list_append(ctxt, "https:/" "/www.w3.org/ns/activitystreams");
     ctxt = xs_list_append(ctxt, "https:/" "/w3id.org/security/v1");
@@ -1242,6 +1334,10 @@ xs_dict *msg_actor(snac *snac)
     if (xs_type(xs_dict_get(snac->config, "bot")) == XSTYPE_TRUE)
         msg = xs_dict_set(msg, "type", "Service");
 
+    /* if it's named "relay", then identify as an "Application" */
+    if (strcmp(snac->uid, "relay") == 0)
+        msg = xs_dict_set(msg, "type", "Application");
+
     /* add the header image, if there is one defined */
     const char *header = xs_dict_get(snac->config, "header");
     if (!xs_is_null(header)) {
@@ -1307,7 +1403,7 @@ xs_dict *msg_actor(snac *snac)
     }
 
     /* use shared inboxes? */
-    if (xs_type(xs_dict_get(srv_config, "shared_inboxes")) == XSTYPE_TRUE) {
+    if (xs_is_true(xs_dict_get(srv_config, "shared_inboxes")) || strcmp(snac->uid, "relay") == 0) {
         xs *d = xs_dict_new();
         xs *si = xs_fmt("%s/shared-inbox", srv_baseurl);
         d = xs_dict_append(d, "sharedInbox", si);
@@ -1326,6 +1422,15 @@ xs_dict *msg_actor(snac *snac)
     msg = xs_dict_set(msg, "manuallyApprovesFollowers",
         xs_stock(xs_is_true(manually) ? XSTYPE_TRUE : XSTYPE_FALSE));
 
+    /* if there are location coords, create a Place object */
+    xs *location = msg_actor_place(snac, "Home");
+    if (xs_type(location) == XSTYPE_DICT)
+        msg = xs_dict_set(msg, "location", location);
+
+    /* cache it */
+    snac_debug(snac, 1, xs_fmt("Caching actor %s", snac->actor));
+    object_add_ow(snac->actor, msg);
+
     return msg;
 }
 
@@ -1423,8 +1528,9 @@ xs_dict *msg_follow(snac *snac, const char *q)
 
 xs_dict *msg_note(snac *snac, const xs_str *content, const xs_val *rcpts,
                   const xs_str *in_reply_to, const xs_list *attach,
-                  int priv, const char *lang_str)
+                  int scope, const char *lang_str)
 /* creates a 'Note' message */
+/* scope: 0, public; 1, private (mentioned only); 2, "quiet public"; 3, followers only */
 {
     xs *ntid = tid(0);
     xs *id   = xs_fmt("%s/p/%s", snac->actor, ntid);
@@ -1440,6 +1546,9 @@ xs_dict *msg_note(snac *snac, const xs_str *content, const xs_val *rcpts,
     xs_list *p;
     const xs_val *v;
 
+    /* FIXME: implement scope 3 */
+    int priv = scope == 1;
+
     if (rcpts == NULL)
         to = xs_list_new();
     else {
@@ -1557,6 +1666,12 @@ xs_dict *msg_note(snac *snac, const xs_str *content, const xs_val *rcpts,
         }
     }
 
+    if (scope == 2) {
+        /* Mastodon's "quiet public": add public address to cc */
+        if (xs_list_in(cc, public_address) == -1)
+            cc = xs_list_append(cc, public_address);
+    }
+    else
     /* no recipients? must be for everybody */
     if (!priv && xs_list_len(to) == 0)
         to = xs_list_append(to, public_address);
@@ -1845,6 +1960,17 @@ int process_input_message(snac *snac, const xs_dict *msg, const xs_dict *req)
     /* reject uninteresting messages right now */
     if (xs_match(type, "Add|View|Reject|Read|Remove")) {
         srv_debug(0, xs_fmt("Ignored message of type '%s'", type));
+
+        /* archive the ignored activity */
+        xs *ntid = tid(0);
+        xs *fn = xs_fmt("%s/ignored/%s.json", srv_basedir, ntid);
+        FILE *f;
+
+        if ((f = fopen(fn, "w")) != NULL) {
+            xs_json_dump(msg, 4, f);
+            fclose(f);
+        }
+
         return -1;
     }
 
@@ -2118,14 +2244,14 @@ int process_input_message(snac *snac, const xs_dict *msg, const xs_dict *req)
                 snac_log(snac, xs_fmt("new 'Question' %s %s", actor, id));
         }
         else
-        if (strcmp(utype, "Video") == 0) { /** **/
+        if (xs_match(utype, "Audio|Video|Event")) { /** **/
             const char *id = xs_dict_get(object, "id");
 
             if (xs_is_null(id))
                 snac_log(snac, xs_fmt("malformed message: no 'id' field"));
             else
             if (timeline_add(snac, id, object))
-                snac_log(snac, xs_fmt("new 'Video' %s %s", actor, id));
+                snac_log(snac, xs_fmt("new '%s' %s %s", utype, actor, id));
         }
         else
             snac_debug(snac, 1, xs_fmt("ignored 'Create' for object type '%s'", utype));
@@ -2203,15 +2329,23 @@ int process_input_message(snac *snac, const xs_dict *msg, const xs_dict *req)
                     xs *who_o = NULL;
 
                     if (valid_status(actor_request(snac, who, &who_o))) {
-                        if (timeline_admire(snac, object, actor, 0) == HTTP_STATUS_CREATED)
-                            snac_log(snac, xs_fmt("new 'Announce' %s %s", actor, object));
-                        else
-                            snac_log(snac, xs_fmt("repeated 'Announce' from %s to %s",
-                                actor, object));
+                        /* don't account as such announces by our own relay */
+                        xs *this_relay = xs_fmt("%s/relay", srv_baseurl);
+
+                        if (strcmp(actor, this_relay) != 0) {
+                            if (timeline_admire(snac, object, actor, 0) == HTTP_STATUS_CREATED)
+                                snac_log(snac, xs_fmt("new 'Announce' %s %s", actor, object));
+                            else
+                                snac_log(snac, xs_fmt("repeated 'Announce' from %s to %s",
+                                    actor, object));
+                        }
 
                         /* distribute the post with the actor as 'proxy' */
                         list_distribute(snac, actor, a_msg);
 
+                        /* distribute the post to users following these hashtags */
+                        followed_hashtag_distribute(a_msg);
+
                         do_notify = 1;
                     }
                     else
@@ -2226,14 +2360,14 @@ int process_input_message(snac *snac, const xs_dict *msg, const xs_dict *req)
     }
     else
     if (strcmp(type, "Update") == 0) { /** **/
-        if (xs_match(utype, "Person|Service")) { /** **/
+        if (xs_match(utype, "Person|Service|Application")) { /** **/
             actor_add(actor, xs_dict_get(msg, "object"));
             timeline_touch(snac);
 
             snac_log(snac, xs_fmt("updated actor %s", actor));
         }
         else
-        if (xs_match(utype, "Note|Page|Article|Video")) { /** **/
+        if (xs_match(utype, "Note|Page|Article|Video|Audio|Event")) { /** **/
             const char *id = xs_dict_get(object, "id");
 
             if (xs_is_null(id))
@@ -2419,7 +2553,7 @@ int send_email(const char *msg)
 }
 
 
-void process_user_queue_item(snac *snac, xs_dict *q_item)
+void process_user_queue_item(snac *user, xs_dict *q_item)
 /* processes an item from the user queue */
 {
     const char *type;
@@ -2430,7 +2564,7 @@ void process_user_queue_item(snac *snac, xs_dict *q_item)
 
     if (strcmp(type, "message") == 0) {
         const xs_dict *msg = xs_dict_get(q_item, "message");
-        xs *rcpts = recipient_list(snac, msg, 1);
+        xs *rcpts = recipient_list(user, msg, 1);
         xs_set inboxes;
         const xs_str *actor;
 
@@ -2439,7 +2573,7 @@ void process_user_queue_item(snac *snac, xs_dict *q_item)
         /* add this shared inbox first */
         xs *this_shared_inbox = xs_fmt("%s/shared-inbox", srv_baseurl);
         xs_set_add(&inboxes, this_shared_inbox);
-        enqueue_output(snac, msg, this_shared_inbox, 0, 0);
+        enqueue_output(user, msg, this_shared_inbox, 0, 0);
 
         /* iterate the recipients */
         xs_list_foreach(rcpts, actor) {
@@ -2450,10 +2584,10 @@ void process_user_queue_item(snac *snac, xs_dict *q_item)
                 if (inbox != NULL) {
                     /* add to the set and, if it's not there, send message */
                     if (xs_set_add(&inboxes, inbox) == 1)
-                        enqueue_output(snac, msg, inbox, 0, 0);
+                        enqueue_output(user, msg, inbox, 0, 0);
                 }
                 else
-                    snac_log(snac, xs_fmt("cannot find inbox for %s", actor));
+                    snac_log(user, xs_fmt("cannot find inbox for %s", actor));
             }
         }
 
@@ -2465,12 +2599,36 @@ void process_user_queue_item(snac *snac, xs_dict *q_item)
 
                 xs_list_foreach(shibx, inbox) {
                     if (xs_set_add(&inboxes, inbox) == 1)
-                        enqueue_output(snac, msg, inbox, 0, 0);
+                        enqueue_output(user, msg, inbox, 0, 0);
                 }
             }
         }
 
         xs_set_free(&inboxes);
+
+        /* relay this note */
+        if (is_msg_public(msg) && strcmp(user->uid, "relay") != 0) { /* avoid loops */
+            snac relay;
+            if (user_open(&relay, "relay")) {
+                /* a 'relay' user exists */
+                const char *type = xs_dict_get(msg, "type");
+
+                if (xs_is_string(type) && strcmp(type, "Create") == 0) {
+                    const xs_val *object = xs_dict_get(msg, "object");
+
+                    if (xs_is_dict(object)) {
+                        object = xs_dict_get(object, "id");
+
+                        snac_debug(&relay, 1, xs_fmt("relaying message %s", object));
+
+                        xs *boost = msg_admiration(&relay, object, "Announce");
+                        enqueue_message(&relay, boost);
+                    }
+                }
+
+                user_free(&relay);
+            }
+        }
     }
     else
     if (strcmp(type, "input") == 0) {
@@ -2482,13 +2640,13 @@ void process_user_queue_item(snac *snac, xs_dict *q_item)
         if (xs_is_null(msg))
             return;
 
-        if (!process_input_message(snac, msg, req)) {
+        if (!process_input_message(user, msg, req)) {
             if (retries > queue_retry_max)
-                snac_log(snac, xs_fmt("input giving up"));
+                snac_log(user, xs_fmt("input giving up"));
             else {
                 /* reenqueue */
-                enqueue_input(snac, msg, req, retries + 1);
-                snac_log(snac, xs_fmt("input requeue #%d", retries + 1));
+                enqueue_input(user, msg, req, retries + 1);
+                snac_log(user, xs_fmt("input requeue #%d", retries + 1));
             }
         }
     }
@@ -2498,7 +2656,7 @@ void process_user_queue_item(snac *snac, xs_dict *q_item)
         const char *id = xs_dict_get(q_item, "message");
 
         if (!xs_is_null(id))
-            update_question(snac, id);
+            update_question(user, id);
     }
     else
     if (strcmp(type, "object_request") == 0) {
@@ -2508,17 +2666,17 @@ void process_user_queue_item(snac *snac, xs_dict *q_item)
             int status;
             xs *data = NULL;
 
-            status = activitypub_request(snac, id, &data);
+            status = activitypub_request(user, id, &data);
 
             if (valid_status(status))
                 object_add_ow(id, data);
 
-            snac_debug(snac, 1, xs_fmt("object_request %s %d", id, status));
+            snac_debug(user, 1, xs_fmt("object_request %s %d", id, status));
         }
     }
     else
     if (strcmp(type, "verify_links") == 0) {
-        verify_links(snac);
+        verify_links(user);
     }
     else
     if (strcmp(type, "actor_refresh") == 0) {
@@ -2530,16 +2688,16 @@ void process_user_queue_item(snac *snac, xs_dict *q_item)
             xs *actor_o = NULL;
             int status;
 
-            if (valid_status((status = activitypub_request(snac, actor, &actor_o))))
+            if (valid_status((status = activitypub_request(user, actor, &actor_o))))
                 actor_add(actor, actor_o);
             else
                 object_touch(actor);
 
-            snac_log(snac, xs_fmt("actor_refresh %s %d", actor, status));
+            snac_log(user, xs_fmt("actor_refresh %s %d", actor, status));
         }
     }
     else
-        snac_log(snac, xs_fmt("unexpected user q_item type '%s'", type));
+        snac_log(user, xs_fmt("unexpected user q_item type '%s'", type));
 }
 
 
@@ -2640,7 +2798,7 @@ void process_queue_item(xs_dict *q_item)
                 || status == HTTP_STATUS_UNPROCESSABLE_CONTENT
                 || status < 0)
                 /* explicit error: discard */
-                srv_log(xs_fmt("output message: fatal error %s %d", inbox, status));
+                srv_log(xs_fmt("output message: error %s %d", inbox, status));
             else
             if (retries > queue_retry_max)
                 srv_log(xs_fmt("output message: giving up %s %d", inbox, status));
@@ -2769,11 +2927,12 @@ void process_queue_item(xs_dict *q_item)
                 snac user;
 
                 if (user_open(&user, v)) {
-                    if (is_msg_for_me(&user, msg)) {
+                    int rsn = is_msg_for_me(&user, msg);
+                    if (rsn) {
                         xs *fn = xs_fmt("%s/queue/%s.json", user.basedir, ntid);
 
                         snac_debug(&user, 1,
-                            xs_fmt("enqueue_input (from shared inbox) %s", xs_dict_get(msg, "id")));
+                            xs_fmt("enqueue_input (from shared inbox) %s [%d]", xs_dict_get(msg, "id"), rsn));
 
                         if (link(tmpfn, fn) < 0)
                             srv_log(xs_fmt("link(%s, %s) error", tmpfn, fn));

+ 55 - 8
data.c

@@ -1,5 +1,5 @@
 /* snac - A simple, minimalistic ActivityPub instance */
-/* copyright (c) 2022 - 2024 grunfink et al. / MIT license */
+/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */
 
 #include "xs.h"
 #include "xs_hex.h"
@@ -319,7 +319,8 @@ int user_persist(snac *snac, int publish)
 
             if (old != NULL) {
                 int nw = 0;
-                const char *fields[] = { "header", "avatar", "name", "bio", "metadata", NULL };
+                const char *fields[] = { "header", "avatar", "name", "bio",
+                                         "metadata", "latitude", "longitude", NULL };
 
                 for (int n = 0; fields[n]; n++) {
                     const char *of = xs_dict_get(old, fields[n]);
@@ -336,6 +337,10 @@ int user_persist(snac *snac, int publish)
 
                 if (!nw)
                     publish = 0;
+                else {
+                    /* uncache the actor object */
+                    object_del(snac->actor);
+                }
             }
         }
     }
@@ -674,6 +679,37 @@ int index_desc_first(FILE *f, char md5[MD5_HEX_SIZE], int skip)
     return 1;
 }
 
+int index_asc_first(FILE *f,char md5[MD5_HEX_SIZE], const char *seek_md5)
+/* reads the first entry of an ascending index, starting from a given md5 */
+{
+    fseek(f, SEEK_SET, 0);
+    while (fread(md5, MD5_HEX_SIZE, 1, f)) {
+        md5[MD5_HEX_SIZE - 1] = '\0';
+        if (strcmp(md5,seek_md5) == 0) {
+            return index_asc_next(f, md5);
+        }
+    }
+    return 0;
+}
+
+int index_asc_next(FILE *f, char md5[MD5_HEX_SIZE])
+/* reads the next entry of an ascending index */
+{
+    for (;;) {
+        /* read an md5 */
+        if (!fread(md5, MD5_HEX_SIZE, 1, f))
+            return 0;
+
+        /* deleted, skip */
+        if (md5[0] != '-')
+            break;
+    }
+
+    md5[MD5_HEX_SIZE - 1] = '\0';
+
+    return 1;
+}
+
 
 xs_list *index_list_desc(const char *fn, int skip, int show)
 /* returns an index as a list, in reverse order */
@@ -1363,11 +1399,13 @@ void timeline_update_indexes(snac *snac, const char *id)
         if (valid_status(object_get(id, &msg))) {
             /* if its ours and is public, also store in public */
             if (is_msg_public(msg)) {
-                object_user_cache_add(snac, id, "public");
-
-                /* also add it to the instance public timeline */
-                xs *ipt = xs_fmt("%s/public.idx", srv_basedir);
-                index_add(ipt, id);
+                if (object_user_cache_add(snac, id, "public") >= 0) {
+                    /* also add it to the instance public timeline */
+                    xs *ipt = xs_fmt("%s/public.idx", srv_basedir);
+                    index_add(ipt, id);
+                }
+                else
+                    srv_debug(1, xs_fmt("Not added to public instance index %s", id));
             }
         }
     }
@@ -1488,8 +1526,17 @@ xs_list *timeline_instance_list(int skip, int show)
 /* returns the timeline for the full instance */
 {
     xs *idx = instance_index_fn();
+    xs *lst = index_list_desc(idx, skip, show);
 
-    return index_list_desc(idx, skip, show);
+    /* make the list unique */
+    xs_set rep;
+    xs_set_init(&rep);
+    const char *md5;
+
+    xs_list_foreach(lst, md5)
+        xs_set_add(&rep, md5);
+
+    return xs_set_result(&rep);
 }
 
 

+ 8 - 1
doc/snac.1

@@ -256,7 +256,7 @@ it's - (a lonely hyphen), the post content will be read from stdin.
 The rest of command line arguments are treated as media files to be
 attached to the post.
 .It Cm note_unlisted Ar basedir Ar uid Ar text Op file file ...
-Like the previous one, but creates an "unlisted" (or "quiet public") one.
+Like the previous one, but creates an "unlisted" (or "quiet public") post.
 .It Cm block Ar basedir Ar instance_url
 Blocks a full instance, given its URL or domain name. All subsequent
 incoming activities with identifiers from that instance will be immediately
@@ -377,6 +377,13 @@ https://$SNAC_HOST/oauth/x-snac-get-token
 .Pp
 .Sh ENVIRONMENT
 .Bl -tag -width Ds
+.It SNAC_BASEDIR
+This optional environment variable can be set to the base directory of
+your installation; if set, you don't have to add the base directory as an
+argument to command-line operations. This may prove useful if you only
+have one
+.Nm
+instance in you system (which is probably your case).
 .It Ev DEBUG
 Overrides the debugging level from the server 'dbglevel' configuration
 variable. Set it to an integer value. The higher, the deeper in meaningless

+ 6 - 1
doc/snac.5

@@ -24,9 +24,11 @@ A special subset of Markdown is allowed, including:
 .It bold
 **text between two pairs of asterisks**
 .It italic
-*text between a pair of asterisks*
+*text between a pair of asterisks* or _between a pair of underscores_
 .It strikethrough text
 ~~text between a pair of tildes~~
+.It underlined text
+__text between two pairs of underscores__
 .It code
 Text `between backticks` is formatted as code.
 .Bd -literal
@@ -53,6 +55,9 @@ Horizonal rules can be inserted by typing three minus symbols
 alone in a line.
 .It quoted text
 Lines starting with >.
+.It headers
+One, two or three # at the beginning of a line plus a space plus
+some text are converted to HTML headers.
 .It user mentions
 Strings in the format @user@host are requested using the Webfinger
 protocol and converted to links and mentions if something reasonable

+ 40 - 0
doc/snac.8

@@ -585,6 +585,31 @@ to pass the remote connection address in the
 .Ic X-Forwarded-For
 HTTP header (unless you use the FastCGI interface; if that's the case, you don't have
 to do anything).
+.Pp
+.Ss Subscribing to Fediverse Relays
+Since version 2.69, a
+.Nm
+instance can subscribe to LitePub (Pleroma-style) Fediverse Relays. Doing this improves
+visibility and allows following hashtags. To do this, you must create a special user named
+relay and, from it, follow the relay actor(s) like you do with regular actor URLs. This
+special user will start receiving boosts from the relay server of posts from other instances
+also following it. If any other user of the same
+.Nm
+instance follows any of the hashtags included in these boosted posts coming from the relay,
+they will be received as if they were for them.
+.Pp
+Example:
+.Bd -literal -offset indent
+snac adduser $SNAC_BASEDIR relay # only needed once
+snac follow $SNAC_BASEDIR relay https://relay.example.com/actor
+.Ed
+.Pp
+Users on your instance do NOT need to follow the local relay user to benefit from following
+hashtags.
+.Pp
+Please take note that subscribing to relays can increase the traffic towards your instance
+significantly. In any case, lowering the "Maximum days to keep posts" value for the relay
+special user is recommended (e.g. setting to just 1 day).
 .Sh ENVIRONMENT
 .Bl -tag -width Ds
 .It Ev DEBUG
@@ -685,6 +710,12 @@ location /share {
     proxy_set_header Host $http_host;
     proxy_set_header X-Forwarded-For $remote_addr;
 }
+# optional (Mastodon-like "authorize interaction" entrypoint)
+location /authorize_interaction {
+    proxy_pass http://localhost:8001;
+    proxy_set_header Host $http_host;
+    proxy_set_header X-Forwarded-For $remote_addr;
+}
 .Ed
 .Pp
 Restart the nginx daemon and connect to
@@ -738,6 +769,11 @@ ProxyPreserveHost On
 <Location /share>
     ProxyPass http://127.0.0.1:8001/share
 </Location>
+
+# optional (Mastodon-like "authorize interaction" entrypoint)
+<Location /authorize_interaction>
+    ProxyPass http://127.0.0.1:8001/share
+</Location>
 .Ed
 .Pp
 Since version 2.43,
@@ -797,6 +833,10 @@ location "/.well-known/host-meta" {
 location "/share" {
     fastcgi socket tcp "127.0.0.1" 8001
 }
+
+location "/authorize_interaction" {
+    fastcgi socket tcp "127.0.0.1" 8001
+}
 .Ed
 .Sh SEE ALSO
 .Xr snac 1 ,

+ 47 - 2
format.c

@@ -1,5 +1,5 @@
 /* snac - A simple, minimalistic ActivityPub instance */
-/* copyright (c) 2022 - 2024 grunfink et al. / MIT license */
+/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */
 
 #include "xs.h"
 #include "xs_regex.h"
@@ -92,6 +92,8 @@ static xs_str *format_line(const char *line, xs_list **attach)
             "`[^`]+`"                           "|"
             "~~[^~]+~~"                         "|"
             "\\*\\*?\\*?[^\\*]+\\*?\\*?\\*"     "|"
+            "_[^_]+_"                           "|" //anzu
+            "__[^_]+__"                         "|" //anzu
             "!\\[[^]]+\\]\\([^\\)]+\\)"         "|"
             "\\[[^]]+\\]\\([^\\)]+\\)"          "|"
             "[a-z]+:/" "/[^[:space:]]+"         "|"
@@ -127,6 +129,20 @@ static xs_str *format_line(const char *line, xs_list **attach)
                 xs *s2 = xs_fmt("<i>%s</i>", s1);
                 s = xs_str_cat(s, s2);
             }
+            //anzu - begin
+            else
+            if (xs_startswith(v, "__")) {
+                xs *s1 = xs_strip_chars_i(xs_dup(v), "_");
+                xs *s2 = xs_fmt("<u>%s</u>", s1);
+                s = xs_str_cat(s, s2);
+            }
+            else
+            if (xs_startswith(v, "_")) {
+                xs *s1 = xs_strip_chars_i(xs_dup(v), "_");
+                xs *s2 = xs_fmt("<i>%s</i>", s1);
+                s = xs_str_cat(s, s2);
+            }
+            //anzu - end
             else
             if (xs_startswith(v, "~~")) {
                 xs *s1 = xs_strip_chars_i(xs_dup(v), "~");
@@ -303,6 +319,31 @@ xs_str *not_really_markdown(const char *content, xs_list **attach, xs_list **tag
             continue;
         }
 
+        //anzu - begin
+        // h1 reserved for snac?
+        if (xs_startswith(ss, "# ")) {
+            ss = xs_strip_i(xs_crop_i(ss, 2, 0));
+            s = xs_str_cat(s, "<h2>");
+            s = xs_str_cat(s, ss);
+            s = xs_str_cat(s, "</h2>");
+            continue;
+        }
+        if (xs_startswith(ss, "## ")) {
+            ss = xs_strip_i(xs_crop_i(ss, 3, 0));
+            s = xs_str_cat(s, "<h2>");
+            s = xs_str_cat(s, ss);
+            s = xs_str_cat(s, "</h2>");
+            continue;
+        }
+        if (xs_startswith(ss, "### ")) {
+            ss = xs_strip_i(xs_crop_i(ss, 4, 0));
+            s = xs_str_cat(s, "<h3>");
+            s = xs_str_cat(s, ss);
+            s = xs_str_cat(s, "</h3>");
+            continue;
+        }
+        //anzu - end
+
         if (xs_startswith(ss, ">")) {
             /* delete the > and subsequent spaces */
             ss = xs_strip_i(xs_crop_i(ss, 1, 0));
@@ -336,6 +377,8 @@ xs_str *not_really_markdown(const char *content, xs_list **attach, xs_list **tag
     s = xs_replace_i(s, "<br><br><blockquote>", "<br><blockquote>");
     s = xs_replace_i(s, "</blockquote><br>", "</blockquote>");
     s = xs_replace_i(s, "</pre><br>", "</pre>");
+    s = xs_replace_i(s, "</h2><br>", "</h2>"); //anzu ???
+    s = xs_replace_i(s, "</h3><br>", "</h3>"); //anzu ???
 
     {
         /* traditional emoticons */
@@ -378,7 +421,9 @@ xs_str *not_really_markdown(const char *content, xs_list **attach, xs_list **tag
 
 const char *valid_tags[] = {
     "a", "p", "br", "br/", "blockquote", "ul", "ol", "li", "cite", "small",
-    "span", "i", "b", "u", "s", "pre", "code", "em", "strong", "hr", "img", "del", "bdi", NULL
+    "span", "i", "b", "u", "s", "pre", "code", "em", "strong", "hr", "img", "del", "bdi",
+    "h2","h3", //anzu
+    NULL
 };
 
 xs_str *sanitize(const char *content)

+ 374 - 19
html.c

@@ -1,5 +1,5 @@
 /* snac - A simple, minimalistic ActivityPub instance */
-/* copyright (c) 2022 - 2024 grunfink et al. / MIT license */
+/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */
 
 #include "xs.h"
 #include "xs_io.h"
@@ -12,6 +12,7 @@
 #include "xs_match.h"
 #include "xs_html.h"
 #include "xs_curl.h"
+#include "xs_unicode.h"
 
 #include "snac.h"
 
@@ -113,7 +114,8 @@ xs_str *actor_name(xs_dict *actor, const char *proxy)
 
 
 xs_html *html_actor_icon(snac *user, xs_dict *actor, const char *date,
-                        const char *udate, const char *url, int priv, int in_people, const char *proxy)
+                        const char *udate, const char *url, int priv,
+                        int in_people, const char *proxy, const char *lang)
 {
     xs_html *actor_icon = xs_html_tag("p", NULL);
 
@@ -219,6 +221,9 @@ xs_html *html_actor_icon(snac *user, xs_dict *actor, const char *date,
             date_title = xs_str_cat(date_title, " / ", udate);
         }
 
+        if (xs_is_string(lang))
+            date_title = xs_str_cat(date_title, " (", lang, ")");
+
         xs_html_add(actor_icon,
             xs_html_text(" "),
             xs_html_tag("time",
@@ -265,6 +270,7 @@ xs_html *html_msg_icon(snac *user, const char *actor_id, const xs_dict *msg, con
         const char *date  = NULL;
         const char *udate = NULL;
         const char *url   = NULL;
+        const char *lang  = NULL;
         int priv    = 0;
         const char *type = xs_dict_get(msg, "type");
 
@@ -276,7 +282,17 @@ xs_html *html_msg_icon(snac *user, const char *actor_id, const xs_dict *msg, con
         date  = xs_dict_get(msg, "published");
         udate = xs_dict_get(msg, "updated");
 
-        actor_icon = html_actor_icon(user, actor, date, udate, url, priv, 0, proxy);
+        lang = xs_dict_get(msg, "contentMap");
+        if (xs_is_dict(lang)) {
+            const char *v;
+            int c = 0;
+
+            xs_dict_next(lang, &lang, &v, &c);
+        }
+        else
+            lang = NULL;
+
+        actor_icon = html_actor_icon(user, actor, date, udate, url, priv, 0, proxy, lang);
     }
 
     return actor_icon;
@@ -933,6 +949,7 @@ static xs_html *html_user_body(snac *user, int read_only)
                             xs_html_raw("&#10004; "),
                             xs_html_tag("a",
                                 xs_html_attr("rel", "me"),
+                                xs_html_attr("target", "_blank"),
                                 xs_html_attr("href", v),
                                 xs_html_text(v)));
                     }
@@ -969,6 +986,23 @@ static xs_html *html_user_body(snac *user, int read_only)
                 snac_metadata);
         }
 
+        const char *latitude = xs_dict_get_def(user->config, "latitude", "");
+        const char *longitude = xs_dict_get_def(user->config, "longitude", "");
+
+        if (*latitude && *longitude) {
+            xs *label = xs_fmt(L("%s,%s"), latitude, longitude);
+            xs *url   = xs_fmt(L("https://openstreetmap.org/search?query=%s,%s"),
+                        latitude, longitude);
+
+            xs_html_add(top_user,
+                xs_html_tag("p",
+                    xs_html_text(L("Location: ")),
+                    xs_html_tag("a",
+                        xs_html_attr("href", url),
+                        xs_html_attr("target", "_blank"),
+                        xs_html_text(label))));
+        }
+
         if (xs_is_true(xs_dict_get(user->config, "show_contact_metrics"))) {
             xs *fwers = follower_list(user);
             xs *fwing = following_list(user);
@@ -1110,6 +1144,8 @@ xs_html *html_top_controls(snac *snac)
     const xs_val *coll_thrds = xs_dict_get(snac->config, "collapse_threads");
     const xs_val *pending    = xs_dict_get(snac->config, "approve_followers");
     const xs_val *show_foll  = xs_dict_get(snac->config, "show_contact_metrics");
+    const char *latitude     = xs_dict_get_def(snac->config, "latitude", "");
+    const char *longitude    = xs_dict_get_def(snac->config, "longitude", "");
 
     xs *metadata = NULL;
     const xs_dict *md = xs_dict_get(snac->config, "metadata");
@@ -1299,6 +1335,20 @@ xs_html *html_top_controls(snac *snac)
                     xs_html_tag("label",
                         xs_html_attr("for", "show_contact_metrics"),
                         xs_html_text(L("Publish follower and following metrics")))),
+                xs_html_tag("p",
+                    xs_html_text(L("Current location:")),
+                    xs_html_sctag("br", NULL),
+                    xs_html_sctag("input",
+                        xs_html_attr("type", "text"),
+                        xs_html_attr("name", "latitude"),
+                        xs_html_attr("value", latitude),
+                        xs_html_attr("placeholder", "latitude")),
+                    xs_html_text(" "),
+                    xs_html_sctag("input",
+                        xs_html_attr("type", "text"),
+                        xs_html_attr("name", "longitude"),
+                        xs_html_attr("value", longitude),
+                        xs_html_attr("placeholder", "longitude"))),
                 xs_html_tag("p",
                     xs_html_text(L("Profile metadata (key=value pairs in each line):")),
                     xs_html_sctag("br", NULL),
@@ -1328,7 +1378,41 @@ xs_html *html_top_controls(snac *snac)
                 xs_html_sctag("input",
                     xs_html_attr("type", "submit"),
                     xs_html_attr("class", "button"),
-                    xs_html_attr("value", L("Update user info")))))));
+                    xs_html_attr("value", L("Update user info"))),
+
+                xs_html_tag("p", NULL)))));
+
+    xs *followed_hashtags_action = xs_fmt("%s/admin/followed-hashtags", snac->actor);
+    xs *followed_hashtags = xs_join(xs_dict_get_def(snac->config,
+                        "followed_hashtags", xs_stock(XSTYPE_LIST)), "\n");
+
+    xs_html_add(top_controls,
+        xs_html_tag("details",
+        xs_html_tag("summary",
+            xs_html_text(L("Followed hashtags..."))),
+        xs_html_tag("p",
+            xs_html_text(L("One hashtag per line"))),
+        xs_html_tag("div",
+            xs_html_attr("class", "snac-followed-hashtags"),
+            xs_html_tag("form",
+                xs_html_attr("autocomplete", "off"),
+                xs_html_attr("method", "post"),
+                xs_html_attr("action", followed_hashtags_action),
+                xs_html_attr("enctype", "multipart/form-data"),
+
+                xs_html_tag("textarea",
+                    xs_html_attr("name", "followed_hashtags"),
+                    xs_html_attr("cols", "40"),
+                    xs_html_attr("rows", "4"),
+                    xs_html_attr("placeholder", "#cats\n#windowfriday\n#classicalmusic"),
+                    xs_html_text(followed_hashtags)),
+
+                xs_html_tag("br", NULL),
+
+                xs_html_sctag("input",
+                    xs_html_attr("type", "submit"),
+                    xs_html_attr("class", "button"),
+                    xs_html_attr("value", L("Update hashtags")))))));
 
     return top_controls;
 }
@@ -1781,13 +1865,15 @@ xs_html *html_entry(snac *user, xs_dict *msg, int read_only,
             }
         }
     }
-    else
-    if (strcmp(type, "Note") == 0) {
-        if (level == 0) {
-            /* is the parent not here? */
-            const char *parent = get_in_reply_to(msg);
 
-            if (user && !xs_is_null(parent) && *parent && !timeline_here(user, parent)) {
+    if (user && strcmp(type, "Note") == 0) {
+        /* is the parent not here? */
+        const char *parent = get_in_reply_to(msg);
+
+        if (!xs_is_null(parent) && *parent) {
+            xs *md5 = xs_md5_hex(parent, strlen(parent));
+
+            if (!timeline_here(user, md5)) {
                 xs_html_add(post_header,
                     xs_html_tag("div",
                         xs_html_attr("class", "snac-origin"),
@@ -2199,6 +2285,135 @@ xs_html *html_entry(snac *user, xs_dict *msg, int read_only,
             au_tag);
     }
 
+    /* does it have a location? */
+    const xs_dict *location = xs_dict_get(msg, "location");
+    if (xs_type(location) == XSTYPE_DICT) {
+        const xs_number *latitude = xs_dict_get(location, "latitude");
+        const xs_number *longitude = xs_dict_get(location, "longitude");
+        const char *name = xs_dict_get(location, "name");
+        const char *address = xs_dict_get(location, "address");
+        xs *label_list = xs_list_new();
+
+        if (xs_type(name) == XSTYPE_STRING)
+            label_list = xs_list_append(label_list, name);
+        if (xs_type(address) == XSTYPE_STRING)
+            label_list = xs_list_append(label_list, address);
+
+        if (xs_list_len(label_list)) {
+            const char *url = xs_dict_get(location, "url");
+            xs *label = xs_join(label_list, ", ");
+
+            if (xs_type(url) == XSTYPE_STRING) {
+                xs_html_add(snac_content_wrap,
+                    xs_html_tag("p",
+                        xs_html_text(L("Location: ")),
+                        xs_html_tag("a",
+                            xs_html_attr("href", url),
+                            xs_html_attr("target", "_blank"),
+                            xs_html_text(label))));
+            }
+            else
+            if (!xs_is_null(latitude) && !xs_is_null(longitude)) {
+                xs *url = xs_fmt("https://openstreetmap.org/search/?query=%s,%s",
+                    xs_number_str(latitude), xs_number_str(longitude));
+
+                xs_html_add(snac_content_wrap,
+                    xs_html_tag("p",
+                        xs_html_text(L("Location: ")),
+                        xs_html_tag("a",
+                            xs_html_attr("href", url),
+                            xs_html_attr("target", "_blank"),
+                            xs_html_text(label))));
+            }
+            else
+                xs_html_add(snac_content_wrap,
+                    xs_html_tag("p",
+                        xs_html_text(L("Location: ")),
+                        xs_html_text(label)));
+        }
+    }
+
+    if (strcmp(type, "Event") == 0) { /** Event start and end times **/
+        const char *s_time = xs_dict_get(msg, "startTime");
+
+        if (xs_is_string(s_time) && strlen(s_time) > 20) {
+            const char *e_time = xs_dict_get(msg, "endTime");
+            const char *tz     = xs_dict_get(msg, "timezone");
+
+            xs *s = xs_replace_i(xs_dup(s_time), "T", " ");
+            xs *e = NULL;
+
+            if (xs_is_string(e_time) && strlen(e_time) > 20)
+                e = xs_replace_i(xs_dup(e_time), "T", " ");
+
+            /* if the event has a timezone, crop the offsets */
+            if (xs_is_string(tz)) {
+                s = xs_crop_i(s, 0, 19);
+
+                if (e)
+                    e = xs_crop_i(e, 0, 19);
+            }
+            else
+                tz = "";
+
+            /* if start and end share the same day, crop it from the end */
+            if (e && memcmp(s, e, 11) == 0)
+                e = xs_crop_i(e, 11, 0);
+
+            if (e)
+                s = xs_str_cat(s, " / ", e);
+
+            if (*tz)
+                s = xs_str_cat(s, " (", tz, ")");
+
+            /* replace ugly decimals */
+            s = xs_replace_i(s, ".000", "");
+
+            xs_html_add(snac_content_wrap,
+                xs_html_tag("p",
+                    xs_html_text(L("Time: ")),
+                    xs_html_text(s)));
+        }
+    }
+
+    /* show all hashtags that has not been shown previously in the content */
+    const xs_list *tags = xs_dict_get(msg, "tag");
+    const char *o_content = xs_dict_get_def(msg, "content", "");
+
+    if (xs_is_string(o_content) && xs_is_list(tags) && xs_list_len(tags)) {
+        xs *content = xs_utf8_to_lower(o_content);
+        const xs_dict *tag;
+
+        xs_html *add_hashtags = xs_html_tag("ul",
+            xs_html_attr("class", "snac-more-hashtags"));
+
+        xs_list_foreach(tags, tag) {
+            const char *type = xs_dict_get(tag, "type");
+
+            if (xs_is_string(type) && strcmp(type, "Hashtag") == 0) {
+                const char *o_href = xs_dict_get(tag, "href");
+                const char *name   = xs_dict_get(tag, "name");
+
+                if (xs_is_string(o_href) && xs_is_string(name)) {
+                    xs *href = xs_utf8_to_lower(o_href);
+
+                    if (xs_str_in(content, href) == -1 && xs_str_in(content, name) == -1) {
+                        /* not in the content: add here */
+                        xs_html_add(add_hashtags,
+                            xs_html_tag("li",
+                                xs_html_tag("a",
+                                    xs_html_attr("href", href),
+                                    xs_html_text(name),
+                                    xs_html_text(" "))));
+                    }
+                }
+            }
+        }
+
+        xs_html_add(snac_content_wrap,
+            add_hashtags);
+    }
+
     /** controls **/
 
     if (!read_only && user) {
@@ -2583,7 +2798,7 @@ xs_html *html_people_list(snac *snac, xs_list *list, char *header, char *t, cons
                 xs_html_tag("div",
                     xs_html_attr("class", "snac-post-header"),
                     html_actor_icon(snac, actor, xs_dict_get(actor, "published"),
-                                    NULL, NULL, 0, 1, proxy)));
+                                    NULL, NULL, 0, 1, proxy, NULL)));
 
             /* content (user bio) */
             const char *c = xs_dict_get(actor, "summary");
@@ -2762,9 +2977,15 @@ xs_str *html_notifications(snac *user, int skip, int show)
         xs_html_attr("class", "snac-posts"));
     xs_html_add(body, posts);
 
-    xs_list *p = n_list;
+    xs_set rep;
+    xs_set_init(&rep);
+
+    /* dict to store previous notification labels */
+    xs *admiration_labels = xs_dict_new();
+
     const xs_str *v;
-    while (xs_list_iter(&p, &v)) {
+
+    xs_list_foreach(n_list, v) {
         xs *noti = notify_get(user, v);
 
         if (noti == NULL)
@@ -2775,6 +2996,7 @@ xs_str *html_notifications(snac *user, int skip, int show)
         const char *utype = xs_dict_get(noti, "utype");
         const char *id    = xs_dict_get(noti, "objid");
         const char *date  = xs_dict_get(noti, "date");
+        const char *id2   = xs_dict_get_path(noti, "msg.id");
         xs *wrk = NULL;
 
         if (xs_is_null(id))
@@ -2783,8 +3005,16 @@ xs_str *html_notifications(snac *user, int skip, int show)
         if (is_hidden(user, id))
             continue;
 
+        if (xs_is_string(id2) && xs_set_add(&rep, id2) != 1)
+            continue;
+
         object_get(id, &obj);
 
+        const char *msg_id = NULL;
+
+        if (xs_is_dict(obj))
+            msg_id = xs_dict_get(obj, "id");
+
         const char *actor_id = xs_dict_get(noti, "actor");
         xs *actor = NULL;
 
@@ -2817,9 +3047,7 @@ xs_str *html_notifications(snac *user, int skip, int show)
 
         xs *s_date = xs_crop_i(xs_dup(date), 0, 10);
 
-        xs_html *entry = xs_html_tag("div",
-            xs_html_attr("class", "snac-post-with-desc"),
-            xs_html_tag("p",
+        xs_html *this_html_label = xs_html_container(
                 xs_html_tag("b",
                     xs_html_text(label),
                     xs_html_text(" by "),
@@ -2830,13 +3058,45 @@ xs_str *html_notifications(snac *user, int skip, int show)
                 xs_html_tag("time",
                     xs_html_attr("class", "dt-published snac-pubdate"),
                     xs_html_attr("title", date),
-                    xs_html_text(s_date))));
+                    xs_html_text(s_date)));
+
+        xs_html *html_label = NULL;
+
+        if (xs_is_string(msg_id)) {
+            const xs_val *prev_label = xs_dict_get(admiration_labels, msg_id);
+
+            if (xs_type(prev_label) == XSTYPE_DATA) {
+                /* there is a previous list of admiration labels! */
+                xs_data_get(&html_label, prev_label);
+
+                xs_html_add(html_label,
+                    xs_html_sctag("br", NULL),
+                    this_html_label);
+
+                continue;
+            }
+        }
+
+        xs_html *entry = NULL;
+
+        html_label = xs_html_tag("p",
+            this_html_label);
+
+        /* store in the admiration labels dict */
+        xs *pl = xs_data_new(&html_label, sizeof(html_label));
+
+        if (xs_is_string(msg_id))
+            admiration_labels = xs_dict_set(admiration_labels, msg_id, pl);
+
+        entry = xs_html_tag("div",
+            xs_html_attr("class", "snac-post-with-desc"),
+            html_label);
 
         if (strcmp(type, "Follow") == 0 || strcmp(utype, "Follow") == 0 || strcmp(type, "Block") == 0) {
             xs_html_add(entry,
                 xs_html_tag("div",
                     xs_html_attr("class", "snac-post"),
-                    html_actor_icon(user, actor, NULL, NULL, NULL, 0, 0, proxy)));
+                    html_actor_icon(user, actor, NULL, NULL, NULL, 0, 0, proxy, NULL)));
         }
         else
         if (strcmp(type, "Move") == 0) {
@@ -2850,7 +3110,7 @@ xs_str *html_notifications(snac *user, int skip, int show)
                     xs_html_add(entry,
                         xs_html_tag("div",
                             xs_html_attr("class", "snac-post"),
-                            html_actor_icon(user, old_actor, NULL, NULL, NULL, 0, 0, proxy)));
+                            html_actor_icon(user, old_actor, NULL, NULL, NULL, 0, 0, proxy, NULL)));
                 }
             }
         }
@@ -2917,6 +3177,8 @@ xs_str *html_notifications(snac *user, int skip, int show)
                     xs_html_text(L("More...")))));
     }
 
+    xs_set_free(&rep);
+
     xs_html_add(body,
         html_footer());
 
@@ -2970,6 +3232,21 @@ int html_get_handler(const xs_dict *req, const char *q_path,
         else
             return HTTP_STATUS_NOT_FOUND;
     }
+    else
+    if (strcmp(v, "auth-int-bridge") == 0) {
+        const char *login  = xs_dict_get(q_vars, "login");
+        const char *id     = xs_dict_get(q_vars, "id");
+        const char *action = xs_dict_get(q_vars, "action");
+
+        if (xs_is_string(login) && xs_is_string(id) && xs_is_string(action)) {
+            *body = xs_fmt("%s/%s/authorize_interaction?action=%s&id=%s",
+                srv_baseurl, login, action, id);
+
+            return HTTP_STATUS_SEE_OTHER;
+        }
+        else
+            return HTTP_STATUS_NOT_FOUND;
+    }
 
     uid = xs_dup(v);
 
@@ -3541,6 +3818,52 @@ int html_get_handler(const xs_dict *req, const char *q_path,
             status  = HTTP_STATUS_SEE_OTHER;
         }
     }
+    else
+    if (strcmp(p_path, "authorize_interaction") == 0) { /** follow, like or boost from Mastodon **/
+        if (!login(&snac, req)) {
+            *body  = xs_dup(uid);
+            status = HTTP_STATUS_UNAUTHORIZED;
+        }
+        else {
+            status = HTTP_STATUS_NOT_FOUND;
+
+            const char *id     = xs_dict_get(q_vars, "id");
+            const char *action = xs_dict_get(q_vars, "action");
+
+            if (xs_is_string(id) && xs_is_string(action)) {
+                if (strcmp(action, "Follow") == 0) {
+                    xs *msg = msg_follow(&snac, id);
+
+                    if (msg != NULL) {
+                        const char *actor = xs_dict_get(msg, "object");
+
+                        following_add(&snac, actor, msg);
+
+                        enqueue_output_by_actor(&snac, msg, actor, 0);
+
+                        status = HTTP_STATUS_SEE_OTHER;
+                    }
+                }
+                else
+                if (xs_match(action, "Like|Boost|Announce")) {
+                    /* bring the post */
+                    xs *msg = msg_admiration(&snac, id, *action == 'L' ? "Like" : "Announce");
+
+                    if (msg != NULL) {
+                        enqueue_message(&snac, msg);
+                        timeline_admire(&snac, xs_dict_get(msg, "object"), snac.actor, *action == 'L' ? 1 : 0);
+
+                        status = HTTP_STATUS_SEE_OTHER;
+                    }
+                }
+            }
+
+            if (status == HTTP_STATUS_SEE_OTHER) {
+                *body   = xs_fmt("%s/admin", snac.actor);
+                *b_size = strlen(*body);
+            }
+        }
+    }
     else
         status = HTTP_STATUS_NOT_FOUND;
 
@@ -4024,6 +4347,9 @@ int html_post_handler(const xs_dict *req, const char *q_path,
         else
             snac.config = xs_dict_set(snac.config, "show_contact_metrics", xs_stock(XSTYPE_FALSE));
 
+        snac.config = xs_dict_set(snac.config, "latitude", xs_dict_get_def(p_vars, "latitude", ""));
+        snac.config = xs_dict_set(snac.config, "longitude", xs_dict_get_def(p_vars, "longitude", ""));
+
         if ((v = xs_dict_get(p_vars, "metadata")) != NULL)
             snac.config = xs_dict_set(snac.config, "metadata", v);
 
@@ -4140,6 +4466,35 @@ int html_post_handler(const xs_dict *req, const char *q_path,
 
         status = HTTP_STATUS_SEE_OTHER;
     }
+    else
+    if (p_path && strcmp(p_path, "admin/followed-hashtags") == 0) { /** **/
+        const char *followed_hashtags = xs_dict_get(p_vars, "followed_hashtags");
+
+        if (xs_is_string(followed_hashtags)) {
+            xs *new_hashtags = xs_list_new();
+            xs *l = xs_split(followed_hashtags, "\n");
+            const char *v;
+
+            xs_list_foreach(l, v) {
+                xs *s1 = xs_strip_i(xs_dup(v));
+                s1 = xs_replace_i(s1, " ", "");
+
+                if (*s1 == '\0')
+                    continue;
+
+                xs *s2 = xs_utf8_to_lower(s1);
+                if (*s2 != '#')
+                    s2 = xs_str_prepend_i(s2, "#");
+
+                new_hashtags = xs_list_append(new_hashtags, s2);
+            }
+
+            snac.config = xs_dict_set(snac.config, "followed_hashtags", new_hashtags);
+            user_persist(&snac, 0);
+        }
+
+        status = HTTP_STATUS_SEE_OTHER;
+    }
 
     if (status == HTTP_STATUS_SEE_OTHER) {
         const char *redir = xs_dict_get(p_vars, "redir");

+ 1 - 1
http.c

@@ -1,5 +1,5 @@
 /* snac - A simple, minimalistic ActivityPub instance */
-/* copyright (c) 2022 - 2024 grunfink et al. / MIT license */
+/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */
 
 #include "xs.h"
 #include "xs_io.h"

+ 48 - 6
httpd.c

@@ -1,5 +1,5 @@
 /* snac - A simple, minimalistic ActivityPub instance */
-/* copyright (c) 2022 - 2024 grunfink et al. / MIT license */
+/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */
 
 #include "xs.h"
 #include "xs_io.h"
@@ -138,7 +138,7 @@ static xs_str *greeting_html(void)
             while (xs_list_iter(&p, &uid)) {
                 snac user;
 
-                if (user_open(&user, uid)) {
+                if (strcmp(uid, "relay") && user_open(&user, uid)) {
                     xs_html_add(ul,
                         xs_html_tag("li",
                             xs_html_tag("a",
@@ -182,6 +182,29 @@ const char *share_page = ""
 "";
 
 
+const char *authorize_interaction_page = ""
+"<!DOCTYPE html>\n"
+"<html>\n"
+"<head>\n"
+"<title>%s - snac</title>\n"
+"<meta content=\"width=device-width, initial-scale=1, minimum-scale=1, user-scalable=no\" name=\"viewport\">\n"
+"<link rel=\"stylesheet\" type=\"text/css\" href=\"%s/style.css\"/>\n"
+"<style>:root {color-scheme: light dark}</style>\n"
+"</head>\n"
+"<body><h1>%s authorize interaction</h1>\n"
+"<form method=\"get\" action=\"%s/auth-int-bridge\">\n"
+"<select name=\"action\">\n"
+"<option value=\"Follow\">Follow</option>\n"
+"<option value=\"Boost\">Boost</option>\n"
+"<option value=\"Like\">Like</option>\n"
+"</select> %s\n"
+"<input type=\"hidden\" name=\"id\" value=\"%s\">\n"
+"<p>Login: <input type=\"text\" name=\"login\" autocapitalize=\"off\" required=\"required\"></p>\n"
+"<input type=\"submit\" value=\"OK\">\n"
+"</form><p>%s</p></body></html>\n"
+"";
+
+
 int server_get_handler(xs_dict *req, const char *q_path,
                        char **body, int *b_size, char **ctype)
 /* basic server services */
@@ -189,7 +212,7 @@ int server_get_handler(xs_dict *req, const char *q_path,
     int status = 0;
 
     /* is it the server root? */
-    if (*q_path == '\0') {
+    if (*q_path == '\0' || strcmp(q_path, "/") == 0) {
         const xs_dict *q_vars = xs_dict_get(req, "q_vars");
         const char *t = NULL;
 
@@ -318,6 +341,25 @@ int server_get_handler(xs_dict *req, const char *q_path,
             USER_AGENT
         );
     }
+    else
+    if (strcmp(q_path, "/authorize_interaction") == 0) {
+        const xs_dict *q_vars = xs_dict_get(req, "q_vars");
+        const char *uri  = xs_dict_get(q_vars, "uri");
+
+        if (xs_is_string(uri)) {
+            status = HTTP_STATUS_OK;
+            *ctype = "text/html; charset=utf-8";
+            *body  = xs_fmt(authorize_interaction_page,
+                xs_dict_get(srv_config, "host"),
+                srv_baseurl,
+                xs_dict_get(srv_config, "host"),
+                srv_baseurl,
+                uri,
+                uri,
+                USER_AGENT
+            );
+        }
+    }
 
     if (status != 0)
         srv_debug(1, xs_fmt("server_get_handler serving '%s' %d", q_path, status));
@@ -459,13 +501,13 @@ void httpd_connection(FILE *f)
     }
 
     if (status == HTTP_STATUS_FORBIDDEN)
-        body = xs_str_new("<h1>403 Forbidden</h1>");
+        body = xs_str_new("<h1>403 Forbidden (" USER_AGENT ")</h1>");
 
     if (status == HTTP_STATUS_NOT_FOUND)
-        body = xs_str_new("<h1>404 Not Found</h1>");
+        body = xs_str_new("<h1>404 Not Found (" USER_AGENT ")</h1>");
 
     if (status == HTTP_STATUS_BAD_REQUEST && body != NULL)
-        body = xs_str_new("<h1>400 Bad Request</h1>");
+        body = xs_str_new("<h1>400 Bad Request (" USER_AGENT ")</h1>");
 
     if (status == HTTP_STATUS_SEE_OTHER)
         headers = xs_dict_append(headers, "location", body);

+ 46 - 28
main.c

@@ -1,11 +1,12 @@
 /* snac - A simple, minimalistic ActivityPub instance */
-/* copyright (c) 2022 - 2024 grunfink et al. / MIT license */
+/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */
 
 #include "xs.h"
 #include "xs_io.h"
 #include "xs_json.h"
 #include "xs_time.h"
 #include "xs_openssl.h"
+#include "xs_match.h"
 
 #include "snac.h"
 
@@ -14,7 +15,7 @@
 int usage(void)
 {
     printf("snac " VERSION " - A simple, minimalistic ActivityPub instance\n");
-    printf("Copyright (c) 2022 - 2024 grunfink et al. / MIT license\n");
+    printf("Copyright (c) 2022 - 2025 grunfink et al. / MIT license\n");
     printf("\n");
     printf("Commands:\n");
     printf("\n");
@@ -34,6 +35,7 @@ int usage(void)
     printf("actor {basedir} [{uid}] {url}        Requests an actor\n");
     printf("note {basedir} {uid} {text} [files...] Sends a note with optional attachments\n");
     printf("note_unlisted {basedir} {uid} {text} [files...] Sends an unlisted note with optional attachments\n");
+    printf("note_mention {basedir} {uid} {text} [files...] Sends a note only to mentioned accounts\n");
     printf("boost|announce {basedir} {uid} {url} Boosts (announces) a post\n");
     printf("unboost {basedir} {uid} {url}        Unboosts a post\n");
     printf("resetpwd {basedir} {uid}             Resets the password of a user\n");
@@ -49,10 +51,10 @@ int usage(void)
     printf("unlimit {basedir} {uid} {actor}      Unlimits an actor\n");
     printf("verify_links {basedir} {uid}         Verifies a user's links (in the metadata)\n");
     printf("search {basedir} {uid} {regex}       Searches posts by content\n");
-    printf("export_csv {basedir} {uid}           Exports data as CSV files into current directory\n");
+    printf("export_csv {basedir} {uid}           Exports data as CSV files\n");
     printf("alias {basedir} {uid} {account}      Sets account (@user@host or actor url) as an alias\n");
     printf("migrate {basedir} {uid}              Migrates to the account defined as the alias\n");
-    printf("import_csv {basedir} {uid}           Imports data from CSV files in the current directory\n");
+    printf("import_csv {basedir} {uid}           Imports data from CSV files\n");
     printf("import_list {basedir} {uid} {file}   Imports a Mastodon CSV list file\n");
     printf("import_block_list {basedir} {uid} {file} Imports a Mastodon CSV block list file\n");
 
@@ -94,19 +96,6 @@ int main(int argc, char *argv[])
         return snac_init(basedir);
     }
 
-    if (strcmp(cmd, "upgrade") == 0) { /** **/
-        int ret;
-
-        /* upgrade */
-        if ((basedir = GET_ARGV()) == NULL)
-            return usage();
-
-        if ((ret = srv_open(basedir, 1)) == 1)
-            srv_log(xs_dup("OK"));
-
-        return ret;
-    }
-
     if (strcmp(cmd, "markdown") == 0) { /** **/
         /* undocumented, for testing only */
         xs *c = xs_readall(stdin);
@@ -116,8 +105,20 @@ int main(int argc, char *argv[])
         return 0;
     }
 
-    if ((basedir = GET_ARGV()) == NULL)
-        return usage();
+    if ((basedir = getenv("SNAC_BASEDIR")) == NULL) {
+        if ((basedir = GET_ARGV()) == NULL)
+            return usage();
+    }
+
+    if (strcmp(cmd, "upgrade") == 0) { /** **/
+        int ret;
+
+        /* upgrade */
+        if ((ret = srv_open(basedir, 1)) == 1)
+            srv_log(xs_dup("OK"));
+
+        return ret;
+    }
 
     if (!srv_open(basedir, 0)) {
         srv_log(xs_fmt("error opening data storage at %s", basedir));
@@ -351,6 +352,22 @@ int main(int argc, char *argv[])
         return 0;
     }
 
+
+    if (strcmp(cmd, "assist") == 0) { /** **/
+        /* undocumented: experimental (do not use) */
+        xs *msg = msg_admiration(&snac, url, "Accept");
+
+        if (msg != NULL) {
+            enqueue_message(&snac, msg);
+
+            if (dbglevel) {
+                xs_json_dump(msg, 4, stdout);
+            }
+        }
+
+        return 0;
+    }
+
     if (strcmp(cmd, "unboost") == 0) { /** **/
         xs *msg = msg_repulsion(&snac, url, "Announce");
 
@@ -604,7 +621,9 @@ int main(int argc, char *argv[])
         return 0;
     }
 
-    if (strcmp(cmd, "note") == 0 || strcmp(cmd, "note_unlisted") == 0) { /** **/
+    if (strcmp(cmd, "note") == 0 ||             /** **/
+        strcmp(cmd, "note_unlisted") == 0 ||    /** **/
+        strcmp(cmd, "note_mention") == 0) {     /** **/
         xs *content = NULL;
         xs *msg = NULL;
         xs *c_msg = NULL;
@@ -668,15 +687,14 @@ int main(int argc, char *argv[])
         else
             content = xs_dup(url);
 
-        msg = msg_note(&snac, content, NULL, NULL, attl, 0, getenv("LANG"));
+        int scope = 0;
+        if (strcmp(cmd, "note_mention") == 0)
+            scope = 1;
+        else
+        if (strcmp(cmd, "note_unlisted") == 0)
+            scope = 2;
 
-        if (strcmp(cmd, "note_unlisted") == 0) {
-            /* according to Mastodon, "unlisted" posts (now called "quiet public")
-               has the public address as a cc instead of to, so toggle it */
-            xs *to = xs_dup(xs_dict_get(msg, "to"));
-            msg = xs_dict_set(msg, "cc", to);
-            msg = xs_dict_set(msg, "to", xs_stock(XSTYPE_LIST));
-        }
+        msg = msg_note(&snac, content, NULL, NULL, attl, scope, getenv("LANG"));
 
         c_msg = msg_create(&snac, msg);
 

+ 52 - 24
mastoapi.c

@@ -1,5 +1,5 @@
 /* snac - A simple, minimalistic ActivityPub instance */
-/* copyright (c) 2022 - 2024 grunfink et al. / MIT license */
+/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */
 
 #ifndef NO_MASTODON_API
 
@@ -1339,6 +1339,9 @@ xs_list *mastoapi_timeline(snac *user, const xs_dict *args, const char *index_fn
     const char *since_id = xs_dict_get(args, "since_id");
     const char *min_id   = xs_dict_get(args, "min_id"); /* unsupported old-to-new navigation */
     const char *limit_s  = xs_dict_get(args, "limit");
+    int (*iterator)(FILE *, char *);
+    int initial_status = 0;
+    int ascending = 0;
     int limit = 0;
     int cnt   = 0;
 
@@ -1348,27 +1351,40 @@ xs_list *mastoapi_timeline(snac *user, const xs_dict *args, const char *index_fn
     if (limit == 0)
         limit = 20;
 
-    if (index_desc_first(f, md5, 0)) {
+    if (min_id) {
+        iterator = &index_asc_next;
+        initial_status = index_asc_first(f, md5, MID_TO_MD5(min_id));
+        ascending = 1;
+    }
+    else {
+        iterator = &index_desc_next;
+        initial_status = index_desc_first(f, md5, 0);
+    }
+
+    if (initial_status) {
         do {
             xs *msg = NULL;
 
             /* only return entries older that max_id */
             if (max_id) {
-                if (strcmp(md5, MID_TO_MD5(max_id)) == 0)
+                if (strcmp(md5, MID_TO_MD5(max_id)) == 0) {
                     max_id = NULL;
-
-                continue;
+                    if (ascending)
+                        break;
+                }
+                if (!ascending)
+                    continue;
             }
 
             /* only returns entries newer than since_id */
             if (since_id) {
-                if (strcmp(md5, MID_TO_MD5(since_id)) == 0)
-                    break;
-            }
-
-            if (min_id) {
-                if (strcmp(md5, MID_TO_MD5(min_id)) == 0)
-                    break;
+                if (strcmp(md5, MID_TO_MD5(since_id)) == 0) {
+                    if (!ascending)
+                        break;
+                    since_id = NULL;
+                }
+                if (ascending)
+                    continue;
             }
 
             /* get the entry */
@@ -1428,26 +1444,23 @@ xs_list *mastoapi_timeline(snac *user, const xs_dict *args, const char *index_fn
                     continue;
             }
 
-            /* if it has a name and it's not a Page or a Video,
+            /* if it has a name and it's not an object that may have one,
                it's a poll vote, so discard it */
-            if (!xs_is_null(xs_dict_get(msg, "name")) && !xs_match(type, "Page|Video"))
+            if (!xs_is_null(xs_dict_get(msg, "name")) && !xs_match(type, "Page|Video|Audio|Event"))
                 continue;
 
             /* convert the Note into a Mastodon status */
             xs *st = mastoapi_status(user, msg);
 
             if (st != NULL) {
-                out = xs_list_append(out, st);
+                if (ascending)
+                    out = xs_list_insert(out, 0, st);
+                else
+                    out = xs_list_append(out, st);
                 cnt++;
             }
-            if (min_id) {
-                while (cnt > limit) {
-                    out = xs_list_del(out, 0);
-                    cnt--;
-                }
-            }
 
-        } while ((min_id || (cnt < limit)) && index_desc_next(f, md5));
+        } while ((cnt < limit) && (*iterator)(f, md5));
     }
 
     int more = index_desc_next(f, md5);
@@ -1816,6 +1829,11 @@ int mastoapi_get_handler(const xs_dict *req, const char *q_path,
             const xs_list *excl = xs_dict_get(args, "exclude_types[]");
             const char *min_id = xs_dict_get(args, "min_id");
             const char *max_id = xs_dict_get(args, "max_id");
+            const char *limit = xs_dict_get(args, "limit");
+            int limit_count = 0;
+            if (!xs_is_null(limit)) {
+                limit_count = atoi(limit);
+            }
 
             if (dbglevel) {
                 xs *js = xs_json_dumps(args, 0);
@@ -1903,6 +1921,10 @@ int mastoapi_get_handler(const xs_dict *req, const char *q_path,
                 }
 
                 out = xs_list_append(out, mn);
+                if (!xs_is_null(limit)) {
+                    if (--limit_count <= 0)
+                        break;
+                }
             }
 
             srv_debug(1, xs_fmt("mastoapi_notifications count %d", xs_list_len(out)));
@@ -2650,8 +2672,14 @@ int mastoapi_post_handler(const xs_dict *req, const char *q_path,
             }
 
             /* prepare the message */
-            xs *msg = msg_note(&snac, content, NULL, irt, attach_list,
-                        strcmp(visibility, "public") == 0 ? 0 : 1, language);
+            int scope = 1;
+            if (strcmp(visibility, "unlisted") == 0)
+                scope = 2;
+            else
+            if (strcmp(visibility, "public") == 0)
+                scope = 0;
+
+            xs *msg = msg_note(&snac, content, NULL, irt, attach_list, scope, language);
 
             if (!xs_is_null(summary) && *summary) {
                 msg = xs_dict_set(msg, "sensitive", xs_stock(XSTYPE_TRUE));

+ 8 - 1
sandbox.c

@@ -71,15 +71,22 @@ LL_BEGIN(sbox_enter_linux_, const char* basedir, const char *address, int smail)
              LANDLOCK_ACCESS_FS_REFER_COMPAT,
         s  = LANDLOCK_ACCESS_FS_MAKE_SOCK,
         x  = LANDLOCK_ACCESS_FS_EXECUTE;
+    char *resolved_path = NULL;
 
     LL_PATH(basedir,                rf|rd|w|c);
     LL_PATH("/tmp",                 rf|rd|w|c);
 #ifndef WITHOUT_SHM
     LL_PATH("/dev/shm",             rf|w|c   );
 #endif
+    LL_PATH("/dev/urandom",         rf       );
     LL_PATH("/etc/resolv.conf",     rf       );
     LL_PATH("/etc/hosts",           rf       );
-    LL_PATH("/etc/ssl",             rf       );
+    LL_PATH("/etc/ssl",             rf|rd    );
+    if ((resolved_path = realpath("/etc/ssl/cert.pem", NULL))) {
+        /* some distros like cert.pem to be a symlink */
+        LL_PATH(resolved_path,      rf       );
+        free(resolved_path);
+    }
     LL_PATH("/usr/share/zoneinfo",  rf       );
 
     if (mtime("/etc/pki") > 0)

+ 1 - 1
snac.c

@@ -1,5 +1,5 @@
 /* snac - A simple, minimalistic ActivityPub instance */
-/* copyright (c) 2022 - 2024 grunfink et al. / MIT license */
+/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */
 
 #define XS_IMPLEMENTATION
 

+ 5 - 3
snac.h

@@ -1,7 +1,7 @@
 /* snac - A simple, minimalistic ActivityPub instance */
-/* copyright (c) 2022 - 2024 grunfink et al. / MIT license */
+/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */
 
-#define VERSION "2.68-dev"
+#define VERSION "2.70-dev"
 
 #define USER_AGENT "snac/" VERSION
 
@@ -108,6 +108,8 @@ int index_len(const char *fn);
 xs_list *index_list(const char *fn, int max);
 int index_desc_next(FILE *f, char md5[MD5_HEX_SIZE]);
 int index_desc_first(FILE *f, char md5[MD5_HEX_SIZE], int skip);
+int index_asc_next(FILE *f, char md5[MD5_HEX_SIZE]);
+int index_asc_first(FILE *f, char md5[MD5_HEX_SIZE], const char *seek_md5);
 xs_list *index_list_desc(const char *fn, int skip, int show);
 
 int object_add(const char *id, const xs_dict *obj);
@@ -317,7 +319,7 @@ xs_dict *msg_follow(snac *snac, const char *actor);
 
 xs_dict *msg_note(snac *snac, const xs_str *content, const xs_val *rcpts,
                   const xs_str *in_reply_to, const xs_list *attach,
-                  int priv, const char *lang);
+                  int scope, const char *lang);
 
 xs_dict *msg_undo(snac *snac, const xs_val *object);
 xs_dict *msg_delete(snac *snac, const char *id);

+ 1 - 1
upgrade.c

@@ -1,5 +1,5 @@
 /* snac - A simple, minimalistic ActivityPub instance */
-/* copyright (c) 2022 - 2024 grunfink et al. / MIT license */
+/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */
 
 #include "xs.h"
 #include "xs_io.h"

+ 7 - 3
utils.c

@@ -1,5 +1,5 @@
 /* snac - A simple, minimalistic ActivityPub instance */
-/* copyright (c) 2022 - 2024 grunfink et al. / MIT license */
+/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */
 
 #include "xs.h"
 #include "xs_io.h"
@@ -98,7 +98,7 @@ static const char *greeting_html =
     "<html><head>\n"
     "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"/>\n"
     "<link rel=\"icon\" type=\"image/x-icon\" href=\"https://%host%/favicon.ico\"/>\n"
-    "<title>Welcome to %host%</title>\n"
+    "<title>Welcome to %host%</title>\n</head>\n"
     "<body style=\"margin: auto; max-width: 50em\">\n"
     "%blurb%"
     "<p>The following users are part of this community:</p>\n"
@@ -319,6 +319,10 @@ int adduser(const char *uid)
         mkdirx(d);
     }
 
+    /* add a specially short data retention time for the relay */
+    if (strcmp(uid, "relay") == 0)
+        config = xs_dict_set(config, "purge_days", xs_stock(1));
+
     xs *cfn = xs_fmt("%s/user.json", basedir);
 
     if ((f = fopen(cfn, "w")) == NULL) {
@@ -331,7 +335,7 @@ int adduser(const char *uid)
     }
 
     printf("\nCreating RSA key...\n");
-    key = xs_evp_genkey(4096);
+    key = xs_evp_genkey(2048);
     printf("Done.\n");
 
     xs *kfn = xs_fmt("%s/key.json", basedir);

+ 1 - 1
webfinger.c

@@ -1,5 +1,5 @@
 /* snac - A simple, minimalistic ActivityPub instance */
-/* copyright (c) 2022 - 2024 grunfink et al. / MIT license */
+/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */
 
 #include "xs.h"
 #include "xs_json.h"

+ 18 - 8
xs.h

@@ -1,4 +1,4 @@
-/* copyright (c) 2022 - 2024 grunfink et al. / MIT license */
+/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */
 
 #ifndef _XS_H
 
@@ -157,6 +157,9 @@ unsigned int xs_hash_func(const char *data, int size);
 #define xs_is_true(v) (xs_type((v)) == XSTYPE_TRUE)
 #define xs_is_false(v) (xs_type((v)) == XSTYPE_FALSE)
 #define xs_not(v) xs_stock(xs_is_true((v)) ? XSTYPE_FALSE : XSTYPE_TRUE)
+#define xs_is_string(v) (xs_type((v)) == XSTYPE_STRING)
+#define xs_is_list(v) (xs_type((v)) == XSTYPE_LIST)
+#define xs_is_dict(v) (xs_type((v)) == XSTYPE_DICT)
 
 #define xs_list_foreach(l, v) for (int ct_##__LINE__ = 0; xs_list_next(l, &v, &ct_##__LINE__); )
 #define xs_dict_foreach(l, k, v) for (int ct_##__LINE__ = 0; xs_dict_next(l, &k, &v, &ct_##__LINE__); )
@@ -623,15 +626,14 @@ int xs_between(const char *prefix, const char *str, const char *suffix)
 xs_str *xs_crop_i(xs_str *str, int start, int end)
 /* crops the string to be only from start to end */
 {
-    XS_ASSERT_TYPE(str, XSTYPE_STRING);
-
     int sz = strlen(str);
 
     if (end <= 0)
         end = sz + end;
 
     /* crop from the top */
-    str[end] = '\0';
+    if (end > 0 && end < sz)
+        str[end] = '\0';
 
     /* crop from the bottom */
     str = xs_collapse(str, 0, start);
@@ -1061,14 +1063,15 @@ xs_keyval *xs_keyval_make(xs_keyval *keyval, const xs_str *key, const xs_val *va
 
 typedef struct {
     int value_offset;   /* offset to value (from dict start) */
-    int next;           /* next node in sequential search */
+    int next;           /* next node in sequential scanning */
     int child[4];       /* child nodes in hashed search */
     char key[];         /* C string key */
 } ditem_hdr;
 
 typedef struct {
     int size;           /* size of full dict (_XS_TYPE_SIZE) */
-    int first;          /* first node for sequential search */
+    int first;          /* first node for sequential scanning */
+    int last;           /* last node for sequential scanning */
     int root;           /* root node for hashed search */
     /* a bunch of ditem_hdr and value follows */
 } dict_hdr;
@@ -1153,8 +1156,15 @@ xs_dict *xs_dict_set(xs_dict *dict, const xs_str *key, const xs_val *value)
             memcpy(dict + di->value_offset, value, vsz);
 
             /* chain to the sequential list */
-            di->next = dh->first;
-            dh->first = end;
+            if (dh->first == 0)
+                dh->first = end;
+            else {
+                /* chain this new element to the last one */
+                ditem_hdr *dil = (ditem_hdr *)(dict + dh->last);
+                dil->next = end;
+            }
+
+            dh->last = end;
         }
         else {
             /* ditem already exists */

+ 1 - 1
xs_curl.h

@@ -1,4 +1,4 @@
-/* copyright (c) 2022 - 2024 grunfink et al. / MIT license */
+/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */
 
 #ifndef _XS_CURL_H
 

+ 1 - 1
xs_fcgi.h

@@ -1,4 +1,4 @@
-/* copyright (c) 2022 - 2024 grunfink et al. / MIT license */
+/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */
 
 /*
     This is an intentionally-dead-simple FastCGI implementation;

+ 1 - 1
xs_glob.h

@@ -1,4 +1,4 @@
-/* copyright (c) 2022 - 2024 grunfink et al. / MIT license */
+/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */
 
 #ifndef _XS_GLOB_H
 

+ 1 - 1
xs_hex.h

@@ -1,4 +1,4 @@
-/* copyright (c) 2022 - 2024 grunfink et al. / MIT license */
+/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */
 
 #ifndef _XS_HEX_H
 

+ 1 - 1
xs_html.h

@@ -1,4 +1,4 @@
-/* copyright (c) 2022 - 2024 grunfink et al. / MIT license */
+/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */
 
 #ifndef _XS_HTML_H
 

+ 1 - 1
xs_httpd.h

@@ -1,4 +1,4 @@
-/* copyright (c) 2022 - 2024 grunfink et al. / MIT license */
+/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */
 
 #ifndef _XS_HTTPD_H
 

+ 1 - 1
xs_io.h

@@ -1,4 +1,4 @@
-/* copyright (c) 2022 - 2024 grunfink et al. / MIT license */
+/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */
 
 #ifndef _XS_IO_H
 

+ 1 - 1
xs_json.h

@@ -1,4 +1,4 @@
-/* copyright (c) 2022 - 2024 grunfink et al. / MIT license */
+/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */
 
 #ifndef _XS_JSON_H
 

+ 1 - 1
xs_match.h

@@ -1,4 +1,4 @@
-/* copyright (c) 2022 - 2024 grunfink et al. / MIT license */
+/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */
 
 #ifndef _XS_MATCH_H
 

+ 1 - 1
xs_mime.h

@@ -1,4 +1,4 @@
-/* copyright (c) 2022 - 2024 grunfink et al. / MIT license */
+/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */
 
 #ifndef _XS_MIME_H
 

+ 1 - 1
xs_openssl.h

@@ -1,4 +1,4 @@
-/* copyright (c) 2022 - 2024 grunfink et al. / MIT license */
+/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */
 
 #ifndef _XS_OPENSSL_H
 

+ 1 - 1
xs_random.h

@@ -1,4 +1,4 @@
-/* copyright (c) 2022 - 2024 grunfink et al. / MIT license */
+/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */
 
 #ifndef _XS_RANDOM_H
 

+ 1 - 1
xs_regex.h

@@ -1,4 +1,4 @@
-/* copyright (c) 2022 - 2024 grunfink et al. / MIT license */
+/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */
 
 #ifndef _XS_REGEX_H
 

+ 1 - 1
xs_set.h

@@ -1,4 +1,4 @@
-/* copyright (c) 2022 - 2024 grunfink et al. / MIT license */
+/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */
 
 #ifndef _XS_SET_H
 

+ 1 - 1
xs_socket.h

@@ -1,4 +1,4 @@
-/* copyright (c) 2022 - 2024 grunfink et al. / MIT license */
+/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */
 
 #ifndef _XS_SOCKET_H
 

+ 1 - 1
xs_time.h

@@ -1,4 +1,4 @@
-/* copyright (c) 2022 - 2024 grunfink et al. / MIT license */
+/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */
 
 #ifndef _XS_TIME_H
 

+ 1 - 1
xs_unicode.h

@@ -1,4 +1,4 @@
-/* copyright (c) 2022 - 2024 grunfink et al. / MIT license */
+/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */
 
 #ifndef _XS_UNICODE_H
 

+ 1 - 1
xs_unix_socket.h

@@ -1,4 +1,4 @@
-/* copyright (c) 2022 - 2024 grunfink et al. / MIT license */
+/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */
 
 #ifndef _XS_UNIX_SOCKET_H
 

+ 24 - 1
xs_url.h

@@ -1,10 +1,11 @@
-/* copyright (c) 2022 - 2024 grunfink et al. / MIT license */
+/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */
 
 #ifndef _XS_URL_H
 
 #define _XS_URL_H
 
 xs_str *xs_url_dec(const char *str);
+xs_str *xs_url_enc(const char *str);
 xs_dict *xs_url_vars(const char *str);
 xs_dict *xs_multipart_form_data(const char *payload, int p_size, const char *header);
 
@@ -39,6 +40,28 @@ xs_str *xs_url_dec(const char *str)
 }
 
 
+xs_str *xs_url_enc(const char *str)
+/* URL-encodes a string (RFC 3986) */
+{
+    xs_str *s = xs_str_new(NULL);
+
+    while (*str) {
+        if (isalnum(*str) || strchr("-._~", *str)) {
+            s = xs_append_m(s, str, 1);
+        }
+        else {
+            char tmp[8];
+            snprintf(tmp, sizeof(tmp), "%%%02X", (unsigned char)*str);
+            s = xs_append_m(s, tmp, 3);
+        }
+
+        str++;
+    }
+
+    return s;
+}
+
+
 xs_dict *xs_url_vars(const char *str)
 /* parse url variables */
 {

+ 1 - 1
xs_version.h

@@ -1 +1 @@
-/* 297f71e198be7819213e9122e1e78c3b963111bc 2024-11-24T18:48:42+01:00 */
+/* b865e89769aedfdbc61251e94451e9d37579f52e 2025-01-12T16:17:47+01:00 */