commit 3554a73

Oliver  ·  2025-07-16 18:21:20 +0000 UTC
parents d6973cf, a2518f5
Merge pull request 'master' (#9) from grunfink/snac2:master into master

Reviewed-on: https://codeberg.org/zen/snac2/pulls/9
28 files changed,  +2079, -366
M data.c
M html.c
M main.c
A rss.c
M snac.h
M xs.h
+9, -7
 1@@ -5,7 +5,7 @@ CFLAGS?=-g -Wall -Wextra -pedantic
 2 all: snac
 3 
 4 snac: snac.o main.o sandbox.o data.o http.o httpd.o webfinger.o \
 5-    activitypub.o html.o utils.o format.o upgrade.o mastoapi.o
 6+    activitypub.o html.o utils.o format.o upgrade.o mastoapi.o rss.o
 7 	$(CC) $(CFLAGS) -L$(PREFIX)/lib *.o -lcurl -lcrypto $(LDFLAGS) -pthread -o $@
 8 
 9 test: tests/smtp
10@@ -48,25 +48,27 @@ update-po:
11 
12 activitypub.o: activitypub.c xs.h xs_json.h xs_curl.h xs_mime.h \
13  xs_openssl.h xs_regex.h xs_time.h xs_set.h xs_match.h xs_unicode.h \
14- snac.h http_codes.h
15+ xs_webmention.h snac.h http_codes.h
16 data.o: data.c xs.h xs_hex.h xs_io.h xs_json.h xs_openssl.h xs_glob.h \
17  xs_set.h xs_time.h xs_regex.h xs_match.h xs_unicode.h xs_random.h \
18  xs_po.h snac.h http_codes.h
19 format.o: format.c xs.h xs_regex.h xs_mime.h xs_html.h xs_json.h \
20- xs_time.h xs_match.h snac.h http_codes.h
21+ xs_time.h xs_match.h xs_unicode.h snac.h http_codes.h
22 html.o: html.c xs.h xs_io.h xs_json.h xs_regex.h xs_set.h xs_openssl.h \
23  xs_time.h xs_mime.h xs_match.h xs_html.h xs_curl.h xs_unicode.h xs_url.h \
24  xs_random.h snac.h http_codes.h
25 http.o: http.c xs.h xs_io.h xs_openssl.h xs_curl.h xs_time.h xs_json.h \
26  snac.h http_codes.h
27 httpd.o: httpd.c xs.h xs_io.h xs_json.h xs_socket.h xs_unix_socket.h \
28- xs_httpd.h xs_mime.h xs_time.h xs_openssl.h xs_fcgi.h xs_html.h snac.h \
29- http_codes.h
30+ xs_httpd.h xs_mime.h xs_time.h xs_openssl.h xs_fcgi.h xs_html.h \
31+ xs_webmention.h snac.h http_codes.h
32 main.o: main.c xs.h xs_io.h xs_json.h xs_time.h xs_openssl.h xs_match.h \
33- snac.h http_codes.h
34+ xs_random.h snac.h http_codes.h
35 mastoapi.o: mastoapi.c xs.h xs_hex.h xs_openssl.h xs_json.h xs_io.h \
36  xs_time.h xs_glob.h xs_set.h xs_random.h xs_url.h xs_mime.h xs_match.h \
37- snac.h http_codes.h
38+ xs_unicode.h snac.h http_codes.h
39+rss.o: rss.c xs.h xs_html.h xs_regex.h xs_time.h xs_match.h xs_curl.h \
40+ xs_openssl.h xs_json.h snac.h http_codes.h
41 sandbox.o: sandbox.c xs.h snac.h http_codes.h
42 snac.o: snac.c xs.h xs_hex.h xs_io.h xs_unicode_tbl.h xs_unicode.h \
43  xs_json.h xs_curl.h xs_openssl.h xs_socket.h xs_unix_socket.h xs_url.h \
+9, -7
 1@@ -6,7 +6,7 @@ LDFLAGS=-lrt
 2 all: snac
 3 
 4 snac: snac.o main.o sandbox.o data.o http.o httpd.o webfinger.o \
 5-    activitypub.o html.o utils.o format.o upgrade.o mastoapi.o
 6+    activitypub.o html.o utils.o format.o upgrade.o mastoapi.o rss.o
 7 	$(CC) $(CFLAGS) -L/usr/pkg/lib *.o -lcurl -lcrypto -pthread $(LDFLAGS) -Wl,-rpath,/usr/lib -Wl,-rpath,/usr/pkg/lib -o $@
 8 
 9 
10@@ -37,25 +37,27 @@ uninstall:
11 
12 activitypub.o: activitypub.c xs.h xs_json.h xs_curl.h xs_mime.h \
13  xs_openssl.h xs_regex.h xs_time.h xs_set.h xs_match.h xs_unicode.h \
14- snac.h http_codes.h
15+ xs_webmention.h snac.h http_codes.h
16 data.o: data.c xs.h xs_hex.h xs_io.h xs_json.h xs_openssl.h xs_glob.h \
17  xs_set.h xs_time.h xs_regex.h xs_match.h xs_unicode.h xs_random.h \
18  xs_po.h snac.h http_codes.h
19 format.o: format.c xs.h xs_regex.h xs_mime.h xs_html.h xs_json.h \
20- xs_time.h xs_match.h snac.h http_codes.h
21+ xs_time.h xs_match.h xs_unicode.h snac.h http_codes.h
22 html.o: html.c xs.h xs_io.h xs_json.h xs_regex.h xs_set.h xs_openssl.h \
23  xs_time.h xs_mime.h xs_match.h xs_html.h xs_curl.h xs_unicode.h xs_url.h \
24  xs_random.h snac.h http_codes.h
25 http.o: http.c xs.h xs_io.h xs_openssl.h xs_curl.h xs_time.h xs_json.h \
26  snac.h http_codes.h
27 httpd.o: httpd.c xs.h xs_io.h xs_json.h xs_socket.h xs_unix_socket.h \
28- xs_httpd.h xs_mime.h xs_time.h xs_openssl.h xs_fcgi.h xs_html.h snac.h \
29- http_codes.h
30+ xs_httpd.h xs_mime.h xs_time.h xs_openssl.h xs_fcgi.h xs_html.h \
31+ xs_webmention.h snac.h http_codes.h
32 main.o: main.c xs.h xs_io.h xs_json.h xs_time.h xs_openssl.h xs_match.h \
33- snac.h http_codes.h
34+ xs_random.h snac.h http_codes.h
35 mastoapi.o: mastoapi.c xs.h xs_hex.h xs_openssl.h xs_json.h xs_io.h \
36  xs_time.h xs_glob.h xs_set.h xs_random.h xs_url.h xs_mime.h xs_match.h \
37- snac.h http_codes.h
38+ xs_unicode.h snac.h http_codes.h
39+rss.o: rss.c xs.h xs_html.h xs_regex.h xs_time.h xs_match.h xs_curl.h \
40+ xs_openssl.h xs_json.h snac.h http_codes.h
41 sandbox.o: sandbox.c xs.h snac.h http_codes.h
42 snac.o: snac.c xs.h xs_hex.h xs_io.h xs_unicode_tbl.h xs_unicode.h \
43  xs_json.h xs_curl.h xs_openssl.h xs_socket.h xs_unix_socket.h xs_url.h \
+45, -1
 1@@ -2,11 +2,55 @@
 2 
 3 ## UNRELEASED
 4 
 5+Included a small tweak to avoid being confused by implementations that return valid webfinger queries for non-account URLs (like i.e. the Wordpress ActivityPub plugin in some configurations).
 6+
 7+Added Ukrainian translation (contributed by wincentbalin).
 8+
 9+## 2.80
10+
11+Mastodon API: fixed a regression (introduced in previous version in the "boosts disappear in Tusky" fix) that interrupted timelines.
12+
13+## 2.79
14+
15+Added a server-wide Webmention hook; this way, if somebody out there (that supports Webmention) links to a user or post in this instance, a notification is sent (this is the complementary of what was implemented in version 2.76).
16+
17+Fixed regression while sending email via pipe on OpenBSD.
18+
19+Fixed Markdown parsing when the URL has parenthesis.
20+
21+Always show the 'pending follow confirmations' section if there are any (even if the toggle is off).
22+
23+If a metadata value is an account handler, it's also tried to be validated (rel="me" links).
24+
25+Another search by URL tweak (this time for Pixelfed links).
26+
27+Mastodon API: fixed a bug that made some boosts disappear after being shown in apps like Tusky, added followed hashtags maintenance, other minor changes.
28+
29+Renamed command-line actions `create_list` to `list_create` and `delete_list` to `list_remove`.
30+
31+The default favicon URL can be changed from the server configuration.
32+
33+New command-line option `export_posts`, to export all posts by a user in a JSON format compatible with the one generated by Mastodon.
34+
35+The command-line options to send notes also allow an optional `-r` argument, to set the URL of a Fediverse post this note is a reply to.
36+
37+## 2.78
38+
39+Hashtag following also allow URLs to RSS feeds of ActivityPub objects (like e.g. https://mastodon.social/tags/ThankYouTuesday).
40+
41+Users can now configure a webhook to receive an HTTP POST for every notification. This can be useful for implementing bots that react to activities, like autorepliers, chatbots or interactive textual games (see `snac(1)` for more information).
42+
43 The number of pending follow confirmations is shown next to the "people" link.
44 
45 Faster performance metrics (contributed by dandelions).
46 
47-Mastodon API: added follow confirmation endpoints.
48+Improved lowercasing in hashtags (contributed by postscriptum).
49+
50+A search-by-url tweak for implementations that return 200 for invalid webfinger queries (e.g. piefed).
51+
52+Mastodon API: added follow confirmation endpoints, fixed collisions in attachment file names.
53+
54+Fixed potential crashes in attachment uploads.
55 
56 ## 2.77 "Ugly Links Everywhere"
57 
+4, -0
 1@@ -14,6 +14,8 @@ Important: deleting a follower should do more that just delete the object, see h
 2 
 3 ## Wishlist
 4 
 5+The local purge should generate `Delete` activities for local posts.
 6+
 7 Add account reporting.
 8 
 9 Add a list option to hide member posts from the main timeline, see https://codeberg.org/grunfink/snac2/issues/383
10@@ -22,6 +24,8 @@ The instance timeline should also show boosts from users.
11 
12 Mastoapi: implement /v1/conversations.
13 
14+Track "BadgeFed - ActivityPub Badges" https://github.com/tryvocalcat/badgefed
15+
16 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).
17 
18 Implement "FEP-3b86: Activity Intents" https://codeberg.org/fediverse/fep/src/branch/main/fep/3b86/fep-3b86.md
+38, -1
 1@@ -903,7 +903,7 @@ xs_str *process_tags(snac *snac, const char *content, xs_list **tag)
 2             if (*v == '#') {
 3                 /* hashtag */
 4                 xs *d = xs_dict_new();
 5-                xs *n = xs_tolower_i(xs_dup(v));
 6+                xs *n = xs_utf8_to_lower(v);
 7                 xs *h = xs_fmt("%s?t=%s", srv_baseurl, n + 1);
 8                 xs *l = xs_fmt("<a href=\"%s\" class=\"mention hashtag\" rel=\"tag\">%s</a>", h, v);
 9 
10@@ -2765,6 +2765,39 @@ void process_user_queue_item(snac *user, xs_dict *q_item)
11             snac_log(user, xs_fmt("actor_refresh %s %d", actor, status));
12         }
13     }
14+    else
15+    if (strcmp(type, "notify_webhook") == 0) {
16+        const char *webhook = xs_dict_get(user->config, "notify_webhook");
17+
18+        if (xs_is_string(webhook) && xs_match(webhook, "https://*|http://*")) { /** **/
19+            const xs_dict *msg = xs_dict_get(q_item, "message");
20+            int retries = xs_number_get(xs_dict_get(q_item, "retries"));
21+
22+            xs *hdrs = xs_dict_new();
23+
24+            hdrs = xs_dict_set(hdrs, "content-type", "application/json");
25+            hdrs = xs_dict_set(hdrs, "user-agent",   USER_AGENT);
26+
27+            xs *body = xs_json_dumps(msg, 4);
28+
29+            int status;
30+            xs *rsp = xs_http_request("POST", webhook, hdrs, body, strlen(body), &status, NULL, NULL, 0);
31+
32+            snac_debug(user, 0, xs_fmt("webhook post %s %d", webhook, status));
33+
34+            if (!valid_status(status)) {
35+                retries++;
36+
37+                if (retries > queue_retry_max)
38+                    snac_debug(user, 0, xs_fmt("webhook post giving up %s", webhook));
39+                else {
40+                    snac_debug(user, 0, xs_fmt("webhook post requeue %s %d", webhook, retries));
41+
42+                    enqueue_notify_webhook(user, msg, retries);
43+                }
44+            }
45+        }
46+    }
47     else
48         snac_log(user, xs_fmt("unexpected user q_item type '%s'", type));
49 }
50@@ -3049,6 +3082,10 @@ void process_queue_item(xs_dict *q_item)
51             }
52         }
53     }
54+    else
55+    if (strcmp(type, "rss_hashtag_poll") == 0) {
56+        rss_poll_hashtags();
57+    }
58     else
59         srv_log(xs_fmt("unexpected q_item type '%s'", type));
60 }
M data.c
+61, -9
  1@@ -333,6 +333,7 @@ int user_open_by_md5(snac *snac, const char *md5)
  2     return 0;
  3 }
  4 
  5+
  6 int user_persist(snac *snac, int publish)
  7 /* store user */
  8 {
  9@@ -348,7 +349,7 @@ int user_persist(snac *snac, int publish)
 10 
 11             if (old != NULL) {
 12                 int nw = 0;
 13-                const char *fields[] = { "header", "avatar", "name", "bio",
 14+                const char *fields[] = { "header", "avatar", "name", "bio", "alias", "alias_raw",
 15                                          "metadata", "latitude", "longitude", NULL };
 16 
 17                 for (int n = 0; fields[n]; n++) {
 18@@ -1391,7 +1392,7 @@ xs_str *timeline_fn_by_md5(snac *snac, const char *md5)
 19 }
 20 
 21 
 22-int timeline_here(snac *snac, const char *md5)
 23+int timeline_here_by_md5(snac *snac, const char *md5)
 24 /* checks if an object is in the user cache */
 25 {
 26     xs *fn = timeline_fn_by_md5(snac, md5);
 27@@ -1400,6 +1401,14 @@ int timeline_here(snac *snac, const char *md5)
 28 }
 29 
 30 
 31+int timeline_here(snac *user, const char *id)
 32+{
 33+    xs *md5 = xs_md5_hex(id, strlen(id));
 34+
 35+    return timeline_here_by_md5(user, md5);
 36+}
 37+
 38+
 39 int timeline_get_by_md5(snac *snac, const char *md5, xs_dict **msg)
 40 /* gets a message from the timeline */
 41 {
 42@@ -1515,7 +1524,7 @@ xs_list *timeline_top_level(snac *snac, const xs_list *list)
 43                 break;
 44 
 45             /* well, there is a parent... but is it here? */
 46-            if (!timeline_here(snac, line2))
 47+            if (!timeline_here_by_md5(snac, line2))
 48                 break;
 49 
 50             /* it's here! try again with its own parent */
 51@@ -2217,7 +2226,7 @@ void tag_index(const char *id, const xs_dict *obj)
 52                 if (*name == '\0')
 53                     continue;
 54 
 55-                name = xs_tolower_i((xs_str *)name);
 56+                name = xs_utf8_to_lower((xs_str *)name);
 57 
 58                 xs *md5_tag   = xs_md5_hex(name, strlen(name));
 59                 xs *tag_dir   = xs_fmt("%s/%c%c", g_tag_dir, md5_tag[0], md5_tag[1]);
 60@@ -2235,7 +2244,7 @@ void tag_index(const char *id, const xs_dict *obj)
 61                     fclose(f);
 62                 }
 63 
 64-                srv_debug(0, xs_fmt("tagged %s #%s (#%s)", id, name, md5_tag));
 65+                srv_debug(1, xs_fmt("tagged %s #%s (#%s)", id, name, md5_tag));
 66             }
 67         }
 68     }
 69@@ -2247,7 +2256,7 @@ xs_str *tag_fn(const char *tag)
 70     if (*tag == '#')
 71         tag++;
 72 
 73-    xs *lw_tag = xs_tolower_i(xs_dup(tag));
 74+    xs *lw_tag = xs_utf8_to_lower(tag);
 75     xs *md5    = xs_md5_hex(lw_tag, strlen(lw_tag));
 76 
 77     return xs_fmt("%s/tag/%c%c/%s.idx", srv_basedir, md5[0], md5[1], md5);
 78@@ -2832,9 +2841,9 @@ int content_match(const char *file, const xs_dict *msg)
 79             srv_debug(1, xs_fmt("content_match: loading regexes from %s", fn));
 80 
 81             /* massage content (strip HTML tags, etc.) */
 82-            xs *c = xs_regex_replace(v, "<[^>]+>", " ");
 83-            c = xs_regex_replace_i(c, " {2,}", " ");
 84-            c = xs_tolower_i(c);
 85+            xs *c1 = xs_regex_replace(v, "<[^>]+>", " ");
 86+            c1 = xs_regex_replace_i(c1, " {2,}", " ");
 87+            xs *c = xs_utf8_to_lower(c1);
 88 
 89             while (!r && !feof(f)) {
 90                 xs *rx = xs_strip_i(xs_readline(f));
 91@@ -3158,6 +3167,9 @@ void notify_add(snac *snac, const char *type, const char *utype,
 92 
 93         pthread_mutex_unlock(&data_mutex);
 94     }
 95+
 96+    if (!xs_is_true(xs_dict_get(srv_config, "disable_notify_webhook")))
 97+        enqueue_notify_webhook(snac, noti, 0);
 98 }
 99 
100 
101@@ -3513,6 +3525,46 @@ void enqueue_webmention(const xs_dict *msg)
102 }
103 
104 
105+void enqueue_notify_webhook(snac *user, const xs_dict *noti, int retries)
106+/* enqueues a notification webhook */
107+{
108+    const char *webhook = xs_dict_get(user->config, "notify_webhook");
109+
110+    if (xs_is_string(webhook) && xs_match(webhook, "https://*|http://*")) { /** **/
111+        xs *msg = xs_dup(noti);
112+
113+        /* add more data */
114+        msg = xs_dict_set(msg, "target", user->actor);
115+        msg = xs_dict_set(msg, "uid", user->uid);
116+        msg = xs_dict_set(msg, "basedir", srv_basedir);
117+        msg = xs_dict_set(msg, "baseurl", srv_baseurl);
118+
119+        xs *actor_obj = NULL;
120+
121+        if (valid_status(object_get(xs_dict_get(noti, "actor"), &actor_obj)) && actor_obj)
122+            msg = xs_dict_set(msg, "account", actor_obj);
123+
124+        /* if this post is a reply, also add the inReplyTo object */
125+        const char *in_reply_to = xs_dict_get_path(msg, "msg.object.inReplyTo");
126+
127+        if (xs_is_string(in_reply_to)) {
128+            xs *irt_obj = NULL;
129+
130+            if (valid_status(object_get(in_reply_to, &irt_obj)))
131+                msg = xs_dict_set(msg, "reply", irt_obj);
132+        }
133+
134+        xs *qmsg = _new_qmsg("notify_webhook", msg, retries);
135+        const char *ntid = xs_dict_get(qmsg, "ntid");
136+        xs *fn   = xs_fmt("%s/queue/%s.json", user->basedir, ntid);
137+
138+        qmsg = _enqueue_put(fn, qmsg);
139+
140+        snac_debug(user, 1, xs_fmt("notify_webhook"));
141+    }
142+}
143+
144+
145 int was_question_voted(snac *user, const char *id)
146 /* returns true if the user voted in this poll */
147 {
+82, -17
  1@@ -24,9 +24,9 @@ For file and data formats, see
  2 .Ss Web Interface
  3 The web interface provided by
  4 .Nm
  5-is split in two data streams: the public timeline and the
  6-private timeline. There are no other feeds like the server-scoped
  7-or the federated firehoses provided by other similar ActivityPub
  8+is split in three data streams: the public timeline, the private
  9+timeline and the instance timeline. There are no other feeds like
 10+the federated firehoses provided by other similar ActivityPub
 11 implementations like Mastodon or Pleroma.
 12 .Pp
 13 The public timeline, also called the local timeline, is what an
 14@@ -67,9 +67,23 @@ sent to those people you mention in the post body.
 15 If you fill this optional text field with the URL of another one's
 16 post, your text will be considered as a reply to it, not a
 17 standalone one.
 18+.It Draft
 19+If you set this checkbox, your text will not be sent when you
 20+push the Post button, but stored for later modification in
 21+the "Drafts" section.
 22+.It Scheduled post...
 23+This dropdown menu allows setting a date and time for the
 24+post publication.
 25+.It Attachments...
 26+This dropdown menu allows uploading media attachments (images,
 27+audio, video, etc.) to your post.
 28+.It Poll...
 29+this dropdown menu gives access to the voting options, that
 30+will make your post a poll. You can set the options to be
 31+voted, if it's a multiple choice poll and the due date.
 32 .El
 33 .Pp
 34-More options are hidden under a toggle control. They are the
 35+More options are hidden under dropdown menus. They are the
 36 following:
 37 .Bl -tag -offset indent
 38 .It Follow (by URL or user@host)
 39@@ -85,7 +99,9 @@ liked.
 40 This option opens the user setup dialog.
 41 .It Followed hashtags...
 42 Enter here the list of hashtags you want to follow, one
 43-per line, with or without the # symbol.
 44+per line, with or without the # symbol. Since version 2.78,
 45+URLs to RSS feeds of ActivityPub objects are also allowed
 46+(like e.g. https://mastodon.social/tags/bloomscrolling).
 47 .It Blocked hashtags...
 48 Enter here the list of hashtags you want to block, one
 49 per line, with or without the # symbol.
 50@@ -125,6 +141,10 @@ standard ntfy.sh server), fill the two provided
 51 fields (ntfy server/topic and, if protected, the token).
 52 You need to refer to the https://ntfy.sh web site for
 53 more information on this process.
 54+.It Notify webhook
 55+If this is set to an URL, an HTTP POST will be sent to it
 56+whenever a new notification happens (see the 'Webhook for
 57+notifications' section below for more information).
 58 .It Maximum days to keep posts
 59 This numeric value specifies the number of days to pass before
 60 posts (yours and others') will be purged. This value overrides
 61@@ -264,7 +284,7 @@ Requests an object and dumps it to stdout. This is a very low
 62 level command that is not very useful to you.
 63 .It Cm announce Ar basedir Ar uid Ar url
 64 Announces (boosts) a post via its URL.
 65-.It Cm note Ar basedir Ar uid Ar text Op file file ...
 66+.It Cm note Ar basedir Ar uid Ar text Op file file ... Op -r inReplyTo
 67 Enqueues a Create + Note message to all followers. If the
 68 .Ar text
 69 argument is -e, the external editor defined by the EDITOR
 70@@ -272,10 +292,11 @@ environment variable will be invoked to prepare a message; if
 71 it's - (a lonely hyphen), the post content will be read from stdin.
 72 The rest of command line arguments are treated as media files to be
 73 attached to the post. The LANG environment variable (if defined) is used
 74-as the post language.
 75-.It Cm note_unlisted Ar basedir Ar uid Ar text Op file file ...
 76+as the post language. An optional URL to a Fediverse post, prefixed by -r,
 77+can be specified for this note to be a reply to.
 78+.It Cm note_unlisted Ar basedir Ar uid Ar text Op file file ... Op -r inReplyTo
 79 Like the previous one, but creates an "unlisted" (or "quiet public") post.
 80-.It Cm note_mention Ar basedir Ar uid Ar text Op file file ...
 81+.It Cm note_mention Ar basedir Ar uid Ar text Op file file ... Op -r inReplyTo
 82 Like the previous one, but creates a post only for accounts mentioned
 83 in the post body.
 84 .It Cm block Ar basedir Ar instance_url
 85@@ -285,9 +306,9 @@ blocked without further inspection.
 86 .It Cm unblock Ar basedir Ar instance_url
 87 Unblocks a previously blocked instance.
 88 .It Cm verify_links Ar basedir Ar uid
 89-Verifies all links stored as metadata for the given user. This verification
 90-is done by downloading the link content and searching for a link back to
 91-the
 92+Verifies all links or account handles stored as metadata for the given user.
 93+This verification is done by downloading the link content and searching for
 94+a link back to the
 95 .Nm
 96 user url that also contains a rel="me" attribute. These links are specially
 97 marked as verified in the user's public timeline and also via the Mastodon API.
 98@@ -300,6 +321,13 @@ subdirectory inside the user directory:
 99 .Pa blocked_accounts.csv ,
100 .Pa lists.csv , and
101 .Pa following_accounts.csv .
102+.It Cm export_posts Ar basedir Ar uid
103+Exports all posts written by the user to the file
104+.Pa outbox.json
105+inside the
106+.Pa export/
107+subdirectory inside the user directory. The format is compatible with the
108+one generated by the equivalent option in Mastodon.
109 .It Cm alias Ar basedir Ar uid Ar "@account@remotehost"
110 Sets an account as an alias of this one. This is a necessary step to migrate
111 an account to a remote Mastodon instance (see
112@@ -354,10 +382,10 @@ subdirectory of a user's directory inside the server base directory.
113 Prints the name of the user created lists.
114 .It Cm list_members Ar basedir Ar uid Ar name
115 Prints the list of actors in the named list.
116-.It Cm create_list Ar basedir Ar uid Ar name
117+.It Cm list_create Ar basedir Ar uid Ar name
118 Creates a new list.
119-.It Cm delete_list Ar basedir Ar uid Ar name
120-Deletes an existing list.
121+.It Cm list_remove Ar basedir Ar uid Ar name
122+Removes an existing list.
123 .It Cm list_add Ar basedir Ar uid Ar name Ar account
124 Adds an account (by its @name@host handle or actor URL) to a list.
125 .It Cm list_del Ar basedir Ar uid Ar name Ar actor_url
126@@ -408,6 +436,43 @@ You can obtain an API Token by connecting to the following URL:
127 https://$SNAC_HOST/oauth/x-snac-get-token
128 .Ed
129 .Pp
130+.Ss Webhook for notifications
131+Since version 2.78, users can set the URL to a webhook that will receive
132+an HTTP POST with every notification (in JSON format). This can be used to
133+implement some automation whenever a new activity happens, like autorepliers,
134+chatbots, interactive textual games or whatever. The
135+.Pa examples/
136+subdirectory contains a tiny Python program that implements an auto-follower
137+for every new follow. The JSON notification object includes the following data:
138+.Bl -tag -offset indent
139+.It id
140+a unique notification identifier
141+.It actor
142+the origin actor id
143+.It target
144+the target actor id
145+.It date
146+the notification date
147+.It msg
148+the full ActivityPub action JSON object
149+.It objid
150+the object identifier (extracted from msg, may be null)
151+.It type
152+the action type (extracted from msg)
153+.It utype
154+the action subtype (extracted from msg, may be null)
155+.It uid
156+the user identifier (account name)
157+.It basedir
158+the server base directory
159+.It baseurl
160+the server base URL
161+.It account
162+the origin actor object
163+.It reply
164+the activity this post is a reply to (may not exist)
165+.El
166+.Pp
167 .Sh ENVIRONMENT
168 .Bl -tag -width Ds
169 .It SNAC_BASEDIR
170@@ -422,9 +487,9 @@ Overrides the debugging level from the server 'dbglevel' configuration
171 variable. Set it to an integer value. The higher, the deeper in meaningless
172 verbiage you'll find yourself into.
173 .It Ev EDITOR
174-The user-preferred interactive text editor to prepare messages.
175+The user-preferred interactive text editor to prepare notes.
176 .It Ev LANG
177-The language of the post when sending messages.
178+The language of the post when sending notes from the command line.
179 .El
180 .Sh SEE ALSO
181 .Xr snac 5 ,
+10, -0
 1@@ -207,6 +207,8 @@ fields are set (see below), they are also shown.
 2 The email address of the instance administrator (optional).
 3 .It Ic admin_account
 4 The user name of the instance administrator (optional).
 5+.It Ic title
 6+The name of the instance (optional).
 7 .It Ic short_description
 8 A textual short description about the instance (optional).
 9 .It Ic short_description_raw
10@@ -277,6 +279,14 @@ the usual one, like in smtp://mail.example.com:587.
11 .It Ic smtp_user
12 .It Ic smtp_password
13 To be filled if the SMTP server defined by the previous directive needs credentials.
14+.It Ic rss_hashtag_poll_hours
15+The periodic number of hours hashtag RSS are polled (default: 4). It has a minimum
16+value of 1 to avoid hammering servers.
17+.It Ic disable_notify_webhook
18+Since version 2.78, users can set a webhook URL to receive notifications. Set this
19+to true if you don't want your users to have this privilege.
20+.It Ic favicon_url
21+The URL to a favicon. If it's not set, the default one is used instead.
22 .El
23 .Pp
24 You must restart the server to make effective these changes.
+1, -0
1@@ -1,5 +1,6 @@
2 body { max-width: 48em; margin: auto; line-height: 1.5; padding: 0.8em; word-wrap: break-word; }
3 pre { overflow-x: scroll; }
4+blockquote { font-style: italic; }
5 .snac-embedded-video, img { max-width: 100% }
6 .snac-origin { font-size: 85% }
7 .snac-score { float: right; font-size: 85% }
+55, -0
 1@@ -0,0 +1,55 @@
 2+#!/usr/bin/env python3
 3+
 4+# This is an example of a snac webhook that automatically follows all new followers.
 5+
 6+# To use it, configure the user webhook to be http://localhost:12345, and run this program.
 7+
 8+# copyright (C) 2025 grunfink et al. / MIT license
 9+
10+from http.server import BaseHTTPRequestHandler, HTTPServer
11+import time
12+import json
13+import os
14+
15+host_name = "localhost"
16+server_port = 12345
17+
18+class SnacAutoResponderServer(BaseHTTPRequestHandler):
19+
20+    def do_POST(self):
21+        self.send_response(200)
22+        self.end_headers()
23+
24+        content_type = self.headers["content-type"]
25+        content_length = int(self.headers["content-length"])
26+        payload = self.rfile.read(content_length).decode("utf-8")
27+
28+        if content_type == "application/json":
29+            try:
30+                noti = json.loads(payload)
31+
32+                type = noti["type"]
33+
34+                if type == "Follow":
35+                    actor = noti["actor"]
36+                    uid = noti["uid"]
37+                    basedir = noti["basedir"]
38+
39+                    cmd = "snac follow %s %s %s" % (basedir, uid, actor)
40+
41+                    os.system(cmd)
42+
43+            except:
44+                print("Error parsing notification")
45+
46+if __name__ == "__main__":        
47+    webServer = HTTPServer((host_name, server_port), SnacAutoResponderServer)
48+    print("Webhook started http://%s:%s" % (host_name, server_port))
49+
50+    try:
51+        webServer.serve_forever()
52+    except KeyboardInterrupt:
53+        pass
54+
55+    webServer.server_close()
56+    print("Webhook stopped.")
+18, -14
 1@@ -8,6 +8,7 @@
 2 #include "xs_json.h"
 3 #include "xs_time.h"
 4 #include "xs_match.h"
 5+#include "xs_unicode.h"
 6 
 7 #include "snac.h"
 8 
 9@@ -95,8 +96,8 @@ static xs_str *format_line(const char *line, xs_list **attach)
10             "~~[^~]+~~"                         "|"
11             "\\*\\*?\\*?[^\\*]+\\*?\\*?\\*"     "|"
12             "__[^_]+__"                         "|" //anzu
13-            "!\\[[^]]+\\]\\([^\\)]+\\)"         "|"
14-            "\\[[^]]+\\]\\([^\\)]+\\)"          "|"
15+            "!\\[[^]]+\\]\\([^\\)]+\\)\\)?"     "|"
16+            "\\[[^]]+\\]\\([^\\)]+\\)\\)?"      "|"
17             "[a-z]+:/" "/" NOSPACE              "|"
18             "(mailto|xmpp):[^@[:space:]]+@" NOSPACE
19         ")");
20@@ -148,14 +149,15 @@ static xs_str *format_line(const char *line, xs_list **attach)
21             else
22             if (*v == '[') {
23                 /* markdown-like links [label](url) */
24-                xs *w = xs_strip_chars_i(
25-                    xs_replace_i(xs_replace(v, "#", "&#35;"), "@", "&#64;"),
26-                "![)");
27+                xs *w = xs_replace_i(xs_replace(v, "#", "&#35;"), "@", "&#64;");
28                 xs *l = xs_split_n(w, "](", 1);
29 
30                 if (xs_list_len(l) == 2) {
31-                    const char *name = xs_list_get(l, 0);
32-                    const char *url  = xs_list_get(l, 1);
33+                    xs *name = xs_dup(xs_list_get(l, 0));
34+                    xs *url  = xs_dup(xs_list_get(l, 1));
35+
36+                    name = xs_crop_i(name, 1, 0);
37+                    url  = xs_crop_i(url, 0, -1);
38 
39                     xs *link = xs_fmt("<a href=\"%s\">%s</a>", url, name);
40 
41@@ -167,15 +169,17 @@ static xs_str *format_line(const char *line, xs_list **attach)
42             else
43             if (*v == '!') {
44                 /* markdown-like images ![alt text](url to image) */
45-                xs *w = xs_strip_chars_i(
46-                    xs_replace_i(xs_replace(v, "#", "&#35;"), "@", "&#64;"),
47-                "![)");
48+                xs *w = xs_replace_i(xs_replace(v, "#", "&#35;"), "@", "&#64;");
49                 xs *l = xs_split_n(w, "](", 1);
50 
51                 if (xs_list_len(l) == 2) {
52-                    const char *alt_text = xs_list_get(l, 0);
53-                    const char *img_url  = xs_list_get(l, 1);
54-                    const char *mime     = xs_mime_by_ext(img_url);
55+                    xs *alt_text = xs_dup(xs_list_get(l, 0));
56+                    xs *img_url  = xs_dup(xs_list_get(l, 1));
57+
58+                    alt_text = xs_crop_i(alt_text, 2, 0);
59+                    img_url  = xs_crop_i(img_url, 0, -1);
60+
61+                    const char *mime = xs_mime_by_ext(img_url);
62 
63                     if (attach != NULL && xs_startswith(mime, "image/")) {
64                         const xs_dict *ad;
65@@ -443,7 +447,7 @@ xs_str *sanitize(const char *content)
66         if (n & 0x1) {
67             xs *s1  = xs_strip_i(xs_crop_i(xs_dup(v), v[1] == '/' ? 2 : 1, -1));
68             xs *l1  = xs_split_n(s1, " ", 1);
69-            xs *tag = xs_tolower_i(xs_dup(xs_list_get(l1, 0)));
70+            xs *tag = xs_utf8_to_lower(xs_list_get(l1, 0));
71             xs *s2  = NULL;
72             int i;
73 
M html.c
+94, -130
  1@@ -636,13 +636,18 @@ static xs_html *html_base_head(void)
  2             xs_html_attr("content", USER_AGENT)));
  3 
  4     /* add server CSS and favicon */
  5-    xs *f;
  6-    f = xs_fmt("%s/favicon.ico", srv_baseurl);
  7+    xs *f = NULL;
  8+    const char *favicon = xs_dict_get(srv_config, "favicon_url");
  9+
 10+    if (xs_is_string(favicon))
 11+        f = xs_dup(favicon);
 12+    else
 13+        f = xs_fmt("%s/favicon.ico", srv_baseurl);
 14+
 15     const xs_list *p = xs_dict_get(srv_config, "cssurls");
 16     const char *v;
 17-    int c = 0;
 18 
 19-    while (xs_list_next(p, &v, &c)) {
 20+    xs_list_foreach(p, v) {
 21         xs_html_add(head,
 22             xs_html_sctag("link",
 23                 xs_html_attr("rel",  "stylesheet"),
 24@@ -863,6 +868,14 @@ xs_html *html_user_head(snac *user, const char *desc, const char *url)
 25             xs_html_attr("type", "application/activity+json"),
 26             xs_html_attr("href", url ? url : user->actor)));
 27 
 28+    /* webmention hook */
 29+    xs *wbh = xs_fmt("%s/webmention-hook", srv_baseurl);
 30+
 31+    xs_html_add(head,
 32+        xs_html_sctag("link",
 33+            xs_html_attr("rel", "webmention"),
 34+            xs_html_attr("href", wbh)));
 35+
 36     return head;
 37 }
 38 
 39@@ -1077,10 +1090,17 @@ static xs_html *html_user_body(snac *user, int read_only)
 40             while (xs_dict_next(metadata, &k, &v, &c)) {
 41                 xs_html *value;
 42 
 43-                if (xs_startswith(v, "https:/") || xs_startswith(v, "http:/")) {
 44+                if (xs_startswith(v, "https:/") || xs_startswith(v, "http:/") || *v == '@') {
 45                     /* is this link validated? */
 46                     xs *verified_link = NULL;
 47                     const xs_number *val_time = xs_dict_get(val_links, v);
 48+                    const char *url = NULL;
 49+
 50+                    if (xs_is_string(val_time)) {
 51+                        /* resolve again, as it may be an account handle */
 52+                        url = val_time;
 53+                        val_time = xs_dict_get(val_links, val_time);
 54+                    }
 55 
 56                     if (xs_type(val_time) == XSTYPE_NUMBER) {
 57                         time_t t = xs_number_get(val_time);
 58@@ -1098,13 +1118,13 @@ static xs_html *html_user_body(snac *user, int read_only)
 59                             xs_html_tag("a",
 60                                 xs_html_attr("rel", "me"),
 61                                 xs_html_attr("target", "_blank"),
 62-                                xs_html_attr("href", v),
 63+                                xs_html_attr("href", url ? url : v),
 64                                 xs_html_text(v)));
 65                     }
 66                     else {
 67                         value = xs_html_tag("a",
 68                             xs_html_attr("rel", "me"),
 69-                            xs_html_attr("href", v),
 70+                            xs_html_attr("href", url ? url : v),
 71                             xs_html_text(v));
 72                     }
 73                 }
 74@@ -1290,6 +1310,7 @@ xs_html *html_top_controls(snac *user)
 75     const xs_val *show_foll  = xs_dict_get(user->config, "show_contact_metrics");
 76     const char *latitude     = xs_dict_get_def(user->config, "latitude", "");
 77     const char *longitude    = xs_dict_get_def(user->config, "longitude", "");
 78+    const char *webhook      = xs_dict_get_def(user->config, "notify_webhook", "");
 79 
 80     xs *metadata = NULL;
 81     const xs_dict *md = xs_dict_get(user->config, "metadata");
 82@@ -1451,6 +1472,14 @@ xs_html *html_top_controls(snac *user)
 83                         xs_html_attr("name", "ntfy_token"),
 84                         xs_html_attr("value", ntfy_token),
 85                         xs_html_attr("placeholder", L("ntfy token - if needed")))),
 86+                xs_html_tag("p",
 87+                    xs_html_text(L("Notify webhook:")),
 88+                    xs_html_sctag("br", NULL),
 89+                    xs_html_sctag("input",
 90+                        xs_html_attr("type", "url"),
 91+                        xs_html_attr("name", "notify_webhook"),
 92+                        xs_html_attr("value", webhook),
 93+                        xs_html_attr("placeholder", L("http://example.com/webhook")))),
 94                 xs_html_tag("p",
 95                     xs_html_text(L("Maximum days to keep posts (0: server settings):")),
 96                     xs_html_sctag("br", NULL),
 97@@ -1601,7 +1630,8 @@ xs_html *html_top_controls(snac *user)
 98                     xs_html_attr("name", "followed_hashtags"),
 99                     xs_html_attr("cols", "40"),
100                     xs_html_attr("rows", "4"),
101-                    xs_html_attr("placeholder", "#cats\n#windowfriday\n#classicalmusic"),
102+                    xs_html_attr("placeholder", "#cats\n#windowfriday\n#classicalmusic\nhttps:/"
103+                        "/mastodon.social/tags/dogs"),
104                     xs_html_text(followed_hashtags)),
105 
106                 xs_html_tag("br", NULL),
107@@ -1978,8 +2008,13 @@ xs_html *html_entry(snac *user, xs_dict *msg, int read_only,
108     }
109 
110     if ((user == NULL || strcmp(actor, user->actor) != 0)
111-        && !valid_status(actor_get(actor, NULL)))
112+        && !valid_status(actor_get(actor, NULL))) {
113+
114+        if (user)
115+            enqueue_actor_refresh(user, actor, 0);
116+
117         return NULL;
118+    }
119 
120     /** html_entry top tag **/
121     xs_html *entry_top = xs_html_tag("div", NULL);
122@@ -2115,9 +2150,7 @@ xs_html *html_entry(snac *user, xs_dict *msg, int read_only,
123         const char *parent = get_in_reply_to(msg);
124 
125         if (!xs_is_null(parent) && *parent) {
126-            xs *md5 = xs_md5_hex(parent, strlen(parent));
127-
128-            if (!timeline_here(user, md5)) {
129+            if (!timeline_here(user, parent)) {
130                 xs_html_add(post_header,
131                     xs_html_tag("div",
132                         xs_html_attr("class", "snac-origin"),
133@@ -2419,6 +2452,9 @@ xs_html *html_entry(snac *user, xs_dict *msg, int read_only,
134             const char *o_href = xs_dict_get(a, "href");
135             const char *name = xs_dict_get(a, "name");
136 
137+            if (!xs_is_string(type) || !xs_is_string(o_href))
138+                continue;
139+
140             /* if this URL is already in the post content, skip */
141             if (content && xs_str_in(content, o_href) != -1)
142                 continue;
143@@ -3284,12 +3320,12 @@ xs_str *html_people(snac *user)
144 
145     xs *wing = following_list(user);
146     xs *wers = follower_list(user);
147+    xs *pending = pending_list(user);
148 
149     xs_html *lists = xs_html_tag("div",
150         xs_html_attr("class", "snac-posts"));
151 
152-    if (xs_is_true(xs_dict_get(user->config, "approve_followers"))) {
153-        xs *pending = pending_list(user);
154+    if (xs_list_len(pending) || xs_is_true(xs_dict_get(user->config, "approve_followers"))) {
155         xs_html_add(lists,
156             html_people_list(user, pending, L("Pending follow confirmations"), "p", proxy));
157     }
158@@ -3384,13 +3420,15 @@ xs_str *html_notifications(snac *user, int skip, int show)
159 
160         const char *actor_id = xs_dict_get(noti, "actor");
161         xs *actor = NULL;
162+        xs *a_name = NULL;
163 
164-        if (!valid_status(actor_get(actor_id, &actor)))
165-            continue;
166+        if (valid_status(actor_get(actor_id, &actor)))
167+            a_name = actor_name(actor, proxy);
168+        else
169+            a_name = xs_dup(actor_id);
170 
171-        xs *a_name = actor_name(actor, proxy);
172-        xs *label_sanatized = sanitize(type);
173-        const char *label = label_sanatized;
174+        xs *label_sanitized = sanitize(type);
175+        const char *label = label_sanitized;
176 
177         if (strcmp(type, "Create") == 0)
178             label = L("Mention");
179@@ -3462,10 +3500,11 @@ xs_str *html_notifications(snac *user, int skip, int show)
180             html_label);
181 
182         if (strcmp(type, "Follow") == 0 || strcmp(utype, "Follow") == 0 || strcmp(type, "Block") == 0) {
183-            xs_html_add(entry,
184-                xs_html_tag("div",
185-                    xs_html_attr("class", "snac-post"),
186-                    html_actor_icon(user, actor, NULL, NULL, NULL, 0, 0, proxy, NULL, NULL)));
187+            if (actor)
188+                xs_html_add(entry,
189+                    xs_html_tag("div",
190+                        xs_html_attr("class", "snac-post"),
191+                        html_actor_icon(user, actor, NULL, NULL, NULL, 0, 0, proxy, NULL, NULL)));
192         }
193         else
194         if (strcmp(type, "Move") == 0) {
195@@ -3498,6 +3537,13 @@ xs_str *html_notifications(snac *user, int skip, int show)
196                             xs_html_text(L("Context")))),
197                     h);
198             }
199+            else
200+                xs_html_add(entry,
201+                    xs_html_tag("p",
202+                        xs_html_text(L("Location: ")),
203+                        xs_html_tag("a",
204+                            xs_html_attr("href", id),
205+                            xs_html_text(id))));
206         }
207 
208         if (strcmp(v, n_time) > 0) {
209@@ -3748,7 +3794,7 @@ int html_get_handler(const xs_dict *req, const char *q_path,
210                 /* may by an actor; try a webfinger */
211                 xs *actor_obj = NULL;
212 
213-                if (valid_status(webfinger_request(q, &actor_obj, &url_acct))) {
214+                if (valid_status(webfinger_request(q, &actor_obj, &url_acct)) && xs_is_string(url_acct)) {
215                     /* it's an actor; do the dirty trick of changing q to the account name */
216                     q = url_acct;
217                 }
218@@ -3773,12 +3819,18 @@ int html_get_handler(const xs_dict *req, const char *q_path,
219                                 q = url_acct;
220 
221                                 /* add the post to the timeline */
222-                                xs *md5 = xs_md5_hex(q, strlen(q));
223-
224-                                if (!timeline_here(&snac, md5))
225+                                if (!timeline_here(&snac, q))
226                                     timeline_add(&snac, q, object);
227                             }
228                         }
229+                        else {
230+                            /* retry webfinger, this time with the 'official' id */
231+                            const char *id = xs_dict_get(object, "id");
232+
233+                            if (xs_is_string(id) && valid_status(webfinger_request(id, &actor_obj, &url_acct)) &&
234+                                xs_is_string(url_acct))
235+                                q = url_acct;
236+                        }
237                     }
238                 }
239 
240@@ -3917,7 +3969,7 @@ int html_get_handler(const xs_dict *req, const char *q_path,
241             xs *l = xs_split(p_path, "/");
242             const char *md5 = xs_list_get(l, -1);
243 
244-            if (md5 && *md5 && timeline_here(&snac, md5)) {
245+            if (md5 && *md5 && timeline_here_by_md5(&snac, md5)) {
246                 xs *list0 = xs_list_append(xs_list_new(), md5);
247                 xs *list  = timeline_top_level(&snac, list0);
248 
249@@ -4127,7 +4179,7 @@ int html_get_handler(const xs_dict *req, const char *q_path,
250             xs_dict_get(srv_config, "host"));
251         xs *rss_link = xs_fmt("%s.rss", snac.actor);
252 
253-        *body   = timeline_to_rss(&snac, elems, rss_title, rss_link, bio);
254+        *body   = rss_from_timeline(&snac, elems, rss_title, rss_link, bio);
255         *b_size = strlen(*body);
256         *ctype  = "application/rss+xml; charset=utf-8";
257         status  = HTTP_STATUS_OK;
258@@ -4365,7 +4417,7 @@ int html_post_handler(const xs_dict *req, const char *q_path,
259                     xs_rnd_buf(rnd, sizeof(rnd));
260 
261                     const char *ext = strrchr(fn, '.');
262-                    xs *hash  = xs_md5_hex(rnd, strlen(rnd));
263+                    xs *hash  = xs_md5_hex(rnd, sizeof(rnd));
264                     xs *id    = xs_fmt("post-%s%s", hash, ext ? ext : "");
265                     xs *url   = xs_fmt("%s/s/%s", snac.actor, id);
266                     int fo    = xs_number_get(xs_list_get(attach_file, 1));
267@@ -4825,6 +4877,8 @@ int html_post_handler(const xs_dict *req, const char *q_path,
268         snac.config = xs_dict_set(snac.config, "latitude", xs_dict_get_def(p_vars, "latitude", ""));
269         snac.config = xs_dict_set(snac.config, "longitude", xs_dict_get_def(p_vars, "longitude", ""));
270 
271+        snac.config = xs_dict_set(snac.config, "notify_webhook", xs_dict_get_def(p_vars, "notify_webhook", ""));
272+
273         if ((v = xs_dict_get(p_vars, "metadata")) != NULL)
274             snac.config = xs_dict_set(snac.config, "metadata", v);
275 
276@@ -4960,9 +5014,16 @@ int html_post_handler(const xs_dict *req, const char *q_path,
277                 if (*s1 == '\0')
278                     continue;
279 
280-                xs *s2 = xs_utf8_to_lower(s1);
281-                if (*s2 != '#')
282-                    s2 = xs_str_prepend_i(s2, "#");
283+                xs *s2 = NULL;
284+
285+                if (xs_startswith(s1, "https:/"))
286+                    s2 = xs_dup(s1);
287+                else {
288+                    s2 = xs_utf8_to_lower(s1);
289+
290+                    if (*s2 != '#')
291+                        s2 = xs_str_prepend_i(s2, "#");
292+                }
293 
294                 new_hashtags = xs_list_append(new_hashtags, s2);
295             }
296@@ -5024,100 +5085,3 @@ int html_post_handler(const xs_dict *req, const char *q_path,
297 
298     return status;
299 }
300-
301-
302-xs_str *timeline_to_rss(snac *user, const xs_list *timeline,
303-                        const char *title, const char *link, const char *desc)
304-/* converts a timeline to rss */
305-{
306-    xs_html *rss = xs_html_tag("rss",
307-        xs_html_attr("xmlns:content", "http:/" "/purl.org/rss/1.0/modules/content/"),
308-        xs_html_attr("version",       "2.0"),
309-        xs_html_attr("xmlns:atom",    "http:/" "/www.w3.org/2005/Atom"));
310-
311-    xs_html *channel = xs_html_tag("channel",
312-        xs_html_tag("title",
313-            xs_html_text(title)),
314-        xs_html_tag("language",
315-            xs_html_text("en")),
316-        xs_html_tag("link",
317-            xs_html_text(link)),
318-        xs_html_sctag("atom:link",
319-            xs_html_attr("href", link),
320-            xs_html_attr("rel", "self"),
321-            xs_html_attr("type", "application/rss+xml")),
322-        xs_html_tag("generator",
323-            xs_html_text(USER_AGENT)),
324-        xs_html_tag("description",
325-            xs_html_text(desc)));
326-
327-    xs_html_add(rss, channel);
328-
329-    int cnt = 0;
330-    const char *v;
331-
332-    xs_list_foreach(timeline, v) {
333-        xs *msg = NULL;
334-
335-        if (user) {
336-            if (!valid_status(timeline_get_by_md5(user, v, &msg)))
337-                continue;
338-        }
339-        else {
340-            if (!valid_status(object_get_by_md5(v, &msg)))
341-                continue;
342-        }
343-
344-        const char *id = xs_dict_get(msg, "id");
345-        const char *content = xs_dict_get(msg, "content");
346-        const char *published = xs_dict_get(msg, "published");
347-
348-        if (user && !xs_startswith(id, user->actor))
349-            continue;
350-
351-        if (!id || !content || !published)
352-            continue;
353-
354-        /* create a title with the first line of the content */
355-        xs *title = xs_replace(content, "<br>", "\n");
356-        title = xs_regex_replace_i(title, "<[^>]+>", " ");
357-        title = xs_regex_replace_i(title, "&[^;]+;", " ");
358-        int i;
359-
360-        for (i = 0; title[i] && title[i] != '\n' && i < 50; i++);
361-
362-        if (title[i] != '\0') {
363-            title[i] = '\0';
364-            title = xs_str_cat(title, "...");
365-        }
366-
367-        title = xs_strip_i(title);
368-
369-        /* convert the date */
370-        time_t t = xs_parse_iso_date(published, 0);
371-        xs *rss_date = xs_str_utctime(t, "%a, %d %b %Y %T +0000");
372-
373-        /* if it's the first one, add it to the header */
374-        if (cnt == 0)
375-            xs_html_add(channel,
376-                xs_html_tag("lastBuildDate",
377-                    xs_html_text(rss_date)));
378-
379-        xs_html_add(channel,
380-            xs_html_tag("item",
381-                xs_html_tag("title",
382-                    xs_html_text(title)),
383-                xs_html_tag("link",
384-                    xs_html_text(id)),
385-                xs_html_tag("guid",
386-                    xs_html_text(id)),
387-                xs_html_tag("pubDate",
388-                    xs_html_text(rss_date)),
389-                xs_html_tag("description",
390-                    xs_html_text(content))));
391-
392-        cnt++;
393-    }
394-
395-    return xs_html_render_s(rss, "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
396-}
+106, -14
  1@@ -12,6 +12,7 @@
  2 #include "xs_openssl.h"
  3 #include "xs_fcgi.h"
  4 #include "xs_html.h"
  5+#include "xs_webmention.h"
  6 
  7 #include "snac.h"
  8 
  9@@ -65,7 +66,9 @@ const char *nodeinfo_2_0_template = ""
 10     "\"services\":{\"outbound\":[],\"inbound\":[]},"
 11     "\"usage\":{\"users\":{\"total\":%d,\"activeMonth\":%d,\"activeHalfyear\":%d},"
 12     "\"localPosts\":%d},"
 13-    "\"openRegistrations\":false,\"metadata\":{}}";
 14+    "\"openRegistrations\":false,\"metadata\":{"
 15+    "\"nodeDescription\":\"%s\",\"nodeName\":\"%s\""
 16+    "}}";
 17 
 18 xs_str *nodeinfo_2_0(void)
 19 /* builds a nodeinfo json object */
 20@@ -98,7 +101,10 @@ xs_str *nodeinfo_2_0(void)
 21         n_posts += index_len(pidxfn);
 22     }
 23 
 24-    return xs_fmt(nodeinfo_2_0_template, n_utotal, n_umonth, n_uhyear, n_posts);
 25+    const char *name = xs_dict_get_def(srv_config, "title", "");
 26+    const char *desc = xs_dict_get_def(srv_config, "short_description", "");
 27+
 28+    return xs_fmt(nodeinfo_2_0_template, n_utotal, n_umonth, n_uhyear, n_posts, desc, name);
 29 }
 30 
 31 
 32@@ -244,7 +250,7 @@ int server_get_handler(xs_dict *req, const char *q_path,
 33             if (!xs_is_null(accept) && strcmp(accept, "application/rss+xml") == 0) {
 34                 xs *link = xs_fmt("%s/?t=%s", srv_baseurl, t);
 35 
 36-                *body = timeline_to_rss(NULL, tl, link, link, link);
 37+                *body = rss_from_timeline(NULL, tl, link, link, link);
 38                 *ctype = "application/rss+xml; charset=utf-8";
 39             }
 40             else {
 41@@ -373,6 +379,68 @@ int server_get_handler(xs_dict *req, const char *q_path,
 42 }
 43 
 44 
 45+int server_post_handler(const xs_dict *req, const char *q_path,
 46+                      char *payload, int p_size,
 47+                      char **body, int *b_size, char **ctype)
 48+{
 49+    int status = 0;
 50+
 51+    (void)payload;
 52+    (void)p_size;
 53+    (void)body;
 54+    (void)b_size;
 55+    (void)ctype;
 56+
 57+    if (strcmp(q_path, "/webmention-hook") == 0) {
 58+        status = HTTP_STATUS_BAD_REQUEST;
 59+
 60+        const xs_dict *p_vars = xs_dict_get(req, "p_vars");
 61+
 62+        if (!xs_is_dict(p_vars))
 63+            return status;
 64+
 65+        const char *source = xs_dict_get(p_vars, "source");
 66+        const char *target = xs_dict_get(p_vars, "target");
 67+
 68+        if (!xs_is_string(source) || !xs_is_string(target)) {
 69+            srv_debug(1, xs_fmt("webmention-hook bad source or target"));
 70+            return status;
 71+        }
 72+
 73+        if (!xs_startswith(target, srv_baseurl)) {
 74+            srv_debug(1, xs_fmt("webmention-hook unknown target %s", target));
 75+            return status;
 76+        }
 77+
 78+        /* get the user */
 79+        xs *s1 = xs_replace(target, srv_baseurl, "");
 80+
 81+        xs *l1 = xs_split(s1, "/");
 82+        const char *uid = xs_list_get(l1, 1);
 83+        snac user;
 84+
 85+        if (!xs_is_string(uid) || !user_open(&user, uid)) {
 86+            srv_debug(1, xs_fmt("webmention-hook cannot find user for %s", target));
 87+            return status;
 88+        }
 89+
 90+        int r = xs_webmention_hook(source, target, USER_AGENT);
 91+
 92+        if (r > 0) {
 93+            notify_add(&user, "Webmention", NULL, source, target, xs_stock(XSTYPE_DICT));
 94+            timeline_touch(&user);
 95+        }
 96+
 97+        srv_log(xs_fmt("webmention-hook source=%s target=%s %d", source, target, r));
 98+
 99+        user_free(&user);
100+        status = HTTP_STATUS_OK;
101+    }
102+
103+    return status;
104+}
105+
106+
107 void httpd_connection(FILE *f)
108 /* the connection processor */
109 {
110@@ -444,6 +512,10 @@ void httpd_connection(FILE *f)
111     else
112     if (strcmp(method, "POST") == 0) {
113 
114+        if (status == 0)
115+            status = server_post_handler(req, q_path,
116+                        payload, p_size, &body, &b_size, &ctype);
117+
118 #ifndef NO_MASTODON_API
119         if (status == 0)
120             status = oauth_post_handler(req, q_path,
121@@ -705,34 +777,36 @@ static pthread_cond_t  sleep_cond;
122 static void *background_thread(void *arg)
123 /* background thread (queue management and other things) */
124 {
125-    time_t purge_time;
126+    time_t t, purge_time, rss_time;
127 
128     (void)arg;
129 
130+    t = time(NULL);
131+
132     /* first purge time */
133-    purge_time = time(NULL) + 10 * 60;
134+    purge_time = t + 10 * 60;
135+
136+    /* first RSS polling time */
137+    rss_time = t + 15 * 60;
138 
139     srv_log(xs_fmt("background thread started"));
140 
141     while (p_state->srv_running) {
142-        time_t t;
143         int cnt = 0;
144 
145         p_state->th_state[0] = THST_QUEUE;
146 
147         {
148             xs *list = user_list();
149-            char *p;
150             const char *uid;
151 
152             /* process queues for all users */
153-            p = list;
154-            while (xs_list_iter(&p, &uid)) {
155-                snac snac;
156+            xs_list_foreach(list, uid) {
157+                snac user;
158 
159-                if (user_open(&snac, uid)) {
160-                    cnt += process_user_queue(&snac);
161-                    user_free(&snac);
162+                if (user_open(&user, uid)) {
163+                    cnt += process_user_queue(&user);
164+                    user_free(&user);
165                 }
166             }
167         }
168@@ -740,8 +814,10 @@ static void *background_thread(void *arg)
169         /* global queue */
170         cnt += process_queue();
171 
172+        t = time(NULL);
173+
174         /* time to purge? */
175-        if ((t = time(NULL)) > purge_time) {
176+        if (t > purge_time) {
177             /* next purge time is tomorrow */
178             purge_time = t + 24 * 60 * 60;
179 
180@@ -750,6 +826,22 @@ static void *background_thread(void *arg)
181             job_post(q_item, 0);
182         }
183 
184+        /* time to poll the RSS? */
185+        if (t > rss_time) {
186+            /* next RSS poll time */
187+            int hours = xs_number_get(xs_dict_get_def(srv_config, "rss_hashtag_poll_hours", "4"));
188+
189+            /* don't hammer servers too much */
190+            if (hours < 1)
191+                hours = 1;
192+
193+            rss_time = t + 60 * 60 * hours;
194+
195+            xs *q_item = xs_dict_new();
196+            q_item = xs_dict_append(q_item, "type", "rss_hashtag_poll");
197+            job_post(q_item, 0);
198+        }
199+
200         if (cnt == 0) {
201             /* sleep 3 seconds */
202 
M main.c
+108, -58
  1@@ -7,64 +7,90 @@
  2 #include "xs_time.h"
  3 #include "xs_openssl.h"
  4 #include "xs_match.h"
  5+#include "xs_random.h"
  6 
  7 #include "snac.h"
  8 
  9 #include <sys/stat.h>
 10 #include <sys/wait.h>
 11 
 12-int usage(void)
 13+int usage(const char *cmd)
 14 {
 15     printf("snac " VERSION " - A simple, minimalistic ActivityPub instance\n");
 16     printf("Copyright (c) 2022 - 2025 grunfink et al. / MIT license\n");
 17     printf("\n");
 18-    printf("Commands:\n");
 19-    printf("\n");
 20-    printf("init [{basedir}]                     Initializes the data storage\n");
 21-    printf("upgrade {basedir}                    Upgrade to a new version\n");
 22-    printf("adduser {basedir} [{uid}]            Adds a new user\n");
 23-    printf("deluser {basedir} {uid}              Deletes a user\n");
 24-    printf("httpd {basedir}                      Starts the HTTPD daemon\n");
 25-    printf("purge {basedir}                      Purges old data\n");
 26-    printf("state {basedir}                      Prints server state\n");
 27-    printf("webfinger {basedir} {account}        Queries about an account (@user@host or actor url)\n");
 28-    printf("queue {basedir} {uid}                Processes a user queue\n");
 29-    printf("follow {basedir} {uid} {actor}       Follows an actor\n");
 30-    printf("unfollow {basedir} {uid} {actor}     Unfollows an actor\n");
 31-    printf("request {basedir} {uid} {url}        Requests an object\n");
 32-    printf("insert {basedir} {uid} {url}         Requests an object and inserts it into the timeline\n");
 33-    printf("actor {basedir} [{uid}] {url}        Requests an actor\n");
 34-    printf("note {basedir} {uid} {text} [files...] Sends a note with optional attachments\n");
 35-    printf("note_unlisted {basedir} {uid} {text} [files...] Sends an unlisted note with optional attachments\n");
 36-    printf("note_mention {basedir} {uid} {text} [files...] Sends a note only to mentioned accounts\n");
 37-    printf("boost|announce {basedir} {uid} {url} Boosts (announces) a post\n");
 38-    printf("unboost {basedir} {uid} {url}        Unboosts a post\n");
 39-    printf("resetpwd {basedir} {uid}             Resets the password of a user\n");
 40-    printf("ping {basedir} {uid} {actor}         Pings an actor\n");
 41-    printf("webfinger_s {basedir} {uid} {account} Queries about an account (@user@host or actor url)\n");
 42-    printf("pin {basedir} {uid} {msg_url}        Pins a message\n");
 43-    printf("unpin {basedir} {uid} {msg_url}      Unpins a message\n");
 44-    printf("bookmark {basedir} {uid} {msg_url}   Bookmarks a message\n");
 45-    printf("unbookmark {basedir} {uid} {msg_url} Unbookmarks a message\n");
 46-    printf("block {basedir} {instance_url}       Blocks a full instance\n");
 47-    printf("unblock {basedir} {instance_url}     Unblocks a full instance\n");
 48-    printf("limit {basedir} {uid} {actor}        Limits an actor (drops their announces)\n");
 49-    printf("unlimit {basedir} {uid} {actor}      Unlimits an actor\n");
 50-    printf("unmute {basedir} {uid} {actor}       Unmutes a previously muted actor\n");
 51-    printf("verify_links {basedir} {uid}         Verifies a user's links (in the metadata)\n");
 52-    printf("search {basedir} {uid} {regex}       Searches posts by content\n");
 53-    printf("export_csv {basedir} {uid}           Exports data as CSV files\n");
 54-    printf("alias {basedir} {uid} {account}      Sets account (@user@host or actor url) as an alias\n");
 55-    printf("migrate {basedir} {uid}              Migrates to the account defined as the alias\n");
 56-    printf("import_csv {basedir} {uid}           Imports data from CSV files\n");
 57-    printf("import_list {basedir} {uid} {file}   Imports a Mastodon CSV list file\n");
 58-    printf("import_block_list {basedir} {uid} {file} Imports a Mastodon CSV block list file\n");
 59-    printf("lists {basedir} {uid}                Returns the names of the lists created by the user\n");
 60-    printf("list_members {basedir} {uid} {name}  Returns the list of accounts inside a list\n");
 61-    printf("create_list {basedir} {uid} {name}   Creates a new list\n");
 62-    printf("delete_list {basedir} {uid} {name}   Deletes an existing list\n");
 63-    printf("list_add {basedir} {uid} {name} {acct} Adds an account (@user@host or actor url) to a list\n");
 64-    printf("list_del {basedir} {uid} {name} {actor} Deletes an actor URL from a list\n");
 65+
 66+    if (cmd == NULL) {
 67+        printf("Commands:\n");
 68+        printf("\n");
 69+    }
 70+
 71+    const char *cmds =
 72+        "init [{basedir}]                     Initializes the data storage\n"
 73+        "upgrade {basedir}                    Upgrade to a new version\n"
 74+        "adduser {basedir} [{uid}]            Adds a new user\n"
 75+        "deluser {basedir} {uid}              Deletes a user\n"
 76+        "httpd {basedir}                      Starts the HTTPD daemon\n"
 77+        "purge {basedir}                      Purges old data\n"
 78+        "state {basedir}                      Prints server state\n"
 79+        "webfinger {basedir} {account}        Queries about an account (@user@host or actor url)\n"
 80+        "queue {basedir} {uid}                Processes a user queue\n"
 81+        "follow {basedir} {uid} {actor}       Follows an actor\n"
 82+        "unfollow {basedir} {uid} {actor}     Unfollows an actor\n"
 83+        "request {basedir} {uid} {url}        Requests an object\n"
 84+        "insert {basedir} {uid} {url}         Requests an object and inserts it into the timeline\n"
 85+        "actor {basedir} [{uid}] {url}        Requests an actor\n"
 86+        "note {basedir} {uid} {text} [files...] Sends a note with optional attachments\n"
 87+        "note_unlisted {basedir} {uid} {text} [files...] Sends an unlisted note with optional attachments\n"
 88+        "note_mention {basedir} {uid} {text} [files...] Sends a note only to mentioned accounts\n"
 89+        "boost|announce {basedir} {uid} {url} Boosts (announces) a post\n"
 90+        "unboost {basedir} {uid} {url}        Unboosts a post\n"
 91+        "resetpwd {basedir} {uid}             Resets the password of a user\n"
 92+        "ping {basedir} {uid} {actor}         Pings an actor\n"
 93+        "webfinger_s {basedir} {uid} {account} Queries about an account (@user@host or actor url)\n"
 94+        "pin {basedir} {uid} {msg_url}        Pins a message\n"
 95+        "unpin {basedir} {uid} {msg_url}      Unpins a message\n"
 96+        "bookmark {basedir} {uid} {msg_url}   Bookmarks a message\n"
 97+        "unbookmark {basedir} {uid} {msg_url} Unbookmarks a message\n"
 98+        "block {basedir} {instance_url}       Blocks a full instance\n"
 99+        "unblock {basedir} {instance_url}     Unblocks a full instance\n"
100+        "limit {basedir} {uid} {actor}        Limits an actor (drops their announces)\n"
101+        "unlimit {basedir} {uid} {actor}      Unlimits an actor\n"
102+        "unmute {basedir} {uid} {actor}       Unmutes a previously muted actor\n"
103+        "verify_links {basedir} {uid}         Verifies a user's links (in the metadata)\n"
104+        "search {basedir} {uid} {regex}       Searches posts by content\n"
105+        "export_csv {basedir} {uid}           Exports followers, lists, MUTEd and bookmarks to CSV\n"
106+        "export_posts {basedir} {iod}         Exports all posts to outbox.json\n"
107+        "alias {basedir} {uid} {account}      Sets account (@user@host or actor url) as an alias\n"
108+        "migrate {basedir} {uid}              Migrates to the account defined as the alias\n"
109+        "import_csv {basedir} {uid}           Imports data from CSV files\n"
110+        "import_list {basedir} {uid} {file}   Imports a Mastodon CSV list file\n"
111+        "import_block_list {basedir} {uid} {file} Imports a Mastodon CSV block list file\n"
112+        "lists {basedir} {uid}                Returns the names of the lists created by the user\n"
113+        "list_members {basedir} {uid} {name}  Returns the list of accounts inside a list\n"
114+        "list_create {basedir} {uid} {name}   Creates a new list\n"
115+        "list_remove {basedir} {uid} {name}   Removes an existing list\n"
116+        "list_add {basedir} {uid} {name} {acct} Adds an account (@user@host or actor url) to a list\n"
117+        "list_del {basedir} {uid} {name} {actor} Deletes an actor URL from a list\n";
118+
119+    if (cmd == NULL)
120+        printf("%s", cmds);
121+    else {
122+        /* only show help for the entered command */
123+        xs *l = xs_split(cmds, "\n");
124+        const char *v;
125+        int cnt = 0;
126+
127+        xs_list_foreach(l, v) {
128+            if (xs_str_in(v, cmd) != -1) {
129+                printf("%s\n", v);
130+                cnt++;
131+            }
132+        }
133+
134+        if (cnt == 0)
135+            printf("%s", cmds);
136+    }
137 
138     return 1;
139 }
140@@ -94,7 +120,7 @@ int main(int argc, char *argv[])
141     umask(0007);
142 
143     if ((cmd = GET_ARGV()) == NULL)
144-        return usage();
145+        return usage(cmd);
146 
147     if (strcmp(cmd, "init") == 0) { /** **/
148         /* initialize the data storage */
149@@ -106,7 +132,7 @@ int main(int argc, char *argv[])
150 
151     if ((basedir = getenv("SNAC_BASEDIR")) == NULL) {
152         if ((basedir = GET_ARGV()) == NULL)
153-            return usage();
154+            return usage(cmd);
155     }
156 
157     if (strcmp(cmd, "upgrade") == 0) { /** **/
158@@ -143,6 +169,11 @@ int main(int argc, char *argv[])
159         return 0;
160     }
161 
162+    if (strcmp(cmd, "poll_hashtag_rss") == 0) { /** **/
163+        rss_poll_hashtags();
164+        return 0;
165+    }
166+
167     if (strcmp(cmd, "state") == 0) { /** **/
168         xs *shm_name = NULL;
169         srv_state *p_state = srv_state_op(&shm_name, 1);
170@@ -167,7 +198,7 @@ int main(int argc, char *argv[])
171     }
172 
173     if ((user = GET_ARGV()) == NULL)
174-        return usage();
175+        return usage(cmd);
176 
177     if (strcmp(cmd, "block") == 0) { /** **/
178         int ret = instance_block(user);
179@@ -279,6 +310,11 @@ int main(int argc, char *argv[])
180         return 0;
181     }
182 
183+    if (strcmp(cmd, "export_posts") == 0) { /** **/
184+        export_posts(&snac);
185+        return 0;
186+    }
187+
188     if (strcmp(cmd, "import_csv") == 0) { /** **/
189         import_csv(&snac);
190         return 0;
191@@ -300,7 +336,7 @@ int main(int argc, char *argv[])
192     }
193 
194     if ((url = GET_ARGV()) == NULL)
195-        return usage();
196+        return usage(cmd);
197 
198     if (strcmp(cmd, "list_members") == 0) { /** **/
199         xs *lid = list_maint(&snac, url, 4);
200@@ -323,7 +359,7 @@ int main(int argc, char *argv[])
201         return 0;
202     }
203 
204-    if (strcmp(cmd, "create_list") == 0) { /** **/
205+    if (strcmp(cmd, "list_create") == 0) { /** **/
206         xs *lid = list_maint(&snac, url, 4);
207 
208         if (lid == NULL) {
209@@ -336,7 +372,7 @@ int main(int argc, char *argv[])
210         return 0;
211     }
212 
213-    if (strcmp(cmd, "delete_list") == 0) { /** **/
214+    if (strcmp(cmd, "list_remove") == 0) { /** **/
215         xs *lid = list_maint(&snac, url, 4);
216 
217         if (lid != NULL) {
218@@ -744,12 +780,24 @@ int main(int argc, char *argv[])
219         xs *msg = NULL;
220         xs *c_msg = NULL;
221         xs *attl = xs_list_new();
222-        char *fn = NULL;
223+        const char *fn = NULL;
224+        const char *in_reply_to = NULL;
225+        const char **next = NULL;
226 
227         /* iterate possible attachments */
228         while ((fn = GET_ARGV())) {
229             FILE *f;
230 
231+            if (next) {
232+                *next = fn;
233+                next = NULL;
234+            }
235+            else
236+            if (strcmp(fn, "-r") == 0) {
237+                /* next argument is an inReplyTo */
238+                next = &in_reply_to;
239+            }
240+            else
241             if ((f = fopen(fn, "rb")) != NULL) {
242                 /* get the file size and content */
243                 fseek(f, 0, SEEK_END);
244@@ -759,8 +807,10 @@ int main(int argc, char *argv[])
245                 fclose(f);
246 
247                 char *ext = strrchr(fn, '.');
248-                xs *hash  = xs_md5_hex(fn, strlen(fn));
249-                xs *id    = xs_fmt("%s%s", hash, ext);
250+                char rnd[32];
251+                xs_rnd_buf(rnd, sizeof(rnd));
252+                xs *hash  = xs_md5_hex(rnd, sizeof(rnd));
253+                xs *id    = xs_fmt("post-%s%s", hash, ext ? ext : "");
254                 xs *url   = xs_fmt("%s/s/%s", snac.actor, id);
255 
256                 /* store */
257@@ -821,7 +871,7 @@ int main(int argc, char *argv[])
258         if (strcmp(cmd, "note_unlisted") == 0)
259             scope = 2;
260 
261-        msg = msg_note(&snac, content, NULL, NULL, attl, scope, getenv("LANG"), NULL);
262+        msg = msg_note(&snac, content, NULL, in_reply_to, attl, scope, getenv("LANG"), NULL);
263 
264         c_msg = msg_create(&snac, msg);
265 
+128, -19
  1@@ -15,6 +15,7 @@
  2 #include "xs_url.h"
  3 #include "xs_mime.h"
  4 #include "xs_match.h"
  5+#include "xs_unicode.h"
  6 
  7 #include "snac.h"
  8 
  9@@ -381,7 +382,7 @@ int oauth_post_handler(const xs_dict *req, const char *q_path,
 10             }
 11         }
 12 
 13-        /* no code? 
 14+        /* no code?
 15            I'm not sure of the impacts of this right now, but Subway Tooter does not
 16            provide a code so one must be generated */
 17         if (xs_is_null(code)){
 18@@ -1133,9 +1134,14 @@ xs_dict *mastoapi_status(snac *snac, const xs_dict *msg)
 19             bst = xs_dict_set(bst, "content", "");
 20             bst = xs_dict_set(bst, "reblog", st);
 21 
 22+            xs *b_id = xs_toupper_i(xs_dup(xs_dict_get(st, "id")));
 23+            bst = xs_dict_set(bst, "id", b_id);
 24+
 25             xs_free(st);
 26             st = bst;
 27         }
 28+        else
 29+            xs_free(bst);
 30     }
 31 
 32     return st;
 33@@ -1338,9 +1344,9 @@ xs_list *mastoapi_timeline(snac *user, const xs_dict *args, const char *index_fn
 34     if ((f = fopen(index_fn, "r")) == NULL)
 35         return out;
 36 
 37-    const char *max_id   = xs_dict_get(args, "max_id");
 38-    const char *since_id = xs_dict_get(args, "since_id");
 39-    const char *min_id   = xs_dict_get(args, "min_id"); /* unsupported old-to-new navigation */
 40+    const char *o_max_id   = xs_dict_get(args, "max_id");
 41+    const char *o_since_id = xs_dict_get(args, "since_id");
 42+    const char *o_min_id   = xs_dict_get(args, "min_id"); /* unsupported old-to-new navigation */
 43     const char *limit_s  = xs_dict_get(args, "limit");
 44     int (*iterator)(FILE *, char *);
 45     int initial_status = 0;
 46@@ -1348,6 +1354,10 @@ xs_list *mastoapi_timeline(snac *user, const xs_dict *args, const char *index_fn
 47     int limit = 0;
 48     int cnt   = 0;
 49 
 50+    xs *max_id   = o_max_id ? xs_tolower_i(xs_dup(o_max_id)) : NULL;
 51+    xs *since_id = o_since_id ? xs_tolower_i(xs_dup(o_since_id)) : NULL;
 52+    xs *min_id   = o_min_id ? xs_tolower_i(xs_dup(o_min_id)) : NULL;
 53+
 54     if (!xs_is_null(limit_s))
 55         limit = atoi(limit_s);
 56 
 57@@ -1371,7 +1381,7 @@ xs_list *mastoapi_timeline(snac *user, const xs_dict *args, const char *index_fn
 58             /* only return entries older that max_id */
 59             if (max_id) {
 60                 if (strcmp(md5, MID_TO_MD5(max_id)) == 0) {
 61-                    max_id = NULL;
 62+                    max_id = xs_free(max_id);
 63                     if (ascending)
 64                         break;
 65                 }
 66@@ -1384,7 +1394,7 @@ xs_list *mastoapi_timeline(snac *user, const xs_dict *args, const char *index_fn
 67                 if (strcmp(md5, MID_TO_MD5(since_id)) == 0) {
 68                     if (!ascending)
 69                         break;
 70-                    since_id = NULL;
 71+                    since_id = xs_free(since_id);
 72                 }
 73                 if (ascending)
 74                     continue;
 75@@ -1637,7 +1647,7 @@ int mastoapi_get_handler(const xs_dict *req, const char *q_path,
 76                 const char *aq = xs_dict_get(args, "q");
 77 
 78                 if (!xs_is_null(aq)) {
 79-                    xs *q    = xs_tolower_i(xs_dup(aq));
 80+                    xs *q    = xs_utf8_to_lower(aq);
 81                     out      = xs_list_new();
 82                     xs *wing = following_list(&snac1);
 83                     xs *wers = follower_list(&snac1);
 84@@ -1780,7 +1790,7 @@ int mastoapi_get_handler(const xs_dict *req, const char *q_path,
 85                     }
 86                     else
 87                     if (strcmp(opt, "statuses") == 0) {
 88-                        /* we don't serve statuses of others; return the empty list */ 
 89+                        /* we don't serve statuses of others; return the empty list */
 90                         out = xs_list_new();
 91                     }
 92                     else
 93@@ -1999,7 +2009,7 @@ int mastoapi_get_handler(const xs_dict *req, const char *q_path,
 94     }
 95     else
 96     if (strcmp(cmd, "/v2/filters") == 0) { /** **/
 97-        /* snac will never have filters 
 98+        /* snac will never have filters
 99          * but still, without a v2 endpoint a short delay is introduced
100          * in some apps */
101         *body  = xs_dup("[]");
102@@ -2331,19 +2341,36 @@ int mastoapi_get_handler(const xs_dict *req, const char *q_path,
103         status = HTTP_STATUS_OK;
104     }
105     else
106+    if (strcmp(cmd, "/v1/instance/extended_description") == 0) { /** **/
107+        xs *d = xs_dict_new();
108+        xs *greeting = xs_fmt("%s/greeting.html", srv_basedir);
109+        time_t t = mtime(greeting);
110+        xs *updated_at = xs_str_iso_date(t);
111+        xs *content = xs_replace(snac_blurb, "%host%", xs_dict_get(srv_config, "host"));
112+
113+        d = xs_dict_set(d, "updated_at", updated_at);
114+        d = xs_dict_set(d, "content", content);
115+
116+        *body  = xs_json_dumps(d, 4);
117+        *ctype = "application/json";
118+        status = HTTP_STATUS_OK;
119+    }
120+    else
121     if (xs_startswith(cmd, "/v1/statuses/")) { /** **/
122         /* information about a status */
123         if (logged_in) {
124             xs *l = xs_split(cmd, "/");
125-            const char *id = xs_list_get(l, 3);
126+            const char *oid = xs_list_get(l, 3);
127             const char *op = xs_list_get(l, 4);
128 
129-            if (!xs_is_null(id)) {
130+            if (!xs_is_null(oid)) {
131                 xs *msg = NULL;
132                 xs *out = NULL;
133 
134                 /* skip the 'fake' part of the id */
135-                id = MID_TO_MD5(id);
136+                oid = MID_TO_MD5(oid);
137+
138+                xs *id = xs_tolower_i(xs_dup(oid));
139 
140                 if (valid_status(object_get_by_md5(id, &msg))) {
141                     if (op == NULL) {
142@@ -2459,7 +2486,7 @@ int mastoapi_get_handler(const xs_dict *req, const char *q_path,
143         if (logged_in) {
144             const xs_list *timeline = xs_dict_get(args, "timeline[]");
145             xs_str *json = NULL;
146-            if (!xs_is_null(timeline)) 
147+            if (!xs_is_null(timeline))
148                 json = xs_json_dumps(markers_get(&snac1, timeline), 4);
149 
150             if (!xs_is_null(json))
151@@ -2475,6 +2502,40 @@ int mastoapi_get_handler(const xs_dict *req, const char *q_path,
152     }
153     else
154     if (strcmp(cmd, "/v1/followed_tags") == 0) { /** **/
155+        if (logged_in) {
156+            xs *r = xs_list_new();
157+            const xs_list *followed_hashtags = xs_dict_get_def(snac1.config,
158+                        "followed_hashtags", xs_stock(XSTYPE_LIST));
159+            const char *hashtag;
160+
161+            xs_list_foreach(followed_hashtags, hashtag) {
162+                if (*hashtag == '#') {
163+                    xs *d = xs_dict_new();
164+                    xs *s = xs_fmt("%s?t=%s", srv_baseurl, hashtag + 1);
165+
166+                    d = xs_dict_set(d, "name", hashtag + 1);
167+                    d = xs_dict_set(d, "url", s);
168+                    d = xs_dict_set(d, "history", xs_stock(XSTYPE_LIST));
169+
170+                    r = xs_list_append(r, d);
171+                }
172+            }
173+
174+            *body  = xs_json_dumps(r, 4);
175+            *ctype = "application/json";
176+            status = HTTP_STATUS_OK;
177+        }
178+        else
179+            status = HTTP_STATUS_UNAUTHORIZED;
180+    }
181+    else
182+    if (strcmp(cmd, "/v1/blocks") == 0) { /** **/
183+        *body  = xs_dup("[]");
184+        *ctype = "application/json";
185+        status = HTTP_STATUS_OK;
186+    }
187+    else
188+    if (strcmp(cmd, "/v1/mutes") == 0) { /** **/
189         *body  = xs_dup("[]");
190         *ctype = "application/json";
191         status = HTTP_STATUS_OK;
192@@ -2507,9 +2568,7 @@ int mastoapi_get_handler(const xs_dict *req, const char *q_path,
193                 /* reply something only for offset 0; otherwise,
194                    apps like Tusky keep asking again and again */
195                 if (xs_startswith(q, "https://")) {
196-                    xs *md5 = xs_md5_hex(q, strlen(q));
197-
198-                    if (!timeline_here(&snac1, md5)) {
199+                    if (!timeline_here(&snac1, q)) {
200                         xs *object = NULL;
201                         int status;
202 
203@@ -2979,8 +3038,10 @@ int mastoapi_post_handler(const xs_dict *req, const char *q_path,
204 
205                 if (*fn != '\0') {
206                     char *ext = strrchr(fn, '.');
207-                    xs *hash  = xs_md5_hex(fn, strlen(fn));
208-                    xs *id    = xs_fmt("%s%s", hash, ext);
209+                    char rnd[32];
210+                    xs_rnd_buf(rnd, sizeof(rnd));
211+                    xs *hash  = xs_md5_hex(rnd, sizeof(rnd));
212+                    xs *id    = xs_fmt("post-%s%s", hash, ext ? ext : "");
213                     xs *url   = xs_fmt("%s/s/%s", snac.actor, id);
214                     int fo    = xs_number_get(xs_list_get(file, 1));
215                     int fs    = xs_number_get(xs_list_get(file, 2));
216@@ -3227,7 +3288,7 @@ int mastoapi_post_handler(const xs_dict *req, const char *q_path,
217                 if (!xs_is_null(home))
218                     home_marker = xs_dict_get(home, "last_read_id");
219             }
220-            
221+
222             const xs_str *notify_marker = xs_dict_get(args, "notifications[last_read_id]");
223             if (xs_is_null(notify_marker)) {
224                 const xs_dict *notify = xs_dict_get(args, "notifications");
225@@ -3295,6 +3356,54 @@ int mastoapi_post_handler(const xs_dict *req, const char *q_path,
226             }
227         }
228     }
229+    else
230+    if (xs_startswith(cmd, "/v1/tags/")) { /** **/
231+        if (logged_in) {
232+            xs *l = xs_split(cmd, "/");
233+            const char *i_tag = xs_list_get(l, 3);
234+            const char *cmd = xs_list_get(l, 4);
235+
236+            status = HTTP_STATUS_UNPROCESSABLE_CONTENT;
237+
238+            if (xs_is_string(i_tag) && xs_is_string(cmd)) {
239+                int ok = 0;
240+
241+                xs *tag = xs_fmt("#%s", i_tag);
242+                xs *followed_hashtags = xs_dup(xs_dict_get_def(snac.config,
243+                                "followed_hashtags", xs_stock(XSTYPE_LIST)));
244+
245+                if (strcmp(cmd, "follow") == 0) {
246+                    followed_hashtags = xs_list_append(followed_hashtags, tag);
247+                    ok = 1;
248+                }
249+                else
250+                if (strcmp(cmd, "unfollow") == 0) {
251+                    int off = xs_list_in(followed_hashtags, tag);
252+
253+                    if (off != -1)
254+                        followed_hashtags = xs_list_del(followed_hashtags, off);
255+
256+                    ok = 1;
257+                }
258+
259+                if (ok) {
260+                    /* update */
261+                    xs_dict_set(snac.config, "followed_hashtags", followed_hashtags);
262+                    user_persist(&snac, 0);
263+
264+                    xs *d = xs_dict_new();
265+                    xs *s = xs_fmt("%s?t=%s", srv_baseurl, i_tag);
266+                    d = xs_dict_set(d, "name", i_tag);
267+                    d = xs_dict_set(d, "url", s);
268+                    d = xs_dict_set(d, "history", xs_stock(XSTYPE_LIST));
269+
270+                    *body = xs_json_dumps(d, 4);
271+                    *ctype = "application/json";
272+                    status = HTTP_STATUS_OK;
273+                }
274+            }
275+        }
276+    }
277     else
278         status = HTTP_STATUS_UNPROCESSABLE_CONTENT;
279 
+4, -4
 1@@ -126,7 +126,7 @@ msgstr "люди"
 2 
 3 #: html.c:936
 4 msgid "instance"
 5-msgstr "экземпляр"
 6+msgstr "сервер"
 7 
 8 #: html.c:945
 9 msgid ""
10@@ -679,12 +679,12 @@ msgstr "Ничего не найдено для '%s'"
11 
12 #: html.c:3929
13 msgid "Showing instance timeline"
14-msgstr "Показываем ленту инстанции"
15+msgstr "Показываем ленту сервера"
16 
17 #: html.c:4012
18 #, c-format
19 msgid "Showing timeline for list '%s'"
20-msgstr "Показываем ленты инстанции для списка '%s'"
21+msgstr "Показываем ленту для списка '%s'"
22 
23 #: httpd.c:250
24 #, c-format
25@@ -693,7 +693,7 @@ msgstr "Результаты поиска для тега #%s"
26 
27 #: httpd.c:259
28 msgid "Recent posts by users in this instance"
29-msgstr "Последние сообщения на этой инстанции"
30+msgstr "Последние сообщения на этом сервере"
31 
32 #: html.c:1603
33 msgid "Blocked hashtags..."
+768, -0
  1@@ -0,0 +1,768 @@
  2+# snac message translation file
  3+#
  4+msgid ""
  5+msgstr ""
  6+"Project-Id-Version: snac\n"
  7+"Last-Translator: grunfink\n"
  8+"Language: uk\n"
  9+"Content-Type: text/plain; charset=UTF-8\n"
 10+"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && "
 11+"n%10<=4 && (n%100<12 || n%100>14) ? 1 : 2);\n"
 12+"POT-Creation-Date: \n"
 13+"PO-Revision-Date: \n"
 14+"Language-Team: \n"
 15+"MIME-Version: 1.0\n"
 16+"Content-Transfer-Encoding: 8bit\n"
 17+"X-Generator: Poedit 3.0\n"
 18+
 19+#: html.c:384
 20+msgid "Sensitive content: "
 21+msgstr "Делікатний вміст: "
 22+
 23+#: html.c:392
 24+msgid "Sensitive content description"
 25+msgstr "Опис делікатного вмісту"
 26+
 27+#: html.c:405
 28+msgid "Only for mentioned people: "
 29+msgstr "Тільки для зазначених осіб: "
 30+
 31+#: html.c:428
 32+msgid "Reply to (URL): "
 33+msgstr "Відповісти до (URL): "
 34+
 35+#: html.c:437
 36+msgid "Don't send, but store as a draft"
 37+msgstr "Не надсилати, але зберігти як чернетку"
 38+
 39+#: html.c:438
 40+msgid "Draft:"
 41+msgstr "Чернетка:"
 42+
 43+#: html.c:494
 44+msgid "Attachments..."
 45+msgstr "Вкладення..."
 46+
 47+#: html.c:517
 48+msgid "File:"
 49+msgstr "Файл:"
 50+
 51+#: html.c:521
 52+msgid "Clear this field to delete the attachment"
 53+msgstr "Очистіть це поле, щоб видалити вкладення"
 54+
 55+#: html.c:530 html.c:555
 56+msgid "Attachment description"
 57+msgstr "Опис вкладення"
 58+
 59+#: html.c:566
 60+msgid "Poll..."
 61+msgstr "Опитування..."
 62+
 63+#: html.c:568
 64+msgid "Poll options (one per line, up to 8):"
 65+msgstr "Варіанти відповідей (по одному в рядку, до 8):"
 66+
 67+#: html.c:580
 68+msgid "One choice"
 69+msgstr "Єдиний вибір"
 70+
 71+#: html.c:583
 72+msgid "Multiple choices"
 73+msgstr "Декілька варіантів вибору"
 74+
 75+#: html.c:589
 76+msgid "End in 5 minutes"
 77+msgstr "Закінчення через 5 хвилин"
 78+
 79+#: html.c:593
 80+msgid "End in 1 hour"
 81+msgstr "Закінчення через 1 годину"
 82+
 83+#: html.c:596
 84+msgid "End in 1 day"
 85+msgstr "Закінчення через 1 день"
 86+
 87+#: html.c:604
 88+msgid "Post"
 89+msgstr "Надіслати"
 90+
 91+#: html.c:701 html.c:708
 92+msgid "Site description"
 93+msgstr "Опис сайту"
 94+
 95+#: html.c:719
 96+msgid "Admin email"
 97+msgstr "Пошта админа"
 98+
 99+#: html.c:732
100+msgid "Admin account"
101+msgstr "Обліковий запис адміна"
102+
103+#: html.c:800 html.c:1136
104+#, c-format
105+msgid "%d following, %d followers"
106+msgstr "%d підписок, %d підписників"
107+
108+#: html.c:890
109+msgid "RSS"
110+msgstr "RSS"
111+
112+#: html.c:895 html.c:923
113+msgid "private"
114+msgstr "особисте"
115+
116+#: html.c:919
117+msgid "public"
118+msgstr "публічне"
119+
120+#: html.c:927
121+msgid "notifications"
122+msgstr "повідомлення"
123+
124+#: html.c:932
125+msgid "people"
126+msgstr "люди"
127+
128+#: html.c:936
129+msgid "instance"
130+msgstr "сервер"
131+
132+#: html.c:945
133+msgid ""
134+"Search posts by URL or content (regular expression), @user@host accounts, or "
135+"#tag"
136+msgstr ""
137+"Шукати дописи за URL або вмістом (регулярний вираз), акаунтами @user@host "
138+"або #тегом"
139+
140+#: html.c:946
141+msgid "Content search"
142+msgstr "Пошук за вмістом"
143+
144+#: html.c:1068
145+msgid "verified link"
146+msgstr "перевірене посилання"
147+
148+#: html.c:1125 html.c:2540 html.c:2553 html.c:2562
149+msgid "Location: "
150+msgstr "Місце знаходження: "
151+
152+#: html.c:1161
153+msgid "New Post..."
154+msgstr "Новий допис..."
155+
156+#: html.c:1163
157+msgid "What's on your mind?"
158+msgstr "Що у вас на думці?"
159+
160+#: html.c:1172
161+msgid "Operations..."
162+msgstr "Дії..."
163+
164+#: html.c:1187 html.c:1788 html.c:3193 html.c:4578
165+msgid "Follow"
166+msgstr "Підписатися"
167+
168+#: html.c:1189
169+msgid "(by URL or user@host)"
170+msgstr "(за URL або user@host)"
171+
172+#: html.c:1204 html.c:1764 html.c:4527
173+msgid "Boost"
174+msgstr "Просувати"
175+
176+#: html.c:1206 html.c:1223
177+msgid "(by URL)"
178+msgstr "(за URL)"
179+
180+#: html.c:1221 html.c:1743 html.c:4518
181+msgid "Like"
182+msgstr "Вподобайка"
183+
184+#: html.c:1347
185+msgid "User Settings..."
186+msgstr "Налаштування користувача..."
187+
188+#: html.c:1356
189+msgid "Display name:"
190+msgstr "Видиме ім'я:"
191+
192+#: html.c:1362
193+msgid "Your name"
194+msgstr "Ваше ім'я"
195+
196+#: html.c:1364
197+msgid "Avatar: "
198+msgstr "Аватар: "
199+
200+#: html.c:1372
201+msgid "Delete current avatar"
202+msgstr "Видалити поточний аватар"
203+
204+#: html.c:1374
205+msgid "Header image (banner): "
206+msgstr "Зображення заголовка (банер): "
207+
208+#: html.c:1382
209+msgid "Delete current header image"
210+msgstr "Видалити поточне зображення заголовка"
211+
212+#: html.c:1384
213+msgid "Bio:"
214+msgstr "Про себе:"
215+
216+#: html.c:1390
217+msgid "Write about yourself here..."
218+msgstr "Напишіть про себе тут..."
219+
220+#: html.c:1399
221+msgid "Always show sensitive content"
222+msgstr "Завжди показувати делікатний вміст"
223+
224+#: html.c:1401
225+msgid "Email address for notifications:"
226+msgstr "Поштова адреса для повідомлень:"
227+
228+#: html.c:1409
229+msgid "Telegram notifications (bot key and chat id):"
230+msgstr "Повідомлення в Telegram (ключ бота та id чату):"
231+
232+#: html.c:1423
233+msgid "ntfy notifications (ntfy server and token):"
234+msgstr "Повідомлення в ntfy (сервер та токен ntfy):"
235+
236+#: html.c:1437
237+msgid "Maximum days to keep posts (0: server settings):"
238+msgstr "Максимальний час для зберігання дописів (0: налаштування сервера):"
239+
240+#: html.c:1451
241+msgid "Drop direct messages from people you don't follow"
242+msgstr "Відхиляти особисті повідомлення від незнайомців"
243+
244+#: html.c:1460
245+msgid "This account is a bot"
246+msgstr "Цей акаунт є ботом"
247+
248+#: html.c:1469
249+msgid "Auto-boost all mentions to this account"
250+msgstr "Автоматично просувати всі згадки цього акаунта"
251+
252+#: html.c:1478
253+msgid "This account is private (posts are not shown through the web)"
254+msgstr "Це закритий акаунт (дописи не показуються в мережі)"
255+
256+#: html.c:1488
257+msgid "Collapse top threads by default"
258+msgstr "Згорнути потоки за замовчуванням"
259+
260+#: html.c:1497
261+msgid "Follow requests must be approved"
262+msgstr "Запити на підписку мають бути схвалені"
263+
264+#: html.c:1506
265+msgid "Publish follower and following metrics"
266+msgstr "Публікувати метрики підписок та підписників"
267+
268+#: html.c:1508
269+msgid "Current location:"
270+msgstr "Поточне місцезнаходження:"
271+
272+#: html.c:1522
273+msgid "Profile metadata (key=value pairs in each line):"
274+msgstr "Метадані профілю (пари ключ=значення в кожному рядку):"
275+
276+#: html.c:1533
277+msgid "Web interface language:"
278+msgstr "Мова інтерфейсу:"
279+
280+#: html.c:1543
281+msgid "New password:"
282+msgstr "Новий пароль:"
283+
284+#: html.c:1550
285+msgid "Repeat new password:"
286+msgstr "Повторити новий пароль:"
287+
288+#: html.c:1560
289+msgid "Update user info"
290+msgstr "Оновлення даних користувача"
291+
292+#: html.c:1571
293+msgid "Followed hashtags..."
294+msgstr "Підписані хештеги..."
295+
296+#: html.c:1573 html.c:1605
297+msgid "One hashtag per line"
298+msgstr "Один хештег на рядок"
299+
300+#: html.c:1594 html.c:1626
301+msgid "Update hashtags"
302+msgstr "Оновити хештеги"
303+
304+#: html.c:1743
305+msgid "Say you like this post"
306+msgstr "Позначте допис як вподобаний"
307+
308+#: html.c:1748 html.c:4536
309+msgid "Unlike"
310+msgstr "Більше не подобається"
311+
312+#: html.c:1748
313+msgid "Nah don't like it that much"
314+msgstr "Мені це не дуже подобається"
315+
316+#: html.c:1754 html.c:4673
317+msgid "Unpin"
318+msgstr "Відкріпити"
319+
320+#: html.c:1754
321+msgid "Unpin this post from your timeline"
322+msgstr "Відкріпити цей допис зі власної стрічки"
323+
324+#: html.c:1757 html.c:4668
325+msgid "Pin"
326+msgstr "Закріпити"
327+
328+#: html.c:1757
329+msgid "Pin this post to the top of your timeline"
330+msgstr "Закріпити цей допис до власної стрічки"
331+
332+#: html.c:1764
333+msgid "Announce this post to your followers"
334+msgstr "Поділитися цим дописом зі своїми підписниками"
335+
336+#: html.c:1769 html.c:4544
337+msgid "Unboost"
338+msgstr "Скасувати просування"
339+
340+#: html.c:1769
341+msgid "I regret I boosted this"
342+msgstr "Я шкодую, що просував це"
343+
344+#: html.c:1775 html.c:4683
345+msgid "Unbookmark"
346+msgstr "Видалити з закладок"
347+
348+#: html.c:1775
349+msgid "Delete this post from your bookmarks"
350+msgstr "Видалити цей допис з закладок"
351+
352+#: html.c:1778 html.c:4678
353+msgid "Bookmark"
354+msgstr "Додати в закладки"
355+
356+#: html.c:1778
357+msgid "Add this post to your bookmarks"
358+msgstr "Додайте цей допис в закладки"
359+
360+#: html.c:1784 html.c:3179 html.c:3367 html.c:4591
361+msgid "Unfollow"
362+msgstr "Відписатися"
363+
364+#: html.c:1784 html.c:3180
365+msgid "Stop following this user's activity"
366+msgstr "Відписатися від цього користувача"
367+
368+#: html.c:1788 html.c:3194
369+msgid "Start following this user's activity"
370+msgstr "Підписатися до цього користувача"
371+
372+#: html.c:1794 html.c:4621
373+msgid "Unfollow Group"
374+msgstr "Відписатися від групи"
375+
376+#: html.c:1795
377+msgid "Stop following this group or channel"
378+msgstr "Відписатися від групи чи канала"
379+
380+#: html.c:1799 html.c:4608
381+msgid "Follow Group"
382+msgstr "Підписатися на групу"
383+
384+#: html.c:1800
385+msgid "Start following this group or channel"
386+msgstr "Підписатися на групу чи канал"
387+
388+#: html.c:1805 html.c:3216 html.c:4552
389+msgid "MUTE"
390+msgstr "Заглушити"
391+
392+#: html.c:1806
393+msgid "Block any activity from this user forever"
394+msgstr "Назавжди заблокувати активність цього користувача"
395+
396+#: html.c:1811 html.c:3198 html.c:4638
397+msgid "Delete"
398+msgstr "Видалити"
399+
400+#: html.c:1811
401+msgid "Delete this post"
402+msgstr "Видалити цей допис"
403+
404+#: html.c:1814 html.c:4560
405+msgid "Hide"
406+msgstr "Приховати"
407+
408+#: html.c:1814
409+msgid "Hide this post and its children"
410+msgstr "Приховати цей допис разом з обговоренням"
411+
412+#: html.c:1845
413+msgid "Edit..."
414+msgstr "Редагувати..."
415+
416+#: html.c:1865
417+msgid "Reply..."
418+msgstr "Відповісти..."
419+
420+#: html.c:1916
421+msgid "Truncated (too deep)"
422+msgstr "Обрізано (занадто багато)"
423+
424+#: html.c:1925
425+msgid "follows you"
426+msgstr "підписан на вас"
427+
428+#: html.c:1988
429+msgid "Pinned"
430+msgstr "Закріплено"
431+
432+#: html.c:1996
433+msgid "Bookmarked"
434+msgstr "Додано до закладок"
435+
436+#: html.c:2004
437+msgid "Poll"
438+msgstr "Опитування"
439+
440+#: html.c:2011
441+msgid "Voted"
442+msgstr "Проголосовано"
443+
444+#: html.c:2020
445+msgid "Event"
446+msgstr "Подія"
447+
448+#: html.c:2052 html.c:2081
449+msgid "boosted"
450+msgstr "просунуто"
451+
452+#: html.c:2097
453+msgid "in reply to"
454+msgstr "у відповідь на"
455+
456+#: html.c:2148
457+msgid " [SENSITIVE CONTENT]"
458+msgstr " [ДЕЛІКАТНИЙ ВМІСТ]"
459+
460+#: html.c:2325
461+msgid "Vote"
462+msgstr "Голос"
463+
464+#: html.c:2335
465+msgid "Closed"
466+msgstr "Завершено"
467+
468+#: html.c:2360
469+msgid "Closes in"
470+msgstr "Завершується через"
471+
472+#: html.c:2441
473+msgid "Video"
474+msgstr "Відео"
475+
476+#: html.c:2456
477+msgid "Audio"
478+msgstr "Аудіо"
479+
480+#: html.c:2484
481+msgid "Attachment"
482+msgstr "Вкладення"
483+
484+#: html.c:2498
485+msgid "Alt..."
486+msgstr "Опис..."
487+
488+#: html.c:2511
489+msgid "Source channel or community"
490+msgstr "Вихідний канал або спільнота"
491+
492+#: html.c:2605
493+msgid "Time: "
494+msgstr "Час: "
495+
496+#: html.c:2686
497+msgid "Older..."
498+msgstr "Раніше..."
499+
500+#: html.c:2788
501+msgid "about this site"
502+msgstr "про цей сайт"
503+
504+#: html.c:2790
505+msgid "powered by "
506+msgstr "на базі "
507+
508+#: html.c:2855
509+msgid "Dismiss"
510+msgstr "Відхилити"
511+
512+#: html.c:2872
513+#, c-format
514+msgid "Timeline for list '%s'"
515+msgstr "Стрічки для списку '%s'"
516+
517+#: html.c:2891 html.c:3944
518+msgid "Pinned posts"
519+msgstr "Закріплені дописи"
520+
521+#: html.c:2903 html.c:3959
522+msgid "Bookmarked posts"
523+msgstr "Дописи у закладках"
524+
525+#: html.c:2915 html.c:3974
526+msgid "Post drafts"
527+msgstr "Чернетки дописів"
528+
529+#: html.c:2986
530+msgid "No more unseen posts"
531+msgstr "Усе переглянуто"
532+
533+#: html.c:2990 html.c:3090
534+msgid "Back to top"
535+msgstr "Повернутися до початку"
536+
537+#: html.c:3043
538+msgid "History"
539+msgstr "Історія"
540+
541+#: html.c:3095 html.c:3515
542+msgid "More..."
543+msgstr "Більше..."
544+
545+#: html.c:3184 html.c:4574
546+msgid "Unlimit"
547+msgstr "Без обмеження"
548+
549+#: html.c:3185
550+msgid "Allow announces (boosts) from this user"
551+msgstr "Дозволити просування від цього користувача"
552+
553+#: html.c:3188 html.c:4570
554+msgid "Limit"
555+msgstr "Обмежити"
556+
557+#: html.c:3189
558+msgid "Block announces (boosts) from this user"
559+msgstr "Заборонити просування від цього користувача"
560+
561+#: html.c:3198
562+msgid "Delete this user"
563+msgstr "Видалити користувача"
564+
565+#: html.c:3203 html.c:4688
566+msgid "Approve"
567+msgstr "Підтвердити"
568+
569+#: html.c:3204
570+msgid "Approve this follow request"
571+msgstr "Підтвердити цей запит на підписку"
572+
573+#: html.c:3207 html.c:4712
574+msgid "Discard"
575+msgstr "Відхилити"
576+
577+#: html.c:3207
578+msgid "Discard this follow request"
579+msgstr "Відхилити цей запит на підписку"
580+
581+#: html.c:3212 html.c:4556
582+msgid "Unmute"
583+msgstr "Скасувати глушіння"
584+
585+#: html.c:3213
586+msgid "Stop blocking activities from this user"
587+msgstr "Припинити глушіння дій цього користувача"
588+
589+#: html.c:3217
590+msgid "Block any activity from this user"
591+msgstr "Заглушити всі дії цього користувача"
592+
593+#: html.c:3225
594+msgid "Direct Message..."
595+msgstr "Особисте повідомлення..."
596+
597+#: html.c:3260
598+msgid "Pending follow confirmations"
599+msgstr "Запити на підписку очікують на розгляд"
600+
601+#: html.c:3264
602+msgid "People you follow"
603+msgstr "Ваші підписки"
604+
605+#: html.c:3265
606+msgid "People that follow you"
607+msgstr "Ваші підписники"
608+
609+#: html.c:3304
610+msgid "Clear all"
611+msgstr "Очистити все"
612+
613+#: html.c:3361
614+msgid "Mention"
615+msgstr "Згадка"
616+
617+#: html.c:3364
618+msgid "Finished poll"
619+msgstr "Завершене опитування"
620+
621+#: html.c:3379
622+msgid "Follow Request"
623+msgstr "Запит на підписку"
624+
625+#: html.c:3462
626+msgid "Context"
627+msgstr "Контекст"
628+
629+#: html.c:3473
630+msgid "New"
631+msgstr "Нове"
632+
633+#: html.c:3488
634+msgid "Already seen"
635+msgstr "Вже переглянуто"
636+
637+#: html.c:3503
638+msgid "None"
639+msgstr "Нема"
640+
641+#: html.c:3769
642+#, c-format
643+msgid "Search results for account %s"
644+msgstr "Результати пошуку для облікового запису %s"
645+
646+#: html.c:3776
647+#, c-format
648+msgid "Account %s not found"
649+msgstr "Обліковий запис %s не знайдений"
650+
651+#: html.c:3807
652+#, c-format
653+msgid "Search results for tag %s"
654+msgstr "Результати пошуку тега %s"
655+
656+#: html.c:3807
657+#, c-format
658+msgid "Nothing found for tag %s"
659+msgstr "Нічого не знайдено за тегом %s"
660+
661+#: html.c:3823
662+#, c-format
663+msgid "Search results for '%s' (may be more)"
664+msgstr "Результати пошуку для '%s' (можливо є більше)"
665+
666+#: html.c:3826
667+#, c-format
668+msgid "Search results for '%s'"
669+msgstr "Результати пошуку для '%s'"
670+
671+#: html.c:3829
672+#, c-format
673+msgid "No more matches for '%s'"
674+msgstr "Немає більше збігів для '%s'"
675+
676+#: html.c:3831
677+#, c-format
678+msgid "Nothing found for '%s'"
679+msgstr "Нічого не знайдено для '%s'"
680+
681+#: html.c:3929
682+msgid "Showing instance timeline"
683+msgstr "Показуємо стрічку сервера"
684+
685+#: html.c:4012
686+#, c-format
687+msgid "Showing timeline for list '%s'"
688+msgstr "Показуємо стрічку для списку '%s'"
689+
690+#: httpd.c:250
691+#, c-format
692+msgid "Search results for tag #%s"
693+msgstr "Результати пошуку для тега #%s"
694+
695+#: httpd.c:259
696+msgid "Recent posts by users in this instance"
697+msgstr "Останні повідомлення на цьому сервері"
698+
699+#: html.c:1603
700+msgid "Blocked hashtags..."
701+msgstr "Заблоковані теги..."
702+
703+#: html.c:432
704+msgid "Optional URL to reply to"
705+msgstr "Необов'язковий URL для відповіді"
706+
707+#: html.c:575
708+msgid ""
709+"Option 1...\n"
710+"Option 2...\n"
711+"Option 3...\n"
712+"..."
713+msgstr ""
714+"Варіант 1...\n"
715+"Варіант 2...\n"
716+"Варіант 3...\n"
717+"..."
718+
719+#: html.c:1415
720+msgid "Bot API key"
721+msgstr "Ключ API для бота"
722+
723+#: html.c:1421
724+msgid "Chat id"
725+msgstr "Id чату"
726+
727+#: html.c:1429
728+msgid "ntfy server - full URL (example: https://ntfy.sh/YourTopic)"
729+msgstr "повна URL сервера ntfy (наприклад https://ntfy.sh/YourTopic)"
730+
731+#: html.c:1435
732+msgid "ntfy token - if needed"
733+msgstr "токен ntfy - якщо потрібно"
734+
735+#: html.c:2892
736+msgid "pinned"
737+msgstr "закріплено"
738+
739+#: html.c:2904
740+msgid "bookmarks"
741+msgstr "закладки"
742+
743+#: html.c:2916
744+msgid "drafts"
745+msgstr "чернетки"
746+
747+#: html.c:464
748+msgid "Scheduled post..."
749+msgstr "Запланувати..."
750+
751+msgid "Post date and time:"
752+msgstr "Час допису:"
753+
754+#: html.c:2927 html.c:3989
755+msgid "Scheduled posts"
756+msgstr "Заплановані дописи"
757+
758+#: html.c:2928
759+msgid "scheduled posts"
760+msgstr "заплановані дописи"
761+
762+#: html.c:458
763+#, c-format
764+msgid "Post date and time (timezone: %s):"
765+msgstr "Дата та час допису (часовий пояс: %s):"
766+
767+#: html.c:1538
768+msgid "Time zone:"
769+msgstr "Часовий пояс:"
A rss.c
+274, -0
  1@@ -0,0 +1,274 @@
  2+/* snac - A simple, minimalistic ActivityPub instance */
  3+/* copyright (c) 2025 grunfink et al. / MIT license */
  4+
  5+#include "xs.h"
  6+#include "xs_html.h"
  7+#include "xs_regex.h"
  8+#include "xs_time.h"
  9+#include "xs_match.h"
 10+#include "xs_curl.h"
 11+#include "xs_openssl.h"
 12+#include "xs_json.h"
 13+
 14+#include "snac.h"
 15+
 16+xs_str *rss_from_timeline(snac *user, const xs_list *timeline,
 17+                        const char *title, const char *link, const char *desc)
 18+/* converts a timeline to rss */
 19+{
 20+    xs_html *rss = xs_html_tag("rss",
 21+        xs_html_attr("xmlns:content", "http:/" "/purl.org/rss/1.0/modules/content/"),
 22+        xs_html_attr("version",       "2.0"),
 23+        xs_html_attr("xmlns:atom",    "http:/" "/www.w3.org/2005/Atom"));
 24+
 25+    xs_html *channel = xs_html_tag("channel",
 26+        xs_html_tag("title",
 27+            xs_html_text(title)),
 28+        xs_html_tag("language",
 29+            xs_html_text("en")),
 30+        xs_html_tag("link",
 31+            xs_html_text(link)),
 32+        xs_html_sctag("atom:link",
 33+            xs_html_attr("href", link),
 34+            xs_html_attr("rel", "self"),
 35+            xs_html_attr("type", "application/rss+xml")),
 36+        xs_html_tag("generator",
 37+            xs_html_text(USER_AGENT)),
 38+        xs_html_tag("description",
 39+            xs_html_text(desc)));
 40+
 41+    xs_html_add(rss, channel);
 42+
 43+    int cnt = 0;
 44+    const char *v;
 45+
 46+    xs_list_foreach(timeline, v) {
 47+        xs *msg = NULL;
 48+
 49+        if (user) {
 50+            if (!valid_status(timeline_get_by_md5(user, v, &msg)))
 51+                continue;
 52+        }
 53+        else {
 54+            if (!valid_status(object_get_by_md5(v, &msg)))
 55+                continue;
 56+        }
 57+
 58+        const char *id = xs_dict_get(msg, "id");
 59+        const char *content = xs_dict_get(msg, "content");
 60+        const char *published = xs_dict_get(msg, "published");
 61+
 62+        if (user && !xs_startswith(id, user->actor))
 63+            continue;
 64+
 65+        if (!id || !content || !published)
 66+            continue;
 67+
 68+        /* create a title with the first line of the content */
 69+        xs *title = xs_replace(content, "<br>", "\n");
 70+        title = xs_regex_replace_i(title, "<[^>]+>", " ");
 71+        title = xs_regex_replace_i(title, "&[^;]+;", " ");
 72+        int i;
 73+
 74+        for (i = 0; title[i] && title[i] != '\n' && i < 50; i++);
 75+
 76+        if (title[i] != '\0') {
 77+            title[i] = '\0';
 78+            title = xs_str_cat(title, "...");
 79+        }
 80+
 81+        title = xs_strip_i(title);
 82+
 83+        /* convert the date */
 84+        time_t t = xs_parse_iso_date(published, 0);
 85+        xs *rss_date = xs_str_utctime(t, "%a, %d %b %Y %T +0000");
 86+
 87+        /* if it's the first one, add it to the header */
 88+        if (cnt == 0)
 89+            xs_html_add(channel,
 90+                xs_html_tag("lastBuildDate",
 91+                    xs_html_text(rss_date)));
 92+
 93+        xs_html_add(channel,
 94+            xs_html_tag("item",
 95+                xs_html_tag("title",
 96+                    xs_html_text(title)),
 97+                xs_html_tag("link",
 98+                    xs_html_text(id)),
 99+                xs_html_tag("guid",
100+                    xs_html_text(id)),
101+                xs_html_tag("pubDate",
102+                    xs_html_text(rss_date)),
103+                xs_html_tag("description",
104+                    xs_html_text(content))));
105+
106+        cnt++;
107+    }
108+
109+    return xs_html_render_s(rss, "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
110+}
111+
112+
113+void rss_to_timeline(snac *user, const char *url)
114+/* reads an RSS and inserts all ActivityPub posts into the user's timeline */
115+{
116+    if (!xs_startswith(url, "https:/") && !xs_startswith(url, "http:/"))
117+        return;
118+
119+    xs *hdrs = xs_dict_new();
120+    hdrs = xs_dict_set(hdrs, "accept",     "application/rss+xml");
121+    hdrs = xs_dict_set(hdrs, "user-agent", USER_AGENT);
122+
123+    /* get the RSS metadata */
124+    xs *md5 = xs_md5_hex(url, strlen(url));
125+    xs *rss_md_fn = xs_fmt("%s/rss", user->basedir);
126+    mkdirx(rss_md_fn);
127+    rss_md_fn = xs_str_cat(rss_md_fn, "/", md5, ".json");
128+
129+    xs *rss_md = NULL;
130+    const char *etag = NULL;
131+
132+    FILE *f;
133+    if ((f = fopen(rss_md_fn, "r")) != NULL) {
134+        rss_md = xs_json_load(f);
135+        fclose(f);
136+
137+        etag = xs_dict_get(rss_md, "etag");
138+
139+        if (xs_is_string(etag))
140+            hdrs = xs_dict_set(hdrs, "if-none-match", etag);
141+    }
142+
143+    if (rss_md == NULL)
144+        rss_md = xs_dict_new();
145+
146+    xs *payload = NULL;
147+    int status;
148+    int p_size;
149+
150+    xs *rsp = xs_http_request("GET", url, hdrs, NULL, 0, &status, &payload, &p_size, 0);
151+
152+    snac_log(user, xs_fmt("parsing RSS %s %d", url, status));
153+
154+    if (!valid_status(status) || !xs_is_string(payload))
155+        return;
156+
157+    /* not an RSS? done */
158+    const char *ctype = xs_dict_get(rsp, "content-type");
159+    if (!xs_is_string(ctype) || xs_str_in(ctype, "application/rss+xml") == -1)
160+        return;
161+
162+    /* yes, parsing is done with regexes (now I have two problems blah blah blah) */
163+    xs *links = xs_regex_select(payload, "<link>[^<]+</link>");
164+    const char *link;
165+
166+    xs_list_foreach(links, link) {
167+        xs *l = xs_replace(link, "<link>", "");
168+        char *p = strchr(l, '<');
169+
170+        if (p == NULL)
171+            continue;
172+        *p = '\0';
173+
174+        /* skip this same URL */
175+        if (strcmp(l, url) == 0)
176+            continue;
177+
178+        /* skip crap */
179+        if (!xs_startswith(l, "https:/") && !xs_startswith(l, "http:/"))
180+            continue;
181+
182+        snac_debug(user, 1, xs_fmt("RSS link: %s", l));
183+
184+        if (timeline_here(user, l)) {
185+            snac_debug(user, 1, xs_fmt("RSS entry already in timeline %s", l));
186+            continue;
187+        }
188+
189+        /* special trick for Mastodon: convert from the alternate format */
190+        if (strchr(l, '@') != NULL) {
191+            xs *l2 = xs_split(l, "/");
192+
193+            if (xs_list_len(l2) == 5) {
194+                const char *uid = xs_list_get(l2, 3);
195+                if (*uid == '@') {
196+                    xs *guessed_id = xs_fmt("https:/" "/%s/users/%s/statuses/%s",
197+                        xs_list_get(l2, 2), uid + 1, xs_list_get(l2, -1));
198+
199+                    if (timeline_here(user, guessed_id)) {
200+                        snac_debug(user, 1, xs_fmt("RSS entry already in timeline (alt) %s", guessed_id));
201+                        continue;
202+                    }
203+                }
204+            }
205+        }
206+
207+        xs *obj = NULL;
208+
209+        if (!valid_status(object_get(l, &obj))) {
210+            /* object is not here: bring it */
211+            if (!valid_status(activitypub_request(user, l, &obj)))
212+                continue;
213+        }
214+
215+        if (xs_is_dict(obj)) {
216+            const char *id      = xs_dict_get(obj, "id");
217+            const char *type    = xs_dict_get(obj, "type");
218+            const char *attr_to = get_atto(obj);
219+
220+            if (!xs_is_string(id) || !xs_is_string(type) || !xs_is_string(attr_to))
221+                continue;
222+
223+            if (!xs_match(type, POSTLIKE_OBJECT_TYPE))
224+                continue;
225+
226+            if (timeline_here(user, id)) {
227+                snac_debug(user, 1, xs_fmt("RSS entry already in timeline (id) %s", id));
228+                continue;
229+            }
230+
231+            enqueue_actor_refresh(user, attr_to, 0);
232+
233+            timeline_add(user, id, obj);
234+
235+            snac_log(user, xs_fmt("new '%s' (RSS) %s %s", type, attr_to, id));
236+        }
237+    }
238+
239+    /* update the RSS metadata */
240+    etag = xs_dict_get(rsp, "etag");
241+
242+    if (xs_is_string(etag)) {
243+        rss_md = xs_dict_set(rss_md, "etag", etag);
244+        rss_md = xs_dict_set(rss_md, "url", url);
245+        if ((f = fopen(rss_md_fn, "w")) != NULL) {
246+            xs_json_dump(rss_md, 4, f);
247+            fclose(f);
248+        }
249+    }
250+}
251+
252+
253+void rss_poll_hashtags(void)
254+/* parses all RSS from all users */
255+{
256+    xs *list = user_list();
257+    const char *uid;
258+
259+    xs_list_foreach(list, uid) {
260+        snac user;
261+
262+        if (user_open(&user, uid)) {
263+            const xs_list *rss = xs_dict_get(user.config, "followed_hashtags");
264+
265+            if (xs_is_list(rss)) {
266+                const char *url;
267+
268+                xs_list_foreach(rss, url)
269+                    rss_to_timeline(&user, url);
270+            }
271+
272+            user_free(&user);
273+        }
274+    }
275+}
+14, -0
 1@@ -13,6 +13,14 @@ void sbox_enter(const char *basedir)
 2         return;
 3     }
 4 
 5+    int smail;
 6+    const char *url = xs_dict_get(srv_config, "smtp_url");
 7+
 8+    if (xs_is_string(url) && *url)
 9+        smail = 0;
10+    else
11+        smail = !xs_is_true(xs_dict_get(srv_config, "disable_email_notifications"));
12+
13     srv_debug(1, xs_fmt("Calling unveil()"));
14     unveil(basedir,                "rwc");
15     unveil("/tmp",                 "rwc");
16@@ -25,6 +33,9 @@ void sbox_enter(const char *basedir)
17     if (*address == '/')
18         unveil(address, "rwc");
19 
20+    if (smail)
21+        unveil("/usr/sbin/sendmail",   "x");
22+
23     unveil(NULL,                   NULL);
24 
25     srv_debug(1, xs_fmt("Calling pledge()"));
26@@ -34,6 +45,9 @@ void sbox_enter(const char *basedir)
27     if (*address == '/')
28         p = xs_str_cat(p, " unix");
29 
30+    if (smail)
31+        p = xs_str_cat(p, " exec");
32+
33     pledge(p, NULL);
34 }
35 
M snac.h
+13, -5
 1@@ -1,7 +1,7 @@
 2 /* snac - A simple, minimalistic ActivityPub instance */
 3 /* copyright (c) 2022 - 2025 grunfink et al. / MIT license */
 4 
 5-#define VERSION "2.77"
 6+#define VERSION "2.81-dev"
 7 
 8 #define USER_AGENT "snac/" VERSION
 9 
10@@ -164,7 +164,8 @@ int pending_count(snac *user);
11 
12 double timeline_mtime(snac *snac);
13 int timeline_touch(snac *snac);
14-int timeline_here(snac *snac, const char *md5);
15+int timeline_here_by_md5(snac *snac, const char *md5);
16+int timeline_here(snac *snac, const char *id);
17 int timeline_get_by_md5(snac *snac, const char *md5, xs_dict **msg);
18 int timeline_del(snac *snac, const char *id);
19 xs_str *user_index_fn(snac *user, const char *idx_name);
20@@ -293,6 +294,8 @@ void enqueue_object_request(snac *user, const char *id, int forward_secs);
21 void enqueue_verify_links(snac *user);
22 void enqueue_actor_refresh(snac *user, const char *actor, int forward_secs);
23 void enqueue_webmention(const xs_dict *msg);
24+void enqueue_notify_webhook(snac *user, const xs_dict *noti, int retries);
25+
26 int was_question_voted(snac *user, const char *id);
27 
28 xs_list *user_queue(snac *snac);
29@@ -322,7 +325,7 @@ void httpd(void);
30 int webfinger_request_signed(snac *snac, const char *qs, xs_str **actor, xs_str **user);
31 int webfinger_request(const char *qs, xs_str **actor, xs_str **user);
32 int webfinger_request_fake(const char *qs, xs_str **actor, xs_str **user);
33-int webfinger_get_handler(xs_dict *req, const char *q_path,
34+int webfinger_get_handler(const xs_dict *req, const char *q_path,
35                           xs_val **body, int *b_size, char **ctype);
36 
37 const char *default_avatar_base64(void);
38@@ -394,8 +397,6 @@ int html_get_handler(const xs_dict *req, const char *q_path,
39 int html_post_handler(const xs_dict *req, const char *q_path,
40                       char *payload, int p_size,
41                       char **body, int *b_size, char **ctype);
42-xs_str *timeline_to_rss(snac *user, const xs_list *timeline,
43-                        const char *title, const char *link, const char *desc);
44 
45 int write_default_css(void);
46 int snac_init(const char *_basedir);
47@@ -433,6 +434,8 @@ void mastoapi_purge(void);
48 void verify_links(snac *user);
49 
50 void export_csv(snac *user);
51+void export_posts(snac *user);
52+
53 int migrate_account(snac *user);
54 
55 void import_blocked_accounts_csv(snac *user, const char *fn);
56@@ -461,3 +464,8 @@ int badlogin_check(const char *user, const char *addr);
57 void badlogin_inc(const char *user, const char *addr);
58 
59 const char *lang_str(const char *str, const snac *user);
60+
61+xs_str *rss_from_timeline(snac *user, const xs_list *timeline,
62+                        const char *title, const char *link, const char *desc);
63+void rss_to_timeline(snac *user, const char *url);
64+void rss_poll_hashtags(void);
+76, -0
 1@@ -45,6 +45,7 @@ static const char *default_srv_config = "{"
 2 static const char *default_css =
 3     "body { max-width: 48em; margin: auto; line-height: 1.5; padding: 0.8em; word-wrap: break-word; }\n"
 4     "pre { overflow-x: scroll; }\n"
 5+    "blockquote { font-style: italic; }\n"
 6     ".snac-embedded-video, img { max-width: 100% }\n"
 7     ".snac-origin { font-size: 85% }\n"
 8     ".snac-score { float: right; font-size: 85% }\n"
 9@@ -488,6 +489,26 @@ void verify_links(snac *user)
10 
11     int c = 0;
12     while (metadata && xs_dict_next(metadata, &k, &v, &c)) {
13+        xs *wfinger = NULL;
14+        const char *ov = NULL;
15+
16+        /* is it an account handle? */
17+        if (*v == '@' && strchr(v + 1, '@')) {
18+            /* resolve it via webfinger */
19+            if (valid_status(webfinger_request(v, &wfinger, NULL)) && xs_is_string(wfinger)) {
20+                ov = v;
21+                v = wfinger;
22+
23+                /* store the alias */
24+                if (user->links == NULL)
25+                    user->links = xs_dict_new();
26+
27+                user->links = xs_dict_set(user->links, ov, v);
28+
29+                changed++;
30+            }
31+        }
32+
33         /* not an https link? skip */
34         if (!xs_startswith(v, "https:/" "/"))
35             continue;
36@@ -705,6 +726,61 @@ void export_csv(snac *user)
37 }
38 
39 
40+void export_posts(snac *user)
41+/* exports all posts to an OrderedCollection */
42+{
43+    xs *ifn = xs_fmt("%s/public.idx", user->basedir);
44+    xs *index = index_list(ifn, XS_ALL);
45+    xs *ofn = xs_fmt("%s/export/outbox.json", user->basedir);
46+    FILE *f;
47+
48+    if ((f = fopen(ofn, "w")) == NULL) {
49+        snac_log(user, xs_fmt("Cannot create file %s", ofn));
50+        return;
51+    }
52+
53+    int cnt = 0;
54+
55+    /* raw output */
56+    fprintf(f, "{\"@context\": \"https:/" "/www.w3.org/ns/activitystreams\",");
57+    fprintf(f, "\"id\": \"outbox.json\",");
58+    fprintf(f, "\"type\": \"OrderedCollection\",");
59+    fprintf(f, "\"orderedItems\": [");
60+
61+    const char *md5;
62+
63+    snac_log(user, xs_fmt("Creating %s...", ofn));
64+
65+    xs_list_foreach(index, md5) {
66+        xs *obj = NULL;
67+
68+        if (!valid_status(object_get_by_md5(md5, &obj)))
69+            continue;
70+
71+        const char *type = xs_dict_get(obj, "type");
72+
73+        if (!xs_is_string(type) || strcmp(type, "Note"))
74+            continue;
75+
76+        const char *atto = get_atto(obj);
77+
78+        if (!xs_is_string(atto) || strcmp(atto, user->actor))
79+            continue;
80+
81+        if (cnt)
82+            fprintf(f, ",");
83+
84+        xs *c_msg = msg_create(user, obj);
85+        xs_json_dump(c_msg, 0, f);
86+        cnt++;
87+    }
88+
89+    fprintf(f, "], \"totalItems\": %d}", cnt);
90+
91+    fclose(f);
92+}
93+
94+
95 void import_blocked_accounts_csv(snac *user, const char *ifn)
96 /* imports a Mastodon CSV file of blocked accounts */
97 {
+2, -2
 1@@ -93,7 +93,7 @@ int webfinger_request_signed(snac *snac, const char *qs, xs_str **actor, xs_str
 2         if (user != NULL) {
 3             const char *subject = xs_dict_get(obj, "subject");
 4 
 5-            if (subject)
 6+            if (subject && xs_startswith(subject, "acct:"))
 7                 *user = xs_replace_n(subject, "acct:", "", 1);
 8         }
 9 
10@@ -152,7 +152,7 @@ int webfinger_request_fake(const char *qs, xs_str **actor, xs_str **user)
11 }
12 
13 
14-int webfinger_get_handler(xs_dict *req, const char *q_path,
15+int webfinger_get_handler(const xs_dict *req, const char *q_path,
16                            xs_val **body, int *b_size, char **ctype)
17 /* serves webfinger queries */
18 {
M xs.h
+15, -0
 1@@ -90,6 +90,7 @@ xs_str *xs_rstrip_chars_i(xs_str *str, const char *chars);
 2 xs_str *xs_strip_chars_i(xs_str *str, const char *chars);
 3 #define xs_strip_i(str) xs_strip_chars_i(str, " \r\n\t\v\f")
 4 xs_str *xs_tolower_i(xs_str *str);
 5+xs_str *xs_toupper_i(xs_str *str);
 6 
 7 xs_list *xs_list_new(void);
 8 xs_list *xs_list_append_m(xs_list *list, const char *mem, int dsz);
 9@@ -692,6 +693,20 @@ xs_str *xs_tolower_i(xs_str *str)
10 }
11 
12 
13+xs_str *xs_toupper_i(xs_str *str)
14+/* convert to lowercase */
15+{
16+    XS_ASSERT_TYPE(str, XSTYPE_STRING);
17+
18+    int n;
19+
20+    for (n = 0; str[n]; n++)
21+        str[n] = toupper(str[n]);
22+
23+    return str;
24+}
25+
26+
27 /** lists **/
28 
29 xs_list *xs_list_new(void)
+2, -2
 1@@ -13,7 +13,7 @@
 2 #define _XS_FCGI_H
 3 
 4  xs_dict *xs_fcgi_request(FILE *f, xs_str **payload, int *p_size, int *id);
 5- void xs_fcgi_response(FILE *f, int status, xs_dict *headers, xs_str *body, int b_size, int id);
 6+ void xs_fcgi_response(FILE *f, int status, const xs_dict *headers, const xs_str *body, int b_size, int id);
 7 
 8 
 9 #ifdef XS_IMPLEMENTATION
10@@ -290,7 +290,7 @@ end:
11 }
12 
13 
14-void xs_fcgi_response(FILE *f, int status, xs_dict *headers, xs_str *body, int b_size, int fcgi_id)
15+void xs_fcgi_response(FILE *f, int status, const xs_dict *headers, const xs_str *body, int b_size, int fcgi_id)
16 /* writes an FCGI response */
17 {
18     struct fcgi_record_header hdr = {0};
+6, -6
 1@@ -5,7 +5,8 @@
 2 #define _XS_HTTPD_H
 3 
 4 xs_dict *xs_httpd_request(FILE *f, xs_str **payload, int *p_size);
 5-void xs_httpd_response(FILE *f, int status, const char *status_text, xs_dict *headers, xs_str *body, int b_size);
 6+void xs_httpd_response(FILE *f, int status, const char *status_text,
 7+                        const xs_dict *headers, const xs_val *body, int b_size);
 8 
 9 
10 #ifdef XS_IMPLEMENTATION
11@@ -109,16 +110,15 @@ xs_dict *xs_httpd_request(FILE *f, xs_str **payload, int *p_size)
12 }
13 
14 
15-void xs_httpd_response(FILE *f, int status, const char *status_text, xs_dict *headers, xs_str *body, int b_size)
16+void xs_httpd_response(FILE *f, int status, const char *status_text,
17+                        const xs_dict *headers, const xs_val *body, int b_size)
18 /* sends an httpd response */
19 {
20-    xs *proto;
21+    fprintf(f, "HTTP/1.1 %d %s\r\n", status, status_text ? status_text : "");
22+
23     const xs_str *k;
24     const xs_val *v;
25 
26-    proto = xs_fmt("HTTP/1.1 %d %s", status, status_text);
27-    fprintf(f, "%s\r\n", proto);
28-
29     xs_dict_foreach(headers, k, v) {
30         fprintf(f, "%s: %s\r\n", k, v);
31     }
+94, -69
  1@@ -8,6 +8,7 @@
  2 #define MAX_JSON_DEPTH 32
  3 #endif
  4 
  5+void xs_json_dump_value(const xs_val *data, int level, int indent, FILE *f);
  6 int xs_json_dump(const xs_val *data, int indent, FILE *f);
  7 xs_str *xs_json_dumps(const xs_val *data, int indent);
  8 
  9@@ -19,8 +20,8 @@ xs_val *xs_json_loads_full(const xs_str *json, int maxdepth);
 10 xstype xs_json_load_type(FILE *f);
 11 int xs_json_load_array_iter(FILE *f, xs_val **value, xstype *pt, int *c);
 12 int xs_json_load_object_iter(FILE *f, xs_str **key, xs_val **value, xstype *pt, int *c);
 13-xs_list *xs_json_load_array(FILE *f, int maxdepth);
 14-xs_dict *xs_json_load_object(FILE *f, int maxdepth);
 15+int xs_json_load_array(FILE *f, int maxdepth, xs_list **l);
 16+int xs_json_load_object(FILE *f, int maxdepth, xs_dict **d);
 17 
 18 
 19 #ifdef XS_IMPLEMENTATION
 20@@ -77,7 +78,7 @@ static void _xs_json_indent(int level, int indent, FILE *f)
 21 }
 22 
 23 
 24-static void _xs_json_dump(const xs_val *data, int level, int indent, FILE *f)
 25+void xs_json_dump_value(const xs_val *data, int level, int indent, FILE *f)
 26 /* dumps partial data as JSON */
 27 {
 28     int c = 0;
 29@@ -108,7 +109,7 @@ static void _xs_json_dump(const xs_val *data, int level, int indent, FILE *f)
 30                 fputc(',', f);
 31 
 32             _xs_json_indent(level + 1, indent, f);
 33-            _xs_json_dump(v, level + 1, indent, f);
 34+            xs_json_dump_value(v, level + 1, indent, f);
 35 
 36             c++;
 37         }
 38@@ -135,7 +136,7 @@ static void _xs_json_dump(const xs_val *data, int level, int indent, FILE *f)
 39             if (indent)
 40                 fputc(' ', f);
 41 
 42-            _xs_json_dump(v, level + 1, indent, f);
 43+            xs_json_dump_value(v, level + 1, indent, f);
 44 
 45             c++;
 46         }
 47@@ -154,6 +155,20 @@ static void _xs_json_dump(const xs_val *data, int level, int indent, FILE *f)
 48 }
 49 
 50 
 51+int xs_json_dump(const xs_val *data, int indent, FILE *f)
 52+/* dumps data into a file as JSON */
 53+{
 54+    xstype t = xs_type(data);
 55+
 56+    if (t == XSTYPE_LIST || t == XSTYPE_DICT) {
 57+        xs_json_dump_value(data, 0, indent, f);
 58+        return 1;
 59+    }
 60+
 61+    return 0;
 62+}
 63+
 64+
 65 xs_str *xs_json_dumps(const xs_val *data, int indent)
 66 /* dumps data as a JSON string */
 67 {
 68@@ -173,20 +188,6 @@ xs_str *xs_json_dumps(const xs_val *data, int indent)
 69 }
 70 
 71 
 72-int xs_json_dump(const xs_val *data, int indent, FILE *f)
 73-/* dumps data into a file as JSON */
 74-{
 75-    xstype t = xs_type(data);
 76-
 77-    if (t == XSTYPE_LIST || t == XSTYPE_DICT) {
 78-        _xs_json_dump(data, 0, indent, f);
 79-        return 1;
 80-    }
 81-
 82-    return 0;
 83-}
 84-
 85-
 86 /** JSON loads **/
 87 
 88 typedef enum {
 89@@ -370,6 +371,8 @@ int xs_json_load_array_iter(FILE *f, xs_val **value, xstype *pt, int *c)
 90         else
 91             return -1;
 92     }
 93+    else
 94+        *pt = xs_type(*value);
 95 
 96     *c = *c + 1;
 97 
 98@@ -377,43 +380,51 @@ int xs_json_load_array_iter(FILE *f, xs_val **value, xstype *pt, int *c)
 99 }
100 
101 
102-xs_list *xs_json_load_array(FILE *f, int maxdepth)
103+int xs_json_load_array(FILE *f, int maxdepth, xs_list **l)
104 /* loads a full JSON array (after the initial OBRACK) */
105+/* l can be NULL for the content to be dropped */
106 {
107     xstype t;
108-    xs_list *l = xs_list_new();
109+    int r = 0;
110     int c = 0;
111 
112     for (;;) {
113         xs *v = NULL;
114-        int r = xs_json_load_array_iter(f, &v, &t, &c);
115-
116-        if (r == -1)
117-            l = xs_free(l);
118+        r = xs_json_load_array_iter(f, &v, &t, &c);
119 
120         if (r == 1) {
121             /* partial load? */
122             if (v == NULL && maxdepth != 0) {
123-                if (t == XSTYPE_LIST)
124-                    v = xs_json_load_array(f, maxdepth - 1);
125+                if (t == XSTYPE_LIST) {
126+                    if (l)
127+                        v = xs_list_new();
128+
129+                    r = xs_json_load_array(f, maxdepth - 1, &v);
130+                }
131                 else
132-                if (t == XSTYPE_DICT)
133-                    v = xs_json_load_object(f, maxdepth - 1);
134+                if (t == XSTYPE_DICT) {
135+                    if (l)
136+                        v = xs_dict_new();
137+
138+                    r = xs_json_load_object(f, maxdepth - 1, &v);
139+                }
140             }
141 
142-            /* still null? fail */
143-            if (v == NULL) {
144-                l = xs_free(l);
145+            /* error? */
146+            if (r < 0)
147                 break;
148-            }
149 
150-            l = xs_list_append(l, v);
151+            if (l)
152+                *l = xs_list_append(*l, v);
153         }
154         else
155             break;
156     }
157 
158-    return l;
159+    if (r < 0 && l)
160+        *l = xs_free(*l);
161+
162+    return r;
163 }
164 
165 
166@@ -458,6 +469,8 @@ int xs_json_load_object_iter(FILE *f, xs_str **key, xs_val **value, xstype *pt,
167         else
168             return -1;
169     }
170+    else
171+        *pt = xs_type(*value);
172 
173     *c = *c + 1;
174 
175@@ -465,59 +478,52 @@ int xs_json_load_object_iter(FILE *f, xs_str **key, xs_val **value, xstype *pt,
176 }
177 
178 
179-xs_dict *xs_json_load_object(FILE *f, int maxdepth)
180+int xs_json_load_object(FILE *f, int maxdepth, xs_dict **d)
181 /* loads a full JSON object (after the initial OCURLY) */
182+/* d can be NULL for the content to be dropped */
183 {
184     xstype t;
185-    xs_dict *d = xs_dict_new();
186+    int r = 0;
187     int c = 0;
188 
189     for (;;) {
190         xs *k = NULL;
191         xs *v = NULL;
192-        int r = xs_json_load_object_iter(f, &k, &v, &t, &c);
193-
194-        if (r == -1)
195-            d = xs_free(d);
196+        r = xs_json_load_object_iter(f, &k, &v, &t, &c);
197 
198         if (r == 1) {
199             /* partial load? */
200             if (v == NULL && maxdepth != 0) {
201-                if (t == XSTYPE_LIST)
202-                    v = xs_json_load_array(f, maxdepth - 1);
203+                if (t == XSTYPE_LIST) {
204+                    if (d)
205+                        v = xs_list_new();
206+
207+                    r = xs_json_load_array(f, maxdepth - 1, &v);
208+                }
209                 else
210-                if (t == XSTYPE_DICT)
211-                    v = xs_json_load_object(f, maxdepth - 1);
212+                if (t == XSTYPE_DICT) {
213+                    if (d)
214+                        v = xs_dict_new();
215+
216+                    r = xs_json_load_object(f, maxdepth - 1, &v);
217+                }
218             }
219 
220-            /* still null? fail */
221-            if (v == NULL) {
222-                d = xs_free(d);
223+            /* error? */
224+            if (r < 0)
225                 break;
226-            }
227 
228-            d = xs_dict_append(d, k, v);
229+            if (d)
230+                *d = xs_dict_append(*d, k, v);
231         }
232         else
233             break;
234     }
235 
236-    return d;
237-}
238-
239-
240-xs_val *xs_json_loads_full(const xs_str *json, int maxdepth)
241-/* loads a string in JSON format and converts to a multiple data */
242-{
243-    FILE *f;
244-    xs_val *v = NULL;
245-
246-    if ((f = fmemopen((char *)json, strlen(json), "r")) != NULL) {
247-        v = xs_json_load_full(f, maxdepth);
248-        fclose(f);
249-    }
250+    if (r < 0 && d)
251+        *d = xs_free(*d);
252 
253-    return v;
254+    return r;
255 }
256 
257 
258@@ -545,11 +551,30 @@ xs_val *xs_json_load_full(FILE *f, int maxdepth)
259     xs_val *v = NULL;
260     xstype t = xs_json_load_type(f);
261 
262-    if (t == XSTYPE_LIST)
263-        v = xs_json_load_array(f, maxdepth);
264+    if (t == XSTYPE_LIST) {
265+        v = xs_list_new();
266+        xs_json_load_array(f, maxdepth, &v);
267+    }
268     else
269-    if (t == XSTYPE_DICT)
270-        v = xs_json_load_object(f, maxdepth);
271+    if (t == XSTYPE_DICT) {
272+        v = xs_dict_new();
273+        xs_json_load_object(f, maxdepth, &v);
274+    }
275+
276+    return v;
277+}
278+
279+
280+xs_val *xs_json_loads_full(const xs_str *json, int maxdepth)
281+/* loads a string in JSON format and converts to a multiple data */
282+{
283+    FILE *f;
284+    xs_val *v = NULL;
285+
286+    if ((f = fmemopen((char *)json, strlen(json), "r")) != NULL) {
287+        v = xs_json_load_full(f, maxdepth);
288+        fclose(f);
289+    }
290 
291     return v;
292 }
+1, -1
1@@ -1 +1 @@
2-/* 871d420cef893b6efe32869407294baf084ce3ab 2025-05-04T11:01:01+02:00 */
3+/* 401d229ffbec89b4a5cf97793926b7afb84a4f26 2025-07-08T15:44:54+02:00 */
+42, -0
 1@@ -5,6 +5,7 @@
 2 #define _XS_WEBMENTION_H
 3 
 4 int xs_webmention_send(const char *source, const char *target, const char *user_agent);
 5+int xs_webmention_hook(const char *source, const char *target, const char *user_agent);
 6 
 7 
 8 #ifdef XS_IMPLEMENTATION
 9@@ -118,6 +119,47 @@ int xs_webmention_send(const char *source, const char *target, const char *user_
10 }
11 
12 
13+int xs_webmention_hook(const char *source, const char *target, const char *user_agent)
14+/* a Webmention has been received for a target that is ours; check if the source
15+   really contains a link to our target */
16+{
17+    int status = 0;
18+
19+    xs *ua = xs_fmt("%s (Webmention)", user_agent ? user_agent : "xs_webmention");
20+    xs *headers = xs_dict_new();
21+    headers = xs_dict_set(headers, "accept", "text/html");
22+    headers = xs_dict_set(headers, "user-agent", ua);
23+
24+    xs *g_req = NULL;
25+    xs *payload = NULL;
26+    int p_size = 0;
27+
28+    g_req = xs_http_request("GET", source, headers, NULL, 0, &status, &payload, &p_size, 0);
29+
30+    if (status < 200 || status > 299)
31+        return -1;
32+
33+    if (!xs_is_string(payload))
34+        return -2;
35+
36+    /* note: a "rogue" webmention can include a link to our target in commented-out HTML code */
37+
38+    xs *links = xs_regex_select(payload, "<(a +|link +)[^>]+>");
39+    const char *link;
40+
41+    status = 0;
42+    xs_list_foreach(links, link) {
43+        /* if the link contains our target, it's valid */
44+        if (xs_str_in(link, target) != -1) {
45+            status = 1;
46+            break;
47+        }
48+    }
49+
50+    return status;
51+}
52+
53+
54 #endif /* XS_IMPLEMENTATION */
55 
56 #endif /* _XS_WEBMENTION_H */