Ver código fonte

Merge pull request 'master' (#9) from grunfink/snac2:master into master

Reviewed-on: https://codeberg.org/zen/snac2/pulls/9
Oliver 1 ano atrás
pai
commit
3554a73aa5
28 arquivos alterados com 2079 adições e 366 exclusões
  1. 9 7
      Makefile
  2. 9 7
      Makefile.NetBSD
  3. 45 1
      RELEASE_NOTES.md
  4. 4 0
      TODO.md
  5. 38 1
      activitypub.c
  6. 61 9
      data.c
  7. 82 17
      doc/snac.1
  8. 10 0
      doc/snac.8
  9. 1 0
      doc/style.css
  10. 55 0
      examples/auto_follower_webhook.py
  11. 18 14
      format.c
  12. 94 130
      html.c
  13. 106 14
      httpd.c
  14. 108 58
      main.c
  15. 128 19
      mastoapi.c
  16. 4 4
      po/ru.po
  17. 768 0
      po/uk.po
  18. 274 0
      rss.c
  19. 14 0
      sandbox.c
  20. 13 5
      snac.h
  21. 76 0
      utils.c
  22. 2 2
      webfinger.c
  23. 15 0
      xs.h
  24. 2 2
      xs_fcgi.h
  25. 6 6
      xs_httpd.h
  26. 94 69
      xs_json.h
  27. 1 1
      xs_version.h
  28. 42 0
      xs_webmention.h

+ 9 - 7
Makefile

@@ -5,7 +5,7 @@ CFLAGS?=-g -Wall -Wextra -pedantic
 all: snac
 
 snac: snac.o main.o sandbox.o data.o http.o httpd.o webfinger.o \
-    activitypub.o html.o utils.o format.o upgrade.o mastoapi.o
+    activitypub.o html.o utils.o format.o upgrade.o mastoapi.o rss.o
 	$(CC) $(CFLAGS) -L$(PREFIX)/lib *.o -lcurl -lcrypto $(LDFLAGS) -pthread -o $@
 
 test: tests/smtp
@@ -48,25 +48,27 @@ update-po:
 
 activitypub.o: activitypub.c xs.h xs_json.h xs_curl.h xs_mime.h \
  xs_openssl.h xs_regex.h xs_time.h xs_set.h xs_match.h xs_unicode.h \
- snac.h http_codes.h
+ xs_webmention.h snac.h http_codes.h
 data.o: data.c xs.h xs_hex.h xs_io.h xs_json.h xs_openssl.h xs_glob.h \
  xs_set.h xs_time.h xs_regex.h xs_match.h xs_unicode.h xs_random.h \
  xs_po.h snac.h http_codes.h
 format.o: format.c xs.h xs_regex.h xs_mime.h xs_html.h xs_json.h \
- xs_time.h xs_match.h snac.h http_codes.h
+ xs_time.h xs_match.h xs_unicode.h snac.h http_codes.h
 html.o: html.c xs.h xs_io.h xs_json.h xs_regex.h xs_set.h xs_openssl.h \
  xs_time.h xs_mime.h xs_match.h xs_html.h xs_curl.h xs_unicode.h xs_url.h \
  xs_random.h snac.h http_codes.h
 http.o: http.c xs.h xs_io.h xs_openssl.h xs_curl.h xs_time.h xs_json.h \
  snac.h http_codes.h
 httpd.o: httpd.c xs.h xs_io.h xs_json.h xs_socket.h xs_unix_socket.h \
- xs_httpd.h xs_mime.h xs_time.h xs_openssl.h xs_fcgi.h xs_html.h snac.h \
- http_codes.h
+ xs_httpd.h xs_mime.h xs_time.h xs_openssl.h xs_fcgi.h xs_html.h \
+ xs_webmention.h snac.h http_codes.h
 main.o: main.c xs.h xs_io.h xs_json.h xs_time.h xs_openssl.h xs_match.h \
- snac.h http_codes.h
+ xs_random.h snac.h http_codes.h
 mastoapi.o: mastoapi.c xs.h xs_hex.h xs_openssl.h xs_json.h xs_io.h \
  xs_time.h xs_glob.h xs_set.h xs_random.h xs_url.h xs_mime.h xs_match.h \
- snac.h http_codes.h
+ xs_unicode.h snac.h http_codes.h
+rss.o: rss.c xs.h xs_html.h xs_regex.h xs_time.h xs_match.h xs_curl.h \
+ xs_openssl.h xs_json.h snac.h http_codes.h
 sandbox.o: sandbox.c xs.h snac.h http_codes.h
 snac.o: snac.c xs.h xs_hex.h xs_io.h xs_unicode_tbl.h xs_unicode.h \
  xs_json.h xs_curl.h xs_openssl.h xs_socket.h xs_unix_socket.h xs_url.h \

+ 9 - 7
Makefile.NetBSD

@@ -6,7 +6,7 @@ LDFLAGS=-lrt
 all: snac
 
 snac: snac.o main.o sandbox.o data.o http.o httpd.o webfinger.o \
-    activitypub.o html.o utils.o format.o upgrade.o mastoapi.o
+    activitypub.o html.o utils.o format.o upgrade.o mastoapi.o rss.o
 	$(CC) $(CFLAGS) -L/usr/pkg/lib *.o -lcurl -lcrypto -pthread $(LDFLAGS) -Wl,-rpath,/usr/lib -Wl,-rpath,/usr/pkg/lib -o $@
 
 
@@ -37,25 +37,27 @@ uninstall:
 
 activitypub.o: activitypub.c xs.h xs_json.h xs_curl.h xs_mime.h \
  xs_openssl.h xs_regex.h xs_time.h xs_set.h xs_match.h xs_unicode.h \
- snac.h http_codes.h
+ xs_webmention.h snac.h http_codes.h
 data.o: data.c xs.h xs_hex.h xs_io.h xs_json.h xs_openssl.h xs_glob.h \
  xs_set.h xs_time.h xs_regex.h xs_match.h xs_unicode.h xs_random.h \
  xs_po.h snac.h http_codes.h
 format.o: format.c xs.h xs_regex.h xs_mime.h xs_html.h xs_json.h \
- xs_time.h xs_match.h snac.h http_codes.h
+ xs_time.h xs_match.h xs_unicode.h snac.h http_codes.h
 html.o: html.c xs.h xs_io.h xs_json.h xs_regex.h xs_set.h xs_openssl.h \
  xs_time.h xs_mime.h xs_match.h xs_html.h xs_curl.h xs_unicode.h xs_url.h \
  xs_random.h snac.h http_codes.h
 http.o: http.c xs.h xs_io.h xs_openssl.h xs_curl.h xs_time.h xs_json.h \
  snac.h http_codes.h
 httpd.o: httpd.c xs.h xs_io.h xs_json.h xs_socket.h xs_unix_socket.h \
- xs_httpd.h xs_mime.h xs_time.h xs_openssl.h xs_fcgi.h xs_html.h snac.h \
- http_codes.h
+ xs_httpd.h xs_mime.h xs_time.h xs_openssl.h xs_fcgi.h xs_html.h \
+ xs_webmention.h snac.h http_codes.h
 main.o: main.c xs.h xs_io.h xs_json.h xs_time.h xs_openssl.h xs_match.h \
- snac.h http_codes.h
+ xs_random.h snac.h http_codes.h
 mastoapi.o: mastoapi.c xs.h xs_hex.h xs_openssl.h xs_json.h xs_io.h \
  xs_time.h xs_glob.h xs_set.h xs_random.h xs_url.h xs_mime.h xs_match.h \
- snac.h http_codes.h
+ xs_unicode.h snac.h http_codes.h
+rss.o: rss.c xs.h xs_html.h xs_regex.h xs_time.h xs_match.h xs_curl.h \
+ xs_openssl.h xs_json.h snac.h http_codes.h
 sandbox.o: sandbox.c xs.h snac.h http_codes.h
 snac.o: snac.c xs.h xs_hex.h xs_io.h xs_unicode_tbl.h xs_unicode.h \
  xs_json.h xs_curl.h xs_openssl.h xs_socket.h xs_unix_socket.h xs_url.h \

+ 45 - 1
RELEASE_NOTES.md

@@ -2,11 +2,55 @@
 
 ## UNRELEASED
 
+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).
+
+Added Ukrainian translation (contributed by wincentbalin).
+
+## 2.80
+
+Mastodon API: fixed a regression (introduced in previous version in the "boosts disappear in Tusky" fix) that interrupted timelines.
+
+## 2.79
+
+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).
+
+Fixed regression while sending email via pipe on OpenBSD.
+
+Fixed Markdown parsing when the URL has parenthesis.
+
+Always show the 'pending follow confirmations' section if there are any (even if the toggle is off).
+
+If a metadata value is an account handler, it's also tried to be validated (rel="me" links).
+
+Another search by URL tweak (this time for Pixelfed links).
+
+Mastodon API: fixed a bug that made some boosts disappear after being shown in apps like Tusky, added followed hashtags maintenance, other minor changes.
+
+Renamed command-line actions `create_list` to `list_create` and `delete_list` to `list_remove`.
+
+The default favicon URL can be changed from the server configuration.
+
+New command-line option `export_posts`, to export all posts by a user in a JSON format compatible with the one generated by Mastodon.
+
+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.
+
+## 2.78
+
+Hashtag following also allow URLs to RSS feeds of ActivityPub objects (like e.g. https://mastodon.social/tags/ThankYouTuesday).
+
+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).
+
 The number of pending follow confirmations is shown next to the "people" link.
 
 Faster performance metrics (contributed by dandelions).
 
-Mastodon API: added follow confirmation endpoints.
+Improved lowercasing in hashtags (contributed by postscriptum).
+
+A search-by-url tweak for implementations that return 200 for invalid webfinger queries (e.g. piefed).
+
+Mastodon API: added follow confirmation endpoints, fixed collisions in attachment file names.
+
+Fixed potential crashes in attachment uploads.
 
 ## 2.77 "Ugly Links Everywhere"
 

+ 4 - 0
TODO.md

@@ -14,6 +14,8 @@ Important: deleting a follower should do more that just delete the object, see h
 
 ## Wishlist
 
+The local purge should generate `Delete` activities for local posts.
+
 Add account reporting.
 
 Add a list option to hide member posts from the main timeline, see https://codeberg.org/grunfink/snac2/issues/383
@@ -22,6 +24,8 @@ The instance timeline should also show boosts from users.
 
 Mastoapi: implement /v1/conversations.
 
+Track "BadgeFed - ActivityPub Badges" https://github.com/tryvocalcat/badgefed
+
 Track 'Event' data types standardization; how to add plan-to-attend and similar activities (more info: https://event-federation.eu/). Friendica interacts with events via activities `Accept` (will go), `TentativeAccept` (will try to go) or `Reject` (cannot go) (`object` field as id, not object). `Undo` for any of these activities cancel (`object` as an object, not id).
 
 Implement "FEP-3b86: Activity Intents" https://codeberg.org/fediverse/fep/src/branch/main/fep/3b86/fep-3b86.md

+ 38 - 1
activitypub.c

@@ -903,7 +903,7 @@ xs_str *process_tags(snac *snac, const char *content, xs_list **tag)
             if (*v == '#') {
                 /* hashtag */
                 xs *d = xs_dict_new();
-                xs *n = xs_tolower_i(xs_dup(v));
+                xs *n = xs_utf8_to_lower(v);
                 xs *h = xs_fmt("%s?t=%s", srv_baseurl, n + 1);
                 xs *l = xs_fmt("<a href=\"%s\" class=\"mention hashtag\" rel=\"tag\">%s</a>", h, v);
 
@@ -2765,6 +2765,39 @@ void process_user_queue_item(snac *user, xs_dict *q_item)
             snac_log(user, xs_fmt("actor_refresh %s %d", actor, status));
         }
     }
+    else
+    if (strcmp(type, "notify_webhook") == 0) {
+        const char *webhook = xs_dict_get(user->config, "notify_webhook");
+
+        if (xs_is_string(webhook) && xs_match(webhook, "https://*|http://*")) { /** **/
+            const xs_dict *msg = xs_dict_get(q_item, "message");
+            int retries = xs_number_get(xs_dict_get(q_item, "retries"));
+
+            xs *hdrs = xs_dict_new();
+
+            hdrs = xs_dict_set(hdrs, "content-type", "application/json");
+            hdrs = xs_dict_set(hdrs, "user-agent",   USER_AGENT);
+
+            xs *body = xs_json_dumps(msg, 4);
+
+            int status;
+            xs *rsp = xs_http_request("POST", webhook, hdrs, body, strlen(body), &status, NULL, NULL, 0);
+
+            snac_debug(user, 0, xs_fmt("webhook post %s %d", webhook, status));
+
+            if (!valid_status(status)) {
+                retries++;
+
+                if (retries > queue_retry_max)
+                    snac_debug(user, 0, xs_fmt("webhook post giving up %s", webhook));
+                else {
+                    snac_debug(user, 0, xs_fmt("webhook post requeue %s %d", webhook, retries));
+
+                    enqueue_notify_webhook(user, msg, retries);
+                }
+            }
+        }
+    }
     else
         snac_log(user, xs_fmt("unexpected user q_item type '%s'", type));
 }
@@ -3049,6 +3082,10 @@ void process_queue_item(xs_dict *q_item)
             }
         }
     }
+    else
+    if (strcmp(type, "rss_hashtag_poll") == 0) {
+        rss_poll_hashtags();
+    }
     else
         srv_log(xs_fmt("unexpected q_item type '%s'", type));
 }

+ 61 - 9
data.c

@@ -333,6 +333,7 @@ int user_open_by_md5(snac *snac, const char *md5)
     return 0;
 }
 
+
 int user_persist(snac *snac, int publish)
 /* store user */
 {
@@ -348,7 +349,7 @@ int user_persist(snac *snac, int publish)
 
             if (old != NULL) {
                 int nw = 0;
-                const char *fields[] = { "header", "avatar", "name", "bio",
+                const char *fields[] = { "header", "avatar", "name", "bio", "alias", "alias_raw",
                                          "metadata", "latitude", "longitude", NULL };
 
                 for (int n = 0; fields[n]; n++) {
@@ -1391,7 +1392,7 @@ xs_str *timeline_fn_by_md5(snac *snac, const char *md5)
 }
 
 
-int timeline_here(snac *snac, const char *md5)
+int timeline_here_by_md5(snac *snac, const char *md5)
 /* checks if an object is in the user cache */
 {
     xs *fn = timeline_fn_by_md5(snac, md5);
@@ -1400,6 +1401,14 @@ int timeline_here(snac *snac, const char *md5)
 }
 
 
+int timeline_here(snac *user, const char *id)
+{
+    xs *md5 = xs_md5_hex(id, strlen(id));
+
+    return timeline_here_by_md5(user, md5);
+}
+
+
 int timeline_get_by_md5(snac *snac, const char *md5, xs_dict **msg)
 /* gets a message from the timeline */
 {
@@ -1515,7 +1524,7 @@ xs_list *timeline_top_level(snac *snac, const xs_list *list)
                 break;
 
             /* well, there is a parent... but is it here? */
-            if (!timeline_here(snac, line2))
+            if (!timeline_here_by_md5(snac, line2))
                 break;
 
             /* it's here! try again with its own parent */
@@ -2217,7 +2226,7 @@ void tag_index(const char *id, const xs_dict *obj)
                 if (*name == '\0')
                     continue;
 
-                name = xs_tolower_i((xs_str *)name);
+                name = xs_utf8_to_lower((xs_str *)name);
 
                 xs *md5_tag   = xs_md5_hex(name, strlen(name));
                 xs *tag_dir   = xs_fmt("%s/%c%c", g_tag_dir, md5_tag[0], md5_tag[1]);
@@ -2235,7 +2244,7 @@ void tag_index(const char *id, const xs_dict *obj)
                     fclose(f);
                 }
 
-                srv_debug(0, xs_fmt("tagged %s #%s (#%s)", id, name, md5_tag));
+                srv_debug(1, xs_fmt("tagged %s #%s (#%s)", id, name, md5_tag));
             }
         }
     }
@@ -2247,7 +2256,7 @@ xs_str *tag_fn(const char *tag)
     if (*tag == '#')
         tag++;
 
-    xs *lw_tag = xs_tolower_i(xs_dup(tag));
+    xs *lw_tag = xs_utf8_to_lower(tag);
     xs *md5    = xs_md5_hex(lw_tag, strlen(lw_tag));
 
     return xs_fmt("%s/tag/%c%c/%s.idx", srv_basedir, md5[0], md5[1], md5);
@@ -2832,9 +2841,9 @@ int content_match(const char *file, const xs_dict *msg)
             srv_debug(1, xs_fmt("content_match: loading regexes from %s", fn));
 
             /* massage content (strip HTML tags, etc.) */
-            xs *c = xs_regex_replace(v, "<[^>]+>", " ");
-            c = xs_regex_replace_i(c, " {2,}", " ");
-            c = xs_tolower_i(c);
+            xs *c1 = xs_regex_replace(v, "<[^>]+>", " ");
+            c1 = xs_regex_replace_i(c1, " {2,}", " ");
+            xs *c = xs_utf8_to_lower(c1);
 
             while (!r && !feof(f)) {
                 xs *rx = xs_strip_i(xs_readline(f));
@@ -3158,6 +3167,9 @@ void notify_add(snac *snac, const char *type, const char *utype,
 
         pthread_mutex_unlock(&data_mutex);
     }
+
+    if (!xs_is_true(xs_dict_get(srv_config, "disable_notify_webhook")))
+        enqueue_notify_webhook(snac, noti, 0);
 }
 
 
@@ -3513,6 +3525,46 @@ void enqueue_webmention(const xs_dict *msg)
 }
 
 
+void enqueue_notify_webhook(snac *user, const xs_dict *noti, int retries)
+/* enqueues a notification webhook */
+{
+    const char *webhook = xs_dict_get(user->config, "notify_webhook");
+
+    if (xs_is_string(webhook) && xs_match(webhook, "https://*|http://*")) { /** **/
+        xs *msg = xs_dup(noti);
+
+        /* add more data */
+        msg = xs_dict_set(msg, "target", user->actor);
+        msg = xs_dict_set(msg, "uid", user->uid);
+        msg = xs_dict_set(msg, "basedir", srv_basedir);
+        msg = xs_dict_set(msg, "baseurl", srv_baseurl);
+
+        xs *actor_obj = NULL;
+
+        if (valid_status(object_get(xs_dict_get(noti, "actor"), &actor_obj)) && actor_obj)
+            msg = xs_dict_set(msg, "account", actor_obj);
+
+        /* if this post is a reply, also add the inReplyTo object */
+        const char *in_reply_to = xs_dict_get_path(msg, "msg.object.inReplyTo");
+
+        if (xs_is_string(in_reply_to)) {
+            xs *irt_obj = NULL;
+
+            if (valid_status(object_get(in_reply_to, &irt_obj)))
+                msg = xs_dict_set(msg, "reply", irt_obj);
+        }
+
+        xs *qmsg = _new_qmsg("notify_webhook", msg, retries);
+        const char *ntid = xs_dict_get(qmsg, "ntid");
+        xs *fn   = xs_fmt("%s/queue/%s.json", user->basedir, ntid);
+
+        qmsg = _enqueue_put(fn, qmsg);
+
+        snac_debug(user, 1, xs_fmt("notify_webhook"));
+    }
+}
+
+
 int was_question_voted(snac *user, const char *id)
 /* returns true if the user voted in this poll */
 {

+ 82 - 17
doc/snac.1

@@ -24,9 +24,9 @@ For file and data formats, see
 .Ss Web Interface
 The web interface provided by
 .Nm
-is split in two data streams: the public timeline and the
-private timeline. There are no other feeds like the server-scoped
-or the federated firehoses provided by other similar ActivityPub
+is split in three data streams: the public timeline, the private
+timeline and the instance timeline. There are no other feeds like
+the federated firehoses provided by other similar ActivityPub
 implementations like Mastodon or Pleroma.
 .Pp
 The public timeline, also called the local timeline, is what an
@@ -67,9 +67,23 @@ sent to those people you mention in the post body.
 If you fill this optional text field with the URL of another one's
 post, your text will be considered as a reply to it, not a
 standalone one.
+.It Draft
+If you set this checkbox, your text will not be sent when you
+push the Post button, but stored for later modification in
+the "Drafts" section.
+.It Scheduled post...
+This dropdown menu allows setting a date and time for the
+post publication.
+.It Attachments...
+This dropdown menu allows uploading media attachments (images,
+audio, video, etc.) to your post.
+.It Poll...
+this dropdown menu gives access to the voting options, that
+will make your post a poll. You can set the options to be
+voted, if it's a multiple choice poll and the due date.
 .El
 .Pp
-More options are hidden under a toggle control. They are the
+More options are hidden under dropdown menus. They are the
 following:
 .Bl -tag -offset indent
 .It Follow (by URL or user@host)
@@ -85,7 +99,9 @@ liked.
 This option opens the user setup dialog.
 .It Followed hashtags...
 Enter here the list of hashtags you want to follow, one
-per line, with or without the # symbol.
+per line, with or without the # symbol. Since version 2.78,
+URLs to RSS feeds of ActivityPub objects are also allowed
+(like e.g. https://mastodon.social/tags/bloomscrolling).
 .It Blocked hashtags...
 Enter here the list of hashtags you want to block, one
 per line, with or without the # symbol.
@@ -125,6 +141,10 @@ standard ntfy.sh server), fill the two provided
 fields (ntfy server/topic and, if protected, the token).
 You need to refer to the https://ntfy.sh web site for
 more information on this process.
+.It Notify webhook
+If this is set to an URL, an HTTP POST will be sent to it
+whenever a new notification happens (see the 'Webhook for
+notifications' section below for more information).
 .It Maximum days to keep posts
 This numeric value specifies the number of days to pass before
 posts (yours and others') will be purged. This value overrides
@@ -264,7 +284,7 @@ Requests an object and dumps it to stdout. This is a very low
 level command that is not very useful to you.
 .It Cm announce Ar basedir Ar uid Ar url
 Announces (boosts) a post via its URL.
-.It Cm note Ar basedir Ar uid Ar text Op file file ...
+.It Cm note Ar basedir Ar uid Ar text Op file file ... Op -r inReplyTo
 Enqueues a Create + Note message to all followers. If the
 .Ar text
 argument is -e, the external editor defined by the EDITOR
@@ -272,10 +292,11 @@ environment variable will be invoked to prepare a message; if
 it's - (a lonely hyphen), the post content will be read from stdin.
 The rest of command line arguments are treated as media files to be
 attached to the post. The LANG environment variable (if defined) is used
-as the post language.
-.It Cm note_unlisted Ar basedir Ar uid Ar text Op file file ...
+as the post language. An optional URL to a Fediverse post, prefixed by -r,
+can be specified for this note to be a reply to.
+.It Cm note_unlisted Ar basedir Ar uid Ar text Op file file ... Op -r inReplyTo
 Like the previous one, but creates an "unlisted" (or "quiet public") post.
-.It Cm note_mention Ar basedir Ar uid Ar text Op file file ...
+.It Cm note_mention Ar basedir Ar uid Ar text Op file file ... Op -r inReplyTo
 Like the previous one, but creates a post only for accounts mentioned
 in the post body.
 .It Cm block Ar basedir Ar instance_url
@@ -285,9 +306,9 @@ blocked without further inspection.
 .It Cm unblock Ar basedir Ar instance_url
 Unblocks a previously blocked instance.
 .It Cm verify_links Ar basedir Ar uid
-Verifies all links stored as metadata for the given user. This verification
-is done by downloading the link content and searching for a link back to
-the
+Verifies all links or account handles stored as metadata for the given user.
+This verification is done by downloading the link content and searching for
+a link back to the
 .Nm
 user url that also contains a rel="me" attribute. These links are specially
 marked as verified in the user's public timeline and also via the Mastodon API.
@@ -300,6 +321,13 @@ subdirectory inside the user directory:
 .Pa blocked_accounts.csv ,
 .Pa lists.csv , and
 .Pa following_accounts.csv .
+.It Cm export_posts Ar basedir Ar uid
+Exports all posts written by the user to the file
+.Pa outbox.json
+inside the
+.Pa export/
+subdirectory inside the user directory. The format is compatible with the
+one generated by the equivalent option in Mastodon.
 .It Cm alias Ar basedir Ar uid Ar "@account@remotehost"
 Sets an account as an alias of this one. This is a necessary step to migrate
 an account to a remote Mastodon instance (see
@@ -354,10 +382,10 @@ subdirectory of a user's directory inside the server base directory.
 Prints the name of the user created lists.
 .It Cm list_members Ar basedir Ar uid Ar name
 Prints the list of actors in the named list.
-.It Cm create_list Ar basedir Ar uid Ar name
+.It Cm list_create Ar basedir Ar uid Ar name
 Creates a new list.
-.It Cm delete_list Ar basedir Ar uid Ar name
-Deletes an existing list.
+.It Cm list_remove Ar basedir Ar uid Ar name
+Removes an existing list.
 .It Cm list_add Ar basedir Ar uid Ar name Ar account
 Adds an account (by its @name@host handle or actor URL) to a list.
 .It Cm list_del Ar basedir Ar uid Ar name Ar actor_url
@@ -408,6 +436,43 @@ You can obtain an API Token by connecting to the following URL:
 https://$SNAC_HOST/oauth/x-snac-get-token
 .Ed
 .Pp
+.Ss Webhook for notifications
+Since version 2.78, users can set the URL to a webhook that will receive
+an HTTP POST with every notification (in JSON format). This can be used to
+implement some automation whenever a new activity happens, like autorepliers,
+chatbots, interactive textual games or whatever. The
+.Pa examples/
+subdirectory contains a tiny Python program that implements an auto-follower
+for every new follow. The JSON notification object includes the following data:
+.Bl -tag -offset indent
+.It id
+a unique notification identifier
+.It actor
+the origin actor id
+.It target
+the target actor id
+.It date
+the notification date
+.It msg
+the full ActivityPub action JSON object
+.It objid
+the object identifier (extracted from msg, may be null)
+.It type
+the action type (extracted from msg)
+.It utype
+the action subtype (extracted from msg, may be null)
+.It uid
+the user identifier (account name)
+.It basedir
+the server base directory
+.It baseurl
+the server base URL
+.It account
+the origin actor object
+.It reply
+the activity this post is a reply to (may not exist)
+.El
+.Pp
 .Sh ENVIRONMENT
 .Bl -tag -width Ds
 .It SNAC_BASEDIR
@@ -422,9 +487,9 @@ Overrides the debugging level from the server 'dbglevel' configuration
 variable. Set it to an integer value. The higher, the deeper in meaningless
 verbiage you'll find yourself into.
 .It Ev EDITOR
-The user-preferred interactive text editor to prepare messages.
+The user-preferred interactive text editor to prepare notes.
 .It Ev LANG
-The language of the post when sending messages.
+The language of the post when sending notes from the command line.
 .El
 .Sh SEE ALSO
 .Xr snac 5 ,

+ 10 - 0
doc/snac.8

@@ -207,6 +207,8 @@ fields are set (see below), they are also shown.
 The email address of the instance administrator (optional).
 .It Ic admin_account
 The user name of the instance administrator (optional).
+.It Ic title
+The name of the instance (optional).
 .It Ic short_description
 A textual short description about the instance (optional).
 .It Ic short_description_raw
@@ -277,6 +279,14 @@ the usual one, like in smtp://mail.example.com:587.
 .It Ic smtp_user
 .It Ic smtp_password
 To be filled if the SMTP server defined by the previous directive needs credentials.
+.It Ic rss_hashtag_poll_hours
+The periodic number of hours hashtag RSS are polled (default: 4). It has a minimum
+value of 1 to avoid hammering servers.
+.It Ic disable_notify_webhook
+Since version 2.78, users can set a webhook URL to receive notifications. Set this
+to true if you don't want your users to have this privilege.
+.It Ic favicon_url
+The URL to a favicon. If it's not set, the default one is used instead.
 .El
 .Pp
 You must restart the server to make effective these changes.

+ 1 - 0
doc/style.css

@@ -1,5 +1,6 @@
 body { max-width: 48em; margin: auto; line-height: 1.5; padding: 0.8em; word-wrap: break-word; }
 pre { overflow-x: scroll; }
+blockquote { font-style: italic; }
 .snac-embedded-video, img { max-width: 100% }
 .snac-origin { font-size: 85% }
 .snac-score { float: right; font-size: 85% }

+ 55 - 0
examples/auto_follower_webhook.py

@@ -0,0 +1,55 @@
+#!/usr/bin/env python3
+
+# This is an example of a snac webhook that automatically follows all new followers.
+
+# To use it, configure the user webhook to be http://localhost:12345, and run this program.
+
+# copyright (C) 2025 grunfink et al. / MIT license
+
+from http.server import BaseHTTPRequestHandler, HTTPServer
+import time
+import json
+import os
+
+host_name = "localhost"
+server_port = 12345
+
+class SnacAutoResponderServer(BaseHTTPRequestHandler):
+
+    def do_POST(self):
+        self.send_response(200)
+        self.end_headers()
+
+        content_type = self.headers["content-type"]
+        content_length = int(self.headers["content-length"])
+        payload = self.rfile.read(content_length).decode("utf-8")
+
+        if content_type == "application/json":
+            try:
+                noti = json.loads(payload)
+
+                type = noti["type"]
+
+                if type == "Follow":
+                    actor = noti["actor"]
+                    uid = noti["uid"]
+                    basedir = noti["basedir"]
+
+                    cmd = "snac follow %s %s %s" % (basedir, uid, actor)
+
+                    os.system(cmd)
+
+            except:
+                print("Error parsing notification")
+
+if __name__ == "__main__":        
+    webServer = HTTPServer((host_name, server_port), SnacAutoResponderServer)
+    print("Webhook started http://%s:%s" % (host_name, server_port))
+
+    try:
+        webServer.serve_forever()
+    except KeyboardInterrupt:
+        pass
+
+    webServer.server_close()
+    print("Webhook stopped.")

+ 18 - 14
format.c

@@ -8,6 +8,7 @@
 #include "xs_json.h"
 #include "xs_time.h"
 #include "xs_match.h"
+#include "xs_unicode.h"
 
 #include "snac.h"
 
@@ -95,8 +96,8 @@ static xs_str *format_line(const char *line, xs_list **attach)
             "~~[^~]+~~"                         "|"
             "\\*\\*?\\*?[^\\*]+\\*?\\*?\\*"     "|"
             "__[^_]+__"                         "|" //anzu
-            "!\\[[^]]+\\]\\([^\\)]+\\)"         "|"
-            "\\[[^]]+\\]\\([^\\)]+\\)"          "|"
+            "!\\[[^]]+\\]\\([^\\)]+\\)\\)?"     "|"
+            "\\[[^]]+\\]\\([^\\)]+\\)\\)?"      "|"
             "[a-z]+:/" "/" NOSPACE              "|"
             "(mailto|xmpp):[^@[:space:]]+@" NOSPACE
         ")");
@@ -148,14 +149,15 @@ static xs_str *format_line(const char *line, xs_list **attach)
             else
             if (*v == '[') {
                 /* markdown-like links [label](url) */
-                xs *w = xs_strip_chars_i(
-                    xs_replace_i(xs_replace(v, "#", "&#35;"), "@", "&#64;"),
-                "![)");
+                xs *w = xs_replace_i(xs_replace(v, "#", "&#35;"), "@", "&#64;");
                 xs *l = xs_split_n(w, "](", 1);
 
                 if (xs_list_len(l) == 2) {
-                    const char *name = xs_list_get(l, 0);
-                    const char *url  = xs_list_get(l, 1);
+                    xs *name = xs_dup(xs_list_get(l, 0));
+                    xs *url  = xs_dup(xs_list_get(l, 1));
+
+                    name = xs_crop_i(name, 1, 0);
+                    url  = xs_crop_i(url, 0, -1);
 
                     xs *link = xs_fmt("<a href=\"%s\">%s</a>", url, name);
 
@@ -167,15 +169,17 @@ static xs_str *format_line(const char *line, xs_list **attach)
             else
             if (*v == '!') {
                 /* markdown-like images ![alt text](url to image) */
-                xs *w = xs_strip_chars_i(
-                    xs_replace_i(xs_replace(v, "#", "&#35;"), "@", "&#64;"),
-                "![)");
+                xs *w = xs_replace_i(xs_replace(v, "#", "&#35;"), "@", "&#64;");
                 xs *l = xs_split_n(w, "](", 1);
 
                 if (xs_list_len(l) == 2) {
-                    const char *alt_text = xs_list_get(l, 0);
-                    const char *img_url  = xs_list_get(l, 1);
-                    const char *mime     = xs_mime_by_ext(img_url);
+                    xs *alt_text = xs_dup(xs_list_get(l, 0));
+                    xs *img_url  = xs_dup(xs_list_get(l, 1));
+
+                    alt_text = xs_crop_i(alt_text, 2, 0);
+                    img_url  = xs_crop_i(img_url, 0, -1);
+
+                    const char *mime = xs_mime_by_ext(img_url);
 
                     if (attach != NULL && xs_startswith(mime, "image/")) {
                         const xs_dict *ad;
@@ -443,7 +447,7 @@ xs_str *sanitize(const char *content)
         if (n & 0x1) {
             xs *s1  = xs_strip_i(xs_crop_i(xs_dup(v), v[1] == '/' ? 2 : 1, -1));
             xs *l1  = xs_split_n(s1, " ", 1);
-            xs *tag = xs_tolower_i(xs_dup(xs_list_get(l1, 0)));
+            xs *tag = xs_utf8_to_lower(xs_list_get(l1, 0));
             xs *s2  = NULL;
             int i;
 

+ 94 - 130
html.c

@@ -636,13 +636,18 @@ static xs_html *html_base_head(void)
             xs_html_attr("content", USER_AGENT)));
 
     /* add server CSS and favicon */
-    xs *f;
-    f = xs_fmt("%s/favicon.ico", srv_baseurl);
+    xs *f = NULL;
+    const char *favicon = xs_dict_get(srv_config, "favicon_url");
+
+    if (xs_is_string(favicon))
+        f = xs_dup(favicon);
+    else
+        f = xs_fmt("%s/favicon.ico", srv_baseurl);
+
     const xs_list *p = xs_dict_get(srv_config, "cssurls");
     const char *v;
-    int c = 0;
 
-    while (xs_list_next(p, &v, &c)) {
+    xs_list_foreach(p, v) {
         xs_html_add(head,
             xs_html_sctag("link",
                 xs_html_attr("rel",  "stylesheet"),
@@ -863,6 +868,14 @@ xs_html *html_user_head(snac *user, const char *desc, const char *url)
             xs_html_attr("type", "application/activity+json"),
             xs_html_attr("href", url ? url : user->actor)));
 
+    /* webmention hook */
+    xs *wbh = xs_fmt("%s/webmention-hook", srv_baseurl);
+
+    xs_html_add(head,
+        xs_html_sctag("link",
+            xs_html_attr("rel", "webmention"),
+            xs_html_attr("href", wbh)));
+
     return head;
 }
 
@@ -1077,10 +1090,17 @@ static xs_html *html_user_body(snac *user, int read_only)
             while (xs_dict_next(metadata, &k, &v, &c)) {
                 xs_html *value;
 
-                if (xs_startswith(v, "https:/") || xs_startswith(v, "http:/")) {
+                if (xs_startswith(v, "https:/") || xs_startswith(v, "http:/") || *v == '@') {
                     /* is this link validated? */
                     xs *verified_link = NULL;
                     const xs_number *val_time = xs_dict_get(val_links, v);
+                    const char *url = NULL;
+
+                    if (xs_is_string(val_time)) {
+                        /* resolve again, as it may be an account handle */
+                        url = val_time;
+                        val_time = xs_dict_get(val_links, val_time);
+                    }
 
                     if (xs_type(val_time) == XSTYPE_NUMBER) {
                         time_t t = xs_number_get(val_time);
@@ -1098,13 +1118,13 @@ static xs_html *html_user_body(snac *user, int read_only)
                             xs_html_tag("a",
                                 xs_html_attr("rel", "me"),
                                 xs_html_attr("target", "_blank"),
-                                xs_html_attr("href", v),
+                                xs_html_attr("href", url ? url : v),
                                 xs_html_text(v)));
                     }
                     else {
                         value = xs_html_tag("a",
                             xs_html_attr("rel", "me"),
-                            xs_html_attr("href", v),
+                            xs_html_attr("href", url ? url : v),
                             xs_html_text(v));
                     }
                 }
@@ -1290,6 +1310,7 @@ xs_html *html_top_controls(snac *user)
     const xs_val *show_foll  = xs_dict_get(user->config, "show_contact_metrics");
     const char *latitude     = xs_dict_get_def(user->config, "latitude", "");
     const char *longitude    = xs_dict_get_def(user->config, "longitude", "");
+    const char *webhook      = xs_dict_get_def(user->config, "notify_webhook", "");
 
     xs *metadata = NULL;
     const xs_dict *md = xs_dict_get(user->config, "metadata");
@@ -1451,6 +1472,14 @@ xs_html *html_top_controls(snac *user)
                         xs_html_attr("name", "ntfy_token"),
                         xs_html_attr("value", ntfy_token),
                         xs_html_attr("placeholder", L("ntfy token - if needed")))),
+                xs_html_tag("p",
+                    xs_html_text(L("Notify webhook:")),
+                    xs_html_sctag("br", NULL),
+                    xs_html_sctag("input",
+                        xs_html_attr("type", "url"),
+                        xs_html_attr("name", "notify_webhook"),
+                        xs_html_attr("value", webhook),
+                        xs_html_attr("placeholder", L("http://example.com/webhook")))),
                 xs_html_tag("p",
                     xs_html_text(L("Maximum days to keep posts (0: server settings):")),
                     xs_html_sctag("br", NULL),
@@ -1601,7 +1630,8 @@ xs_html *html_top_controls(snac *user)
                     xs_html_attr("name", "followed_hashtags"),
                     xs_html_attr("cols", "40"),
                     xs_html_attr("rows", "4"),
-                    xs_html_attr("placeholder", "#cats\n#windowfriday\n#classicalmusic"),
+                    xs_html_attr("placeholder", "#cats\n#windowfriday\n#classicalmusic\nhttps:/"
+                        "/mastodon.social/tags/dogs"),
                     xs_html_text(followed_hashtags)),
 
                 xs_html_tag("br", NULL),
@@ -1978,8 +2008,13 @@ xs_html *html_entry(snac *user, xs_dict *msg, int read_only,
     }
 
     if ((user == NULL || strcmp(actor, user->actor) != 0)
-        && !valid_status(actor_get(actor, NULL)))
+        && !valid_status(actor_get(actor, NULL))) {
+
+        if (user)
+            enqueue_actor_refresh(user, actor, 0);
+
         return NULL;
+    }
 
     /** html_entry top tag **/
     xs_html *entry_top = xs_html_tag("div", NULL);
@@ -2115,9 +2150,7 @@ xs_html *html_entry(snac *user, xs_dict *msg, int read_only,
         const char *parent = get_in_reply_to(msg);
 
         if (!xs_is_null(parent) && *parent) {
-            xs *md5 = xs_md5_hex(parent, strlen(parent));
-
-            if (!timeline_here(user, md5)) {
+            if (!timeline_here(user, parent)) {
                 xs_html_add(post_header,
                     xs_html_tag("div",
                         xs_html_attr("class", "snac-origin"),
@@ -2419,6 +2452,9 @@ xs_html *html_entry(snac *user, xs_dict *msg, int read_only,
             const char *o_href = xs_dict_get(a, "href");
             const char *name = xs_dict_get(a, "name");
 
+            if (!xs_is_string(type) || !xs_is_string(o_href))
+                continue;
+
             /* if this URL is already in the post content, skip */
             if (content && xs_str_in(content, o_href) != -1)
                 continue;
@@ -3284,12 +3320,12 @@ xs_str *html_people(snac *user)
 
     xs *wing = following_list(user);
     xs *wers = follower_list(user);
+    xs *pending = pending_list(user);
 
     xs_html *lists = xs_html_tag("div",
         xs_html_attr("class", "snac-posts"));
 
-    if (xs_is_true(xs_dict_get(user->config, "approve_followers"))) {
-        xs *pending = pending_list(user);
+    if (xs_list_len(pending) || xs_is_true(xs_dict_get(user->config, "approve_followers"))) {
         xs_html_add(lists,
             html_people_list(user, pending, L("Pending follow confirmations"), "p", proxy));
     }
@@ -3384,13 +3420,15 @@ xs_str *html_notifications(snac *user, int skip, int show)
 
         const char *actor_id = xs_dict_get(noti, "actor");
         xs *actor = NULL;
+        xs *a_name = NULL;
 
-        if (!valid_status(actor_get(actor_id, &actor)))
-            continue;
+        if (valid_status(actor_get(actor_id, &actor)))
+            a_name = actor_name(actor, proxy);
+        else
+            a_name = xs_dup(actor_id);
 
-        xs *a_name = actor_name(actor, proxy);
-        xs *label_sanatized = sanitize(type);
-        const char *label = label_sanatized;
+        xs *label_sanitized = sanitize(type);
+        const char *label = label_sanitized;
 
         if (strcmp(type, "Create") == 0)
             label = L("Mention");
@@ -3462,10 +3500,11 @@ xs_str *html_notifications(snac *user, int skip, int show)
             html_label);
 
         if (strcmp(type, "Follow") == 0 || strcmp(utype, "Follow") == 0 || strcmp(type, "Block") == 0) {
-            xs_html_add(entry,
-                xs_html_tag("div",
-                    xs_html_attr("class", "snac-post"),
-                    html_actor_icon(user, actor, NULL, NULL, NULL, 0, 0, proxy, NULL, NULL)));
+            if (actor)
+                xs_html_add(entry,
+                    xs_html_tag("div",
+                        xs_html_attr("class", "snac-post"),
+                        html_actor_icon(user, actor, NULL, NULL, NULL, 0, 0, proxy, NULL, NULL)));
         }
         else
         if (strcmp(type, "Move") == 0) {
@@ -3498,6 +3537,13 @@ xs_str *html_notifications(snac *user, int skip, int show)
                             xs_html_text(L("Context")))),
                     h);
             }
+            else
+                xs_html_add(entry,
+                    xs_html_tag("p",
+                        xs_html_text(L("Location: ")),
+                        xs_html_tag("a",
+                            xs_html_attr("href", id),
+                            xs_html_text(id))));
         }
 
         if (strcmp(v, n_time) > 0) {
@@ -3748,7 +3794,7 @@ int html_get_handler(const xs_dict *req, const char *q_path,
                 /* may by an actor; try a webfinger */
                 xs *actor_obj = NULL;
 
-                if (valid_status(webfinger_request(q, &actor_obj, &url_acct))) {
+                if (valid_status(webfinger_request(q, &actor_obj, &url_acct)) && xs_is_string(url_acct)) {
                     /* it's an actor; do the dirty trick of changing q to the account name */
                     q = url_acct;
                 }
@@ -3773,12 +3819,18 @@ int html_get_handler(const xs_dict *req, const char *q_path,
                                 q = url_acct;
 
                                 /* add the post to the timeline */
-                                xs *md5 = xs_md5_hex(q, strlen(q));
-
-                                if (!timeline_here(&snac, md5))
+                                if (!timeline_here(&snac, q))
                                     timeline_add(&snac, q, object);
                             }
                         }
+                        else {
+                            /* retry webfinger, this time with the 'official' id */
+                            const char *id = xs_dict_get(object, "id");
+
+                            if (xs_is_string(id) && valid_status(webfinger_request(id, &actor_obj, &url_acct)) &&
+                                xs_is_string(url_acct))
+                                q = url_acct;
+                        }
                     }
                 }
 
@@ -3917,7 +3969,7 @@ int html_get_handler(const xs_dict *req, const char *q_path,
             xs *l = xs_split(p_path, "/");
             const char *md5 = xs_list_get(l, -1);
 
-            if (md5 && *md5 && timeline_here(&snac, md5)) {
+            if (md5 && *md5 && timeline_here_by_md5(&snac, md5)) {
                 xs *list0 = xs_list_append(xs_list_new(), md5);
                 xs *list  = timeline_top_level(&snac, list0);
 
@@ -4127,7 +4179,7 @@ int html_get_handler(const xs_dict *req, const char *q_path,
             xs_dict_get(srv_config, "host"));
         xs *rss_link = xs_fmt("%s.rss", snac.actor);
 
-        *body   = timeline_to_rss(&snac, elems, rss_title, rss_link, bio);
+        *body   = rss_from_timeline(&snac, elems, rss_title, rss_link, bio);
         *b_size = strlen(*body);
         *ctype  = "application/rss+xml; charset=utf-8";
         status  = HTTP_STATUS_OK;
@@ -4365,7 +4417,7 @@ int html_post_handler(const xs_dict *req, const char *q_path,
                     xs_rnd_buf(rnd, sizeof(rnd));
 
                     const char *ext = strrchr(fn, '.');
-                    xs *hash  = xs_md5_hex(rnd, strlen(rnd));
+                    xs *hash  = xs_md5_hex(rnd, sizeof(rnd));
                     xs *id    = xs_fmt("post-%s%s", hash, ext ? ext : "");
                     xs *url   = xs_fmt("%s/s/%s", snac.actor, id);
                     int fo    = xs_number_get(xs_list_get(attach_file, 1));
@@ -4825,6 +4877,8 @@ int html_post_handler(const xs_dict *req, const char *q_path,
         snac.config = xs_dict_set(snac.config, "latitude", xs_dict_get_def(p_vars, "latitude", ""));
         snac.config = xs_dict_set(snac.config, "longitude", xs_dict_get_def(p_vars, "longitude", ""));
 
+        snac.config = xs_dict_set(snac.config, "notify_webhook", xs_dict_get_def(p_vars, "notify_webhook", ""));
+
         if ((v = xs_dict_get(p_vars, "metadata")) != NULL)
             snac.config = xs_dict_set(snac.config, "metadata", v);
 
@@ -4960,9 +5014,16 @@ int html_post_handler(const xs_dict *req, const char *q_path,
                 if (*s1 == '\0')
                     continue;
 
-                xs *s2 = xs_utf8_to_lower(s1);
-                if (*s2 != '#')
-                    s2 = xs_str_prepend_i(s2, "#");
+                xs *s2 = NULL;
+
+                if (xs_startswith(s1, "https:/"))
+                    s2 = xs_dup(s1);
+                else {
+                    s2 = xs_utf8_to_lower(s1);
+
+                    if (*s2 != '#')
+                        s2 = xs_str_prepend_i(s2, "#");
+                }
 
                 new_hashtags = xs_list_append(new_hashtags, s2);
             }
@@ -5024,100 +5085,3 @@ int html_post_handler(const xs_dict *req, const char *q_path,
 
     return status;
 }
-
-
-xs_str *timeline_to_rss(snac *user, const xs_list *timeline,
-                        const char *title, const char *link, const char *desc)
-/* converts a timeline to rss */
-{
-    xs_html *rss = xs_html_tag("rss",
-        xs_html_attr("xmlns:content", "http:/" "/purl.org/rss/1.0/modules/content/"),
-        xs_html_attr("version",       "2.0"),
-        xs_html_attr("xmlns:atom",    "http:/" "/www.w3.org/2005/Atom"));
-
-    xs_html *channel = xs_html_tag("channel",
-        xs_html_tag("title",
-            xs_html_text(title)),
-        xs_html_tag("language",
-            xs_html_text("en")),
-        xs_html_tag("link",
-            xs_html_text(link)),
-        xs_html_sctag("atom:link",
-            xs_html_attr("href", link),
-            xs_html_attr("rel", "self"),
-            xs_html_attr("type", "application/rss+xml")),
-        xs_html_tag("generator",
-            xs_html_text(USER_AGENT)),
-        xs_html_tag("description",
-            xs_html_text(desc)));
-
-    xs_html_add(rss, channel);
-
-    int cnt = 0;
-    const char *v;
-
-    xs_list_foreach(timeline, v) {
-        xs *msg = NULL;
-
-        if (user) {
-            if (!valid_status(timeline_get_by_md5(user, v, &msg)))
-                continue;
-        }
-        else {
-            if (!valid_status(object_get_by_md5(v, &msg)))
-                continue;
-        }
-
-        const char *id = xs_dict_get(msg, "id");
-        const char *content = xs_dict_get(msg, "content");
-        const char *published = xs_dict_get(msg, "published");
-
-        if (user && !xs_startswith(id, user->actor))
-            continue;
-
-        if (!id || !content || !published)
-            continue;
-
-        /* create a title with the first line of the content */
-        xs *title = xs_replace(content, "<br>", "\n");
-        title = xs_regex_replace_i(title, "<[^>]+>", " ");
-        title = xs_regex_replace_i(title, "&[^;]+;", " ");
-        int i;
-
-        for (i = 0; title[i] && title[i] != '\n' && i < 50; i++);
-
-        if (title[i] != '\0') {
-            title[i] = '\0';
-            title = xs_str_cat(title, "...");
-        }
-
-        title = xs_strip_i(title);
-
-        /* convert the date */
-        time_t t = xs_parse_iso_date(published, 0);
-        xs *rss_date = xs_str_utctime(t, "%a, %d %b %Y %T +0000");
-
-        /* if it's the first one, add it to the header */
-        if (cnt == 0)
-            xs_html_add(channel,
-                xs_html_tag("lastBuildDate",
-                    xs_html_text(rss_date)));
-
-        xs_html_add(channel,
-            xs_html_tag("item",
-                xs_html_tag("title",
-                    xs_html_text(title)),
-                xs_html_tag("link",
-                    xs_html_text(id)),
-                xs_html_tag("guid",
-                    xs_html_text(id)),
-                xs_html_tag("pubDate",
-                    xs_html_text(rss_date)),
-                xs_html_tag("description",
-                    xs_html_text(content))));
-
-        cnt++;
-    }
-
-    return xs_html_render_s(rss, "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
-}

+ 106 - 14
httpd.c

@@ -12,6 +12,7 @@
 #include "xs_openssl.h"
 #include "xs_fcgi.h"
 #include "xs_html.h"
+#include "xs_webmention.h"
 
 #include "snac.h"
 
@@ -65,7 +66,9 @@ const char *nodeinfo_2_0_template = ""
     "\"services\":{\"outbound\":[],\"inbound\":[]},"
     "\"usage\":{\"users\":{\"total\":%d,\"activeMonth\":%d,\"activeHalfyear\":%d},"
     "\"localPosts\":%d},"
-    "\"openRegistrations\":false,\"metadata\":{}}";
+    "\"openRegistrations\":false,\"metadata\":{"
+    "\"nodeDescription\":\"%s\",\"nodeName\":\"%s\""
+    "}}";
 
 xs_str *nodeinfo_2_0(void)
 /* builds a nodeinfo json object */
@@ -98,7 +101,10 @@ xs_str *nodeinfo_2_0(void)
         n_posts += index_len(pidxfn);
     }
 
-    return xs_fmt(nodeinfo_2_0_template, n_utotal, n_umonth, n_uhyear, n_posts);
+    const char *name = xs_dict_get_def(srv_config, "title", "");
+    const char *desc = xs_dict_get_def(srv_config, "short_description", "");
+
+    return xs_fmt(nodeinfo_2_0_template, n_utotal, n_umonth, n_uhyear, n_posts, desc, name);
 }
 
 
@@ -244,7 +250,7 @@ int server_get_handler(xs_dict *req, const char *q_path,
             if (!xs_is_null(accept) && strcmp(accept, "application/rss+xml") == 0) {
                 xs *link = xs_fmt("%s/?t=%s", srv_baseurl, t);
 
-                *body = timeline_to_rss(NULL, tl, link, link, link);
+                *body = rss_from_timeline(NULL, tl, link, link, link);
                 *ctype = "application/rss+xml; charset=utf-8";
             }
             else {
@@ -373,6 +379,68 @@ int server_get_handler(xs_dict *req, const char *q_path,
 }
 
 
+int server_post_handler(const xs_dict *req, const char *q_path,
+                      char *payload, int p_size,
+                      char **body, int *b_size, char **ctype)
+{
+    int status = 0;
+
+    (void)payload;
+    (void)p_size;
+    (void)body;
+    (void)b_size;
+    (void)ctype;
+
+    if (strcmp(q_path, "/webmention-hook") == 0) {
+        status = HTTP_STATUS_BAD_REQUEST;
+
+        const xs_dict *p_vars = xs_dict_get(req, "p_vars");
+
+        if (!xs_is_dict(p_vars))
+            return status;
+
+        const char *source = xs_dict_get(p_vars, "source");
+        const char *target = xs_dict_get(p_vars, "target");
+
+        if (!xs_is_string(source) || !xs_is_string(target)) {
+            srv_debug(1, xs_fmt("webmention-hook bad source or target"));
+            return status;
+        }
+
+        if (!xs_startswith(target, srv_baseurl)) {
+            srv_debug(1, xs_fmt("webmention-hook unknown target %s", target));
+            return status;
+        }
+
+        /* get the user */
+        xs *s1 = xs_replace(target, srv_baseurl, "");
+
+        xs *l1 = xs_split(s1, "/");
+        const char *uid = xs_list_get(l1, 1);
+        snac user;
+
+        if (!xs_is_string(uid) || !user_open(&user, uid)) {
+            srv_debug(1, xs_fmt("webmention-hook cannot find user for %s", target));
+            return status;
+        }
+
+        int r = xs_webmention_hook(source, target, USER_AGENT);
+
+        if (r > 0) {
+            notify_add(&user, "Webmention", NULL, source, target, xs_stock(XSTYPE_DICT));
+            timeline_touch(&user);
+        }
+
+        srv_log(xs_fmt("webmention-hook source=%s target=%s %d", source, target, r));
+
+        user_free(&user);
+        status = HTTP_STATUS_OK;
+    }
+
+    return status;
+}
+
+
 void httpd_connection(FILE *f)
 /* the connection processor */
 {
@@ -444,6 +512,10 @@ void httpd_connection(FILE *f)
     else
     if (strcmp(method, "POST") == 0) {
 
+        if (status == 0)
+            status = server_post_handler(req, q_path,
+                        payload, p_size, &body, &b_size, &ctype);
+
 #ifndef NO_MASTODON_API
         if (status == 0)
             status = oauth_post_handler(req, q_path,
@@ -705,34 +777,36 @@ static pthread_cond_t  sleep_cond;
 static void *background_thread(void *arg)
 /* background thread (queue management and other things) */
 {
-    time_t purge_time;
+    time_t t, purge_time, rss_time;
 
     (void)arg;
 
+    t = time(NULL);
+
     /* first purge time */
-    purge_time = time(NULL) + 10 * 60;
+    purge_time = t + 10 * 60;
+
+    /* first RSS polling time */
+    rss_time = t + 15 * 60;
 
     srv_log(xs_fmt("background thread started"));
 
     while (p_state->srv_running) {
-        time_t t;
         int cnt = 0;
 
         p_state->th_state[0] = THST_QUEUE;
 
         {
             xs *list = user_list();
-            char *p;
             const char *uid;
 
             /* process queues for all users */
-            p = list;
-            while (xs_list_iter(&p, &uid)) {
-                snac snac;
+            xs_list_foreach(list, uid) {
+                snac user;
 
-                if (user_open(&snac, uid)) {
-                    cnt += process_user_queue(&snac);
-                    user_free(&snac);
+                if (user_open(&user, uid)) {
+                    cnt += process_user_queue(&user);
+                    user_free(&user);
                 }
             }
         }
@@ -740,8 +814,10 @@ static void *background_thread(void *arg)
         /* global queue */
         cnt += process_queue();
 
+        t = time(NULL);
+
         /* time to purge? */
-        if ((t = time(NULL)) > purge_time) {
+        if (t > purge_time) {
             /* next purge time is tomorrow */
             purge_time = t + 24 * 60 * 60;
 
@@ -750,6 +826,22 @@ static void *background_thread(void *arg)
             job_post(q_item, 0);
         }
 
+        /* time to poll the RSS? */
+        if (t > rss_time) {
+            /* next RSS poll time */
+            int hours = xs_number_get(xs_dict_get_def(srv_config, "rss_hashtag_poll_hours", "4"));
+
+            /* don't hammer servers too much */
+            if (hours < 1)
+                hours = 1;
+
+            rss_time = t + 60 * 60 * hours;
+
+            xs *q_item = xs_dict_new();
+            q_item = xs_dict_append(q_item, "type", "rss_hashtag_poll");
+            job_post(q_item, 0);
+        }
+
         if (cnt == 0) {
             /* sleep 3 seconds */
 

+ 108 - 58
main.c

@@ -7,64 +7,90 @@
 #include "xs_time.h"
 #include "xs_openssl.h"
 #include "xs_match.h"
+#include "xs_random.h"
 
 #include "snac.h"
 
 #include <sys/stat.h>
 #include <sys/wait.h>
 
-int usage(void)
+int usage(const char *cmd)
 {
     printf("snac " VERSION " - A simple, minimalistic ActivityPub instance\n");
     printf("Copyright (c) 2022 - 2025 grunfink et al. / MIT license\n");
     printf("\n");
-    printf("Commands:\n");
-    printf("\n");
-    printf("init [{basedir}]                     Initializes the data storage\n");
-    printf("upgrade {basedir}                    Upgrade to a new version\n");
-    printf("adduser {basedir} [{uid}]            Adds a new user\n");
-    printf("deluser {basedir} {uid}              Deletes a user\n");
-    printf("httpd {basedir}                      Starts the HTTPD daemon\n");
-    printf("purge {basedir}                      Purges old data\n");
-    printf("state {basedir}                      Prints server state\n");
-    printf("webfinger {basedir} {account}        Queries about an account (@user@host or actor url)\n");
-    printf("queue {basedir} {uid}                Processes a user queue\n");
-    printf("follow {basedir} {uid} {actor}       Follows an actor\n");
-    printf("unfollow {basedir} {uid} {actor}     Unfollows an actor\n");
-    printf("request {basedir} {uid} {url}        Requests an object\n");
-    printf("insert {basedir} {uid} {url}         Requests an object and inserts it into the timeline\n");
-    printf("actor {basedir} [{uid}] {url}        Requests an actor\n");
-    printf("note {basedir} {uid} {text} [files...] Sends a note with optional attachments\n");
-    printf("note_unlisted {basedir} {uid} {text} [files...] Sends an unlisted note with optional attachments\n");
-    printf("note_mention {basedir} {uid} {text} [files...] Sends a note only to mentioned accounts\n");
-    printf("boost|announce {basedir} {uid} {url} Boosts (announces) a post\n");
-    printf("unboost {basedir} {uid} {url}        Unboosts a post\n");
-    printf("resetpwd {basedir} {uid}             Resets the password of a user\n");
-    printf("ping {basedir} {uid} {actor}         Pings an actor\n");
-    printf("webfinger_s {basedir} {uid} {account} Queries about an account (@user@host or actor url)\n");
-    printf("pin {basedir} {uid} {msg_url}        Pins a message\n");
-    printf("unpin {basedir} {uid} {msg_url}      Unpins a message\n");
-    printf("bookmark {basedir} {uid} {msg_url}   Bookmarks a message\n");
-    printf("unbookmark {basedir} {uid} {msg_url} Unbookmarks a message\n");
-    printf("block {basedir} {instance_url}       Blocks a full instance\n");
-    printf("unblock {basedir} {instance_url}     Unblocks a full instance\n");
-    printf("limit {basedir} {uid} {actor}        Limits an actor (drops their announces)\n");
-    printf("unlimit {basedir} {uid} {actor}      Unlimits an actor\n");
-    printf("unmute {basedir} {uid} {actor}       Unmutes a previously muted actor\n");
-    printf("verify_links {basedir} {uid}         Verifies a user's links (in the metadata)\n");
-    printf("search {basedir} {uid} {regex}       Searches posts by content\n");
-    printf("export_csv {basedir} {uid}           Exports data as CSV files\n");
-    printf("alias {basedir} {uid} {account}      Sets account (@user@host or actor url) as an alias\n");
-    printf("migrate {basedir} {uid}              Migrates to the account defined as the alias\n");
-    printf("import_csv {basedir} {uid}           Imports data from CSV files\n");
-    printf("import_list {basedir} {uid} {file}   Imports a Mastodon CSV list file\n");
-    printf("import_block_list {basedir} {uid} {file} Imports a Mastodon CSV block list file\n");
-    printf("lists {basedir} {uid}                Returns the names of the lists created by the user\n");
-    printf("list_members {basedir} {uid} {name}  Returns the list of accounts inside a list\n");
-    printf("create_list {basedir} {uid} {name}   Creates a new list\n");
-    printf("delete_list {basedir} {uid} {name}   Deletes an existing list\n");
-    printf("list_add {basedir} {uid} {name} {acct} Adds an account (@user@host or actor url) to a list\n");
-    printf("list_del {basedir} {uid} {name} {actor} Deletes an actor URL from a list\n");
+
+    if (cmd == NULL) {
+        printf("Commands:\n");
+        printf("\n");
+    }
+
+    const char *cmds =
+        "init [{basedir}]                     Initializes the data storage\n"
+        "upgrade {basedir}                    Upgrade to a new version\n"
+        "adduser {basedir} [{uid}]            Adds a new user\n"
+        "deluser {basedir} {uid}              Deletes a user\n"
+        "httpd {basedir}                      Starts the HTTPD daemon\n"
+        "purge {basedir}                      Purges old data\n"
+        "state {basedir}                      Prints server state\n"
+        "webfinger {basedir} {account}        Queries about an account (@user@host or actor url)\n"
+        "queue {basedir} {uid}                Processes a user queue\n"
+        "follow {basedir} {uid} {actor}       Follows an actor\n"
+        "unfollow {basedir} {uid} {actor}     Unfollows an actor\n"
+        "request {basedir} {uid} {url}        Requests an object\n"
+        "insert {basedir} {uid} {url}         Requests an object and inserts it into the timeline\n"
+        "actor {basedir} [{uid}] {url}        Requests an actor\n"
+        "note {basedir} {uid} {text} [files...] Sends a note with optional attachments\n"
+        "note_unlisted {basedir} {uid} {text} [files...] Sends an unlisted note with optional attachments\n"
+        "note_mention {basedir} {uid} {text} [files...] Sends a note only to mentioned accounts\n"
+        "boost|announce {basedir} {uid} {url} Boosts (announces) a post\n"
+        "unboost {basedir} {uid} {url}        Unboosts a post\n"
+        "resetpwd {basedir} {uid}             Resets the password of a user\n"
+        "ping {basedir} {uid} {actor}         Pings an actor\n"
+        "webfinger_s {basedir} {uid} {account} Queries about an account (@user@host or actor url)\n"
+        "pin {basedir} {uid} {msg_url}        Pins a message\n"
+        "unpin {basedir} {uid} {msg_url}      Unpins a message\n"
+        "bookmark {basedir} {uid} {msg_url}   Bookmarks a message\n"
+        "unbookmark {basedir} {uid} {msg_url} Unbookmarks a message\n"
+        "block {basedir} {instance_url}       Blocks a full instance\n"
+        "unblock {basedir} {instance_url}     Unblocks a full instance\n"
+        "limit {basedir} {uid} {actor}        Limits an actor (drops their announces)\n"
+        "unlimit {basedir} {uid} {actor}      Unlimits an actor\n"
+        "unmute {basedir} {uid} {actor}       Unmutes a previously muted actor\n"
+        "verify_links {basedir} {uid}         Verifies a user's links (in the metadata)\n"
+        "search {basedir} {uid} {regex}       Searches posts by content\n"
+        "export_csv {basedir} {uid}           Exports followers, lists, MUTEd and bookmarks to CSV\n"
+        "export_posts {basedir} {iod}         Exports all posts to outbox.json\n"
+        "alias {basedir} {uid} {account}      Sets account (@user@host or actor url) as an alias\n"
+        "migrate {basedir} {uid}              Migrates to the account defined as the alias\n"
+        "import_csv {basedir} {uid}           Imports data from CSV files\n"
+        "import_list {basedir} {uid} {file}   Imports a Mastodon CSV list file\n"
+        "import_block_list {basedir} {uid} {file} Imports a Mastodon CSV block list file\n"
+        "lists {basedir} {uid}                Returns the names of the lists created by the user\n"
+        "list_members {basedir} {uid} {name}  Returns the list of accounts inside a list\n"
+        "list_create {basedir} {uid} {name}   Creates a new list\n"
+        "list_remove {basedir} {uid} {name}   Removes an existing list\n"
+        "list_add {basedir} {uid} {name} {acct} Adds an account (@user@host or actor url) to a list\n"
+        "list_del {basedir} {uid} {name} {actor} Deletes an actor URL from a list\n";
+
+    if (cmd == NULL)
+        printf("%s", cmds);
+    else {
+        /* only show help for the entered command */
+        xs *l = xs_split(cmds, "\n");
+        const char *v;
+        int cnt = 0;
+
+        xs_list_foreach(l, v) {
+            if (xs_str_in(v, cmd) != -1) {
+                printf("%s\n", v);
+                cnt++;
+            }
+        }
+
+        if (cnt == 0)
+            printf("%s", cmds);
+    }
 
     return 1;
 }
@@ -94,7 +120,7 @@ int main(int argc, char *argv[])
     umask(0007);
 
     if ((cmd = GET_ARGV()) == NULL)
-        return usage();
+        return usage(cmd);
 
     if (strcmp(cmd, "init") == 0) { /** **/
         /* initialize the data storage */
@@ -106,7 +132,7 @@ int main(int argc, char *argv[])
 
     if ((basedir = getenv("SNAC_BASEDIR")) == NULL) {
         if ((basedir = GET_ARGV()) == NULL)
-            return usage();
+            return usage(cmd);
     }
 
     if (strcmp(cmd, "upgrade") == 0) { /** **/
@@ -143,6 +169,11 @@ int main(int argc, char *argv[])
         return 0;
     }
 
+    if (strcmp(cmd, "poll_hashtag_rss") == 0) { /** **/
+        rss_poll_hashtags();
+        return 0;
+    }
+
     if (strcmp(cmd, "state") == 0) { /** **/
         xs *shm_name = NULL;
         srv_state *p_state = srv_state_op(&shm_name, 1);
@@ -167,7 +198,7 @@ int main(int argc, char *argv[])
     }
 
     if ((user = GET_ARGV()) == NULL)
-        return usage();
+        return usage(cmd);
 
     if (strcmp(cmd, "block") == 0) { /** **/
         int ret = instance_block(user);
@@ -279,6 +310,11 @@ int main(int argc, char *argv[])
         return 0;
     }
 
+    if (strcmp(cmd, "export_posts") == 0) { /** **/
+        export_posts(&snac);
+        return 0;
+    }
+
     if (strcmp(cmd, "import_csv") == 0) { /** **/
         import_csv(&snac);
         return 0;
@@ -300,7 +336,7 @@ int main(int argc, char *argv[])
     }
 
     if ((url = GET_ARGV()) == NULL)
-        return usage();
+        return usage(cmd);
 
     if (strcmp(cmd, "list_members") == 0) { /** **/
         xs *lid = list_maint(&snac, url, 4);
@@ -323,7 +359,7 @@ int main(int argc, char *argv[])
         return 0;
     }
 
-    if (strcmp(cmd, "create_list") == 0) { /** **/
+    if (strcmp(cmd, "list_create") == 0) { /** **/
         xs *lid = list_maint(&snac, url, 4);
 
         if (lid == NULL) {
@@ -336,7 +372,7 @@ int main(int argc, char *argv[])
         return 0;
     }
 
-    if (strcmp(cmd, "delete_list") == 0) { /** **/
+    if (strcmp(cmd, "list_remove") == 0) { /** **/
         xs *lid = list_maint(&snac, url, 4);
 
         if (lid != NULL) {
@@ -744,12 +780,24 @@ int main(int argc, char *argv[])
         xs *msg = NULL;
         xs *c_msg = NULL;
         xs *attl = xs_list_new();
-        char *fn = NULL;
+        const char *fn = NULL;
+        const char *in_reply_to = NULL;
+        const char **next = NULL;
 
         /* iterate possible attachments */
         while ((fn = GET_ARGV())) {
             FILE *f;
 
+            if (next) {
+                *next = fn;
+                next = NULL;
+            }
+            else
+            if (strcmp(fn, "-r") == 0) {
+                /* next argument is an inReplyTo */
+                next = &in_reply_to;
+            }
+            else
             if ((f = fopen(fn, "rb")) != NULL) {
                 /* get the file size and content */
                 fseek(f, 0, SEEK_END);
@@ -759,8 +807,10 @@ int main(int argc, char *argv[])
                 fclose(f);
 
                 char *ext = strrchr(fn, '.');
-                xs *hash  = xs_md5_hex(fn, strlen(fn));
-                xs *id    = xs_fmt("%s%s", hash, ext);
+                char rnd[32];
+                xs_rnd_buf(rnd, sizeof(rnd));
+                xs *hash  = xs_md5_hex(rnd, sizeof(rnd));
+                xs *id    = xs_fmt("post-%s%s", hash, ext ? ext : "");
                 xs *url   = xs_fmt("%s/s/%s", snac.actor, id);
 
                 /* store */
@@ -821,7 +871,7 @@ int main(int argc, char *argv[])
         if (strcmp(cmd, "note_unlisted") == 0)
             scope = 2;
 
-        msg = msg_note(&snac, content, NULL, NULL, attl, scope, getenv("LANG"), NULL);
+        msg = msg_note(&snac, content, NULL, in_reply_to, attl, scope, getenv("LANG"), NULL);
 
         c_msg = msg_create(&snac, msg);
 

+ 128 - 19
mastoapi.c

@@ -15,6 +15,7 @@
 #include "xs_url.h"
 #include "xs_mime.h"
 #include "xs_match.h"
+#include "xs_unicode.h"
 
 #include "snac.h"
 
@@ -381,7 +382,7 @@ int oauth_post_handler(const xs_dict *req, const char *q_path,
             }
         }
 
-        /* no code? 
+        /* no code?
            I'm not sure of the impacts of this right now, but Subway Tooter does not
            provide a code so one must be generated */
         if (xs_is_null(code)){
@@ -1133,9 +1134,14 @@ xs_dict *mastoapi_status(snac *snac, const xs_dict *msg)
             bst = xs_dict_set(bst, "content", "");
             bst = xs_dict_set(bst, "reblog", st);
 
+            xs *b_id = xs_toupper_i(xs_dup(xs_dict_get(st, "id")));
+            bst = xs_dict_set(bst, "id", b_id);
+
             xs_free(st);
             st = bst;
         }
+        else
+            xs_free(bst);
     }
 
     return st;
@@ -1338,9 +1344,9 @@ xs_list *mastoapi_timeline(snac *user, const xs_dict *args, const char *index_fn
     if ((f = fopen(index_fn, "r")) == NULL)
         return out;
 
-    const char *max_id   = xs_dict_get(args, "max_id");
-    const char *since_id = xs_dict_get(args, "since_id");
-    const char *min_id   = xs_dict_get(args, "min_id"); /* unsupported old-to-new navigation */
+    const char *o_max_id   = xs_dict_get(args, "max_id");
+    const char *o_since_id = xs_dict_get(args, "since_id");
+    const char *o_min_id   = xs_dict_get(args, "min_id"); /* unsupported old-to-new navigation */
     const char *limit_s  = xs_dict_get(args, "limit");
     int (*iterator)(FILE *, char *);
     int initial_status = 0;
@@ -1348,6 +1354,10 @@ xs_list *mastoapi_timeline(snac *user, const xs_dict *args, const char *index_fn
     int limit = 0;
     int cnt   = 0;
 
+    xs *max_id   = o_max_id ? xs_tolower_i(xs_dup(o_max_id)) : NULL;
+    xs *since_id = o_since_id ? xs_tolower_i(xs_dup(o_since_id)) : NULL;
+    xs *min_id   = o_min_id ? xs_tolower_i(xs_dup(o_min_id)) : NULL;
+
     if (!xs_is_null(limit_s))
         limit = atoi(limit_s);
 
@@ -1371,7 +1381,7 @@ xs_list *mastoapi_timeline(snac *user, const xs_dict *args, const char *index_fn
             /* only return entries older that max_id */
             if (max_id) {
                 if (strcmp(md5, MID_TO_MD5(max_id)) == 0) {
-                    max_id = NULL;
+                    max_id = xs_free(max_id);
                     if (ascending)
                         break;
                 }
@@ -1384,7 +1394,7 @@ xs_list *mastoapi_timeline(snac *user, const xs_dict *args, const char *index_fn
                 if (strcmp(md5, MID_TO_MD5(since_id)) == 0) {
                     if (!ascending)
                         break;
-                    since_id = NULL;
+                    since_id = xs_free(since_id);
                 }
                 if (ascending)
                     continue;
@@ -1637,7 +1647,7 @@ int mastoapi_get_handler(const xs_dict *req, const char *q_path,
                 const char *aq = xs_dict_get(args, "q");
 
                 if (!xs_is_null(aq)) {
-                    xs *q    = xs_tolower_i(xs_dup(aq));
+                    xs *q    = xs_utf8_to_lower(aq);
                     out      = xs_list_new();
                     xs *wing = following_list(&snac1);
                     xs *wers = follower_list(&snac1);
@@ -1780,7 +1790,7 @@ int mastoapi_get_handler(const xs_dict *req, const char *q_path,
                     }
                     else
                     if (strcmp(opt, "statuses") == 0) {
-                        /* we don't serve statuses of others; return the empty list */ 
+                        /* we don't serve statuses of others; return the empty list */
                         out = xs_list_new();
                     }
                     else
@@ -1999,7 +2009,7 @@ int mastoapi_get_handler(const xs_dict *req, const char *q_path,
     }
     else
     if (strcmp(cmd, "/v2/filters") == 0) { /** **/
-        /* snac will never have filters 
+        /* snac will never have filters
          * but still, without a v2 endpoint a short delay is introduced
          * in some apps */
         *body  = xs_dup("[]");
@@ -2331,19 +2341,36 @@ int mastoapi_get_handler(const xs_dict *req, const char *q_path,
         status = HTTP_STATUS_OK;
     }
     else
+    if (strcmp(cmd, "/v1/instance/extended_description") == 0) { /** **/
+        xs *d = xs_dict_new();
+        xs *greeting = xs_fmt("%s/greeting.html", srv_basedir);
+        time_t t = mtime(greeting);
+        xs *updated_at = xs_str_iso_date(t);
+        xs *content = xs_replace(snac_blurb, "%host%", xs_dict_get(srv_config, "host"));
+
+        d = xs_dict_set(d, "updated_at", updated_at);
+        d = xs_dict_set(d, "content", content);
+
+        *body  = xs_json_dumps(d, 4);
+        *ctype = "application/json";
+        status = HTTP_STATUS_OK;
+    }
+    else
     if (xs_startswith(cmd, "/v1/statuses/")) { /** **/
         /* information about a status */
         if (logged_in) {
             xs *l = xs_split(cmd, "/");
-            const char *id = xs_list_get(l, 3);
+            const char *oid = xs_list_get(l, 3);
             const char *op = xs_list_get(l, 4);
 
-            if (!xs_is_null(id)) {
+            if (!xs_is_null(oid)) {
                 xs *msg = NULL;
                 xs *out = NULL;
 
                 /* skip the 'fake' part of the id */
-                id = MID_TO_MD5(id);
+                oid = MID_TO_MD5(oid);
+
+                xs *id = xs_tolower_i(xs_dup(oid));
 
                 if (valid_status(object_get_by_md5(id, &msg))) {
                     if (op == NULL) {
@@ -2459,7 +2486,7 @@ int mastoapi_get_handler(const xs_dict *req, const char *q_path,
         if (logged_in) {
             const xs_list *timeline = xs_dict_get(args, "timeline[]");
             xs_str *json = NULL;
-            if (!xs_is_null(timeline)) 
+            if (!xs_is_null(timeline))
                 json = xs_json_dumps(markers_get(&snac1, timeline), 4);
 
             if (!xs_is_null(json))
@@ -2475,6 +2502,40 @@ int mastoapi_get_handler(const xs_dict *req, const char *q_path,
     }
     else
     if (strcmp(cmd, "/v1/followed_tags") == 0) { /** **/
+        if (logged_in) {
+            xs *r = xs_list_new();
+            const xs_list *followed_hashtags = xs_dict_get_def(snac1.config,
+                        "followed_hashtags", xs_stock(XSTYPE_LIST));
+            const char *hashtag;
+
+            xs_list_foreach(followed_hashtags, hashtag) {
+                if (*hashtag == '#') {
+                    xs *d = xs_dict_new();
+                    xs *s = xs_fmt("%s?t=%s", srv_baseurl, hashtag + 1);
+
+                    d = xs_dict_set(d, "name", hashtag + 1);
+                    d = xs_dict_set(d, "url", s);
+                    d = xs_dict_set(d, "history", xs_stock(XSTYPE_LIST));
+
+                    r = xs_list_append(r, d);
+                }
+            }
+
+            *body  = xs_json_dumps(r, 4);
+            *ctype = "application/json";
+            status = HTTP_STATUS_OK;
+        }
+        else
+            status = HTTP_STATUS_UNAUTHORIZED;
+    }
+    else
+    if (strcmp(cmd, "/v1/blocks") == 0) { /** **/
+        *body  = xs_dup("[]");
+        *ctype = "application/json";
+        status = HTTP_STATUS_OK;
+    }
+    else
+    if (strcmp(cmd, "/v1/mutes") == 0) { /** **/
         *body  = xs_dup("[]");
         *ctype = "application/json";
         status = HTTP_STATUS_OK;
@@ -2507,9 +2568,7 @@ int mastoapi_get_handler(const xs_dict *req, const char *q_path,
                 /* reply something only for offset 0; otherwise,
                    apps like Tusky keep asking again and again */
                 if (xs_startswith(q, "https://")) {
-                    xs *md5 = xs_md5_hex(q, strlen(q));
-
-                    if (!timeline_here(&snac1, md5)) {
+                    if (!timeline_here(&snac1, q)) {
                         xs *object = NULL;
                         int status;
 
@@ -2979,8 +3038,10 @@ int mastoapi_post_handler(const xs_dict *req, const char *q_path,
 
                 if (*fn != '\0') {
                     char *ext = strrchr(fn, '.');
-                    xs *hash  = xs_md5_hex(fn, strlen(fn));
-                    xs *id    = xs_fmt("%s%s", hash, ext);
+                    char rnd[32];
+                    xs_rnd_buf(rnd, sizeof(rnd));
+                    xs *hash  = xs_md5_hex(rnd, sizeof(rnd));
+                    xs *id    = xs_fmt("post-%s%s", hash, ext ? ext : "");
                     xs *url   = xs_fmt("%s/s/%s", snac.actor, id);
                     int fo    = xs_number_get(xs_list_get(file, 1));
                     int fs    = xs_number_get(xs_list_get(file, 2));
@@ -3227,7 +3288,7 @@ int mastoapi_post_handler(const xs_dict *req, const char *q_path,
                 if (!xs_is_null(home))
                     home_marker = xs_dict_get(home, "last_read_id");
             }
-            
+
             const xs_str *notify_marker = xs_dict_get(args, "notifications[last_read_id]");
             if (xs_is_null(notify_marker)) {
                 const xs_dict *notify = xs_dict_get(args, "notifications");
@@ -3295,6 +3356,54 @@ int mastoapi_post_handler(const xs_dict *req, const char *q_path,
             }
         }
     }
+    else
+    if (xs_startswith(cmd, "/v1/tags/")) { /** **/
+        if (logged_in) {
+            xs *l = xs_split(cmd, "/");
+            const char *i_tag = xs_list_get(l, 3);
+            const char *cmd = xs_list_get(l, 4);
+
+            status = HTTP_STATUS_UNPROCESSABLE_CONTENT;
+
+            if (xs_is_string(i_tag) && xs_is_string(cmd)) {
+                int ok = 0;
+
+                xs *tag = xs_fmt("#%s", i_tag);
+                xs *followed_hashtags = xs_dup(xs_dict_get_def(snac.config,
+                                "followed_hashtags", xs_stock(XSTYPE_LIST)));
+
+                if (strcmp(cmd, "follow") == 0) {
+                    followed_hashtags = xs_list_append(followed_hashtags, tag);
+                    ok = 1;
+                }
+                else
+                if (strcmp(cmd, "unfollow") == 0) {
+                    int off = xs_list_in(followed_hashtags, tag);
+
+                    if (off != -1)
+                        followed_hashtags = xs_list_del(followed_hashtags, off);
+
+                    ok = 1;
+                }
+
+                if (ok) {
+                    /* update */
+                    xs_dict_set(snac.config, "followed_hashtags", followed_hashtags);
+                    user_persist(&snac, 0);
+
+                    xs *d = xs_dict_new();
+                    xs *s = xs_fmt("%s?t=%s", srv_baseurl, i_tag);
+                    d = xs_dict_set(d, "name", i_tag);
+                    d = xs_dict_set(d, "url", s);
+                    d = xs_dict_set(d, "history", xs_stock(XSTYPE_LIST));
+
+                    *body = xs_json_dumps(d, 4);
+                    *ctype = "application/json";
+                    status = HTTP_STATUS_OK;
+                }
+            }
+        }
+    }
     else
         status = HTTP_STATUS_UNPROCESSABLE_CONTENT;
 

+ 4 - 4
po/ru.po

@@ -126,7 +126,7 @@ msgstr "люди"
 
 #: html.c:936
 msgid "instance"
-msgstr "экземпляр"
+msgstr "сервер"
 
 #: html.c:945
 msgid ""
@@ -679,12 +679,12 @@ msgstr "Ничего не найдено для '%s'"
 
 #: html.c:3929
 msgid "Showing instance timeline"
-msgstr "Показываем ленту инстанции"
+msgstr "Показываем ленту сервера"
 
 #: html.c:4012
 #, c-format
 msgid "Showing timeline for list '%s'"
-msgstr "Показываем ленты инстанции для списка '%s'"
+msgstr "Показываем ленту для списка '%s'"
 
 #: httpd.c:250
 #, c-format
@@ -693,7 +693,7 @@ msgstr "Результаты поиска для тега #%s"
 
 #: httpd.c:259
 msgid "Recent posts by users in this instance"
-msgstr "Последние сообщения на этой инстанции"
+msgstr "Последние сообщения на этом сервере"
 
 #: html.c:1603
 msgid "Blocked hashtags..."

+ 768 - 0
po/uk.po

@@ -0,0 +1,768 @@
+# snac message translation file
+#
+msgid ""
+msgstr ""
+"Project-Id-Version: snac\n"
+"Last-Translator: grunfink\n"
+"Language: uk\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && "
+"n%10<=4 && (n%100<12 || n%100>14) ? 1 : 2);\n"
+"POT-Creation-Date: \n"
+"PO-Revision-Date: \n"
+"Language-Team: \n"
+"MIME-Version: 1.0\n"
+"Content-Transfer-Encoding: 8bit\n"
+"X-Generator: Poedit 3.0\n"
+
+#: html.c:384
+msgid "Sensitive content: "
+msgstr "Делікатний вміст: "
+
+#: html.c:392
+msgid "Sensitive content description"
+msgstr "Опис делікатного вмісту"
+
+#: html.c:405
+msgid "Only for mentioned people: "
+msgstr "Тільки для зазначених осіб: "
+
+#: html.c:428
+msgid "Reply to (URL): "
+msgstr "Відповісти до (URL): "
+
+#: html.c:437
+msgid "Don't send, but store as a draft"
+msgstr "Не надсилати, але зберігти як чернетку"
+
+#: html.c:438
+msgid "Draft:"
+msgstr "Чернетка:"
+
+#: html.c:494
+msgid "Attachments..."
+msgstr "Вкладення..."
+
+#: html.c:517
+msgid "File:"
+msgstr "Файл:"
+
+#: html.c:521
+msgid "Clear this field to delete the attachment"
+msgstr "Очистіть це поле, щоб видалити вкладення"
+
+#: html.c:530 html.c:555
+msgid "Attachment description"
+msgstr "Опис вкладення"
+
+#: html.c:566
+msgid "Poll..."
+msgstr "Опитування..."
+
+#: html.c:568
+msgid "Poll options (one per line, up to 8):"
+msgstr "Варіанти відповідей (по одному в рядку, до 8):"
+
+#: html.c:580
+msgid "One choice"
+msgstr "Єдиний вибір"
+
+#: html.c:583
+msgid "Multiple choices"
+msgstr "Декілька варіантів вибору"
+
+#: html.c:589
+msgid "End in 5 minutes"
+msgstr "Закінчення через 5 хвилин"
+
+#: html.c:593
+msgid "End in 1 hour"
+msgstr "Закінчення через 1 годину"
+
+#: html.c:596
+msgid "End in 1 day"
+msgstr "Закінчення через 1 день"
+
+#: html.c:604
+msgid "Post"
+msgstr "Надіслати"
+
+#: html.c:701 html.c:708
+msgid "Site description"
+msgstr "Опис сайту"
+
+#: html.c:719
+msgid "Admin email"
+msgstr "Пошта админа"
+
+#: html.c:732
+msgid "Admin account"
+msgstr "Обліковий запис адміна"
+
+#: html.c:800 html.c:1136
+#, c-format
+msgid "%d following, %d followers"
+msgstr "%d підписок, %d підписників"
+
+#: html.c:890
+msgid "RSS"
+msgstr "RSS"
+
+#: html.c:895 html.c:923
+msgid "private"
+msgstr "особисте"
+
+#: html.c:919
+msgid "public"
+msgstr "публічне"
+
+#: html.c:927
+msgid "notifications"
+msgstr "повідомлення"
+
+#: html.c:932
+msgid "people"
+msgstr "люди"
+
+#: html.c:936
+msgid "instance"
+msgstr "сервер"
+
+#: html.c:945
+msgid ""
+"Search posts by URL or content (regular expression), @user@host accounts, or "
+"#tag"
+msgstr ""
+"Шукати дописи за URL або вмістом (регулярний вираз), акаунтами @user@host "
+"або #тегом"
+
+#: html.c:946
+msgid "Content search"
+msgstr "Пошук за вмістом"
+
+#: html.c:1068
+msgid "verified link"
+msgstr "перевірене посилання"
+
+#: html.c:1125 html.c:2540 html.c:2553 html.c:2562
+msgid "Location: "
+msgstr "Місце знаходження: "
+
+#: html.c:1161
+msgid "New Post..."
+msgstr "Новий допис..."
+
+#: html.c:1163
+msgid "What's on your mind?"
+msgstr "Що у вас на думці?"
+
+#: html.c:1172
+msgid "Operations..."
+msgstr "Дії..."
+
+#: html.c:1187 html.c:1788 html.c:3193 html.c:4578
+msgid "Follow"
+msgstr "Підписатися"
+
+#: html.c:1189
+msgid "(by URL or user@host)"
+msgstr "(за URL або user@host)"
+
+#: html.c:1204 html.c:1764 html.c:4527
+msgid "Boost"
+msgstr "Просувати"
+
+#: html.c:1206 html.c:1223
+msgid "(by URL)"
+msgstr "(за URL)"
+
+#: html.c:1221 html.c:1743 html.c:4518
+msgid "Like"
+msgstr "Вподобайка"
+
+#: html.c:1347
+msgid "User Settings..."
+msgstr "Налаштування користувача..."
+
+#: html.c:1356
+msgid "Display name:"
+msgstr "Видиме ім'я:"
+
+#: html.c:1362
+msgid "Your name"
+msgstr "Ваше ім'я"
+
+#: html.c:1364
+msgid "Avatar: "
+msgstr "Аватар: "
+
+#: html.c:1372
+msgid "Delete current avatar"
+msgstr "Видалити поточний аватар"
+
+#: html.c:1374
+msgid "Header image (banner): "
+msgstr "Зображення заголовка (банер): "
+
+#: html.c:1382
+msgid "Delete current header image"
+msgstr "Видалити поточне зображення заголовка"
+
+#: html.c:1384
+msgid "Bio:"
+msgstr "Про себе:"
+
+#: html.c:1390
+msgid "Write about yourself here..."
+msgstr "Напишіть про себе тут..."
+
+#: html.c:1399
+msgid "Always show sensitive content"
+msgstr "Завжди показувати делікатний вміст"
+
+#: html.c:1401
+msgid "Email address for notifications:"
+msgstr "Поштова адреса для повідомлень:"
+
+#: html.c:1409
+msgid "Telegram notifications (bot key and chat id):"
+msgstr "Повідомлення в Telegram (ключ бота та id чату):"
+
+#: html.c:1423
+msgid "ntfy notifications (ntfy server and token):"
+msgstr "Повідомлення в ntfy (сервер та токен ntfy):"
+
+#: html.c:1437
+msgid "Maximum days to keep posts (0: server settings):"
+msgstr "Максимальний час для зберігання дописів (0: налаштування сервера):"
+
+#: html.c:1451
+msgid "Drop direct messages from people you don't follow"
+msgstr "Відхиляти особисті повідомлення від незнайомців"
+
+#: html.c:1460
+msgid "This account is a bot"
+msgstr "Цей акаунт є ботом"
+
+#: html.c:1469
+msgid "Auto-boost all mentions to this account"
+msgstr "Автоматично просувати всі згадки цього акаунта"
+
+#: html.c:1478
+msgid "This account is private (posts are not shown through the web)"
+msgstr "Це закритий акаунт (дописи не показуються в мережі)"
+
+#: html.c:1488
+msgid "Collapse top threads by default"
+msgstr "Згорнути потоки за замовчуванням"
+
+#: html.c:1497
+msgid "Follow requests must be approved"
+msgstr "Запити на підписку мають бути схвалені"
+
+#: html.c:1506
+msgid "Publish follower and following metrics"
+msgstr "Публікувати метрики підписок та підписників"
+
+#: html.c:1508
+msgid "Current location:"
+msgstr "Поточне місцезнаходження:"
+
+#: html.c:1522
+msgid "Profile metadata (key=value pairs in each line):"
+msgstr "Метадані профілю (пари ключ=значення в кожному рядку):"
+
+#: html.c:1533
+msgid "Web interface language:"
+msgstr "Мова інтерфейсу:"
+
+#: html.c:1543
+msgid "New password:"
+msgstr "Новий пароль:"
+
+#: html.c:1550
+msgid "Repeat new password:"
+msgstr "Повторити новий пароль:"
+
+#: html.c:1560
+msgid "Update user info"
+msgstr "Оновлення даних користувача"
+
+#: html.c:1571
+msgid "Followed hashtags..."
+msgstr "Підписані хештеги..."
+
+#: html.c:1573 html.c:1605
+msgid "One hashtag per line"
+msgstr "Один хештег на рядок"
+
+#: html.c:1594 html.c:1626
+msgid "Update hashtags"
+msgstr "Оновити хештеги"
+
+#: html.c:1743
+msgid "Say you like this post"
+msgstr "Позначте допис як вподобаний"
+
+#: html.c:1748 html.c:4536
+msgid "Unlike"
+msgstr "Більше не подобається"
+
+#: html.c:1748
+msgid "Nah don't like it that much"
+msgstr "Мені це не дуже подобається"
+
+#: html.c:1754 html.c:4673
+msgid "Unpin"
+msgstr "Відкріпити"
+
+#: html.c:1754
+msgid "Unpin this post from your timeline"
+msgstr "Відкріпити цей допис зі власної стрічки"
+
+#: html.c:1757 html.c:4668
+msgid "Pin"
+msgstr "Закріпити"
+
+#: html.c:1757
+msgid "Pin this post to the top of your timeline"
+msgstr "Закріпити цей допис до власної стрічки"
+
+#: html.c:1764
+msgid "Announce this post to your followers"
+msgstr "Поділитися цим дописом зі своїми підписниками"
+
+#: html.c:1769 html.c:4544
+msgid "Unboost"
+msgstr "Скасувати просування"
+
+#: html.c:1769
+msgid "I regret I boosted this"
+msgstr "Я шкодую, що просував це"
+
+#: html.c:1775 html.c:4683
+msgid "Unbookmark"
+msgstr "Видалити з закладок"
+
+#: html.c:1775
+msgid "Delete this post from your bookmarks"
+msgstr "Видалити цей допис з закладок"
+
+#: html.c:1778 html.c:4678
+msgid "Bookmark"
+msgstr "Додати в закладки"
+
+#: html.c:1778
+msgid "Add this post to your bookmarks"
+msgstr "Додайте цей допис в закладки"
+
+#: html.c:1784 html.c:3179 html.c:3367 html.c:4591
+msgid "Unfollow"
+msgstr "Відписатися"
+
+#: html.c:1784 html.c:3180
+msgid "Stop following this user's activity"
+msgstr "Відписатися від цього користувача"
+
+#: html.c:1788 html.c:3194
+msgid "Start following this user's activity"
+msgstr "Підписатися до цього користувача"
+
+#: html.c:1794 html.c:4621
+msgid "Unfollow Group"
+msgstr "Відписатися від групи"
+
+#: html.c:1795
+msgid "Stop following this group or channel"
+msgstr "Відписатися від групи чи канала"
+
+#: html.c:1799 html.c:4608
+msgid "Follow Group"
+msgstr "Підписатися на групу"
+
+#: html.c:1800
+msgid "Start following this group or channel"
+msgstr "Підписатися на групу чи канал"
+
+#: html.c:1805 html.c:3216 html.c:4552
+msgid "MUTE"
+msgstr "Заглушити"
+
+#: html.c:1806
+msgid "Block any activity from this user forever"
+msgstr "Назавжди заблокувати активність цього користувача"
+
+#: html.c:1811 html.c:3198 html.c:4638
+msgid "Delete"
+msgstr "Видалити"
+
+#: html.c:1811
+msgid "Delete this post"
+msgstr "Видалити цей допис"
+
+#: html.c:1814 html.c:4560
+msgid "Hide"
+msgstr "Приховати"
+
+#: html.c:1814
+msgid "Hide this post and its children"
+msgstr "Приховати цей допис разом з обговоренням"
+
+#: html.c:1845
+msgid "Edit..."
+msgstr "Редагувати..."
+
+#: html.c:1865
+msgid "Reply..."
+msgstr "Відповісти..."
+
+#: html.c:1916
+msgid "Truncated (too deep)"
+msgstr "Обрізано (занадто багато)"
+
+#: html.c:1925
+msgid "follows you"
+msgstr "підписан на вас"
+
+#: html.c:1988
+msgid "Pinned"
+msgstr "Закріплено"
+
+#: html.c:1996
+msgid "Bookmarked"
+msgstr "Додано до закладок"
+
+#: html.c:2004
+msgid "Poll"
+msgstr "Опитування"
+
+#: html.c:2011
+msgid "Voted"
+msgstr "Проголосовано"
+
+#: html.c:2020
+msgid "Event"
+msgstr "Подія"
+
+#: html.c:2052 html.c:2081
+msgid "boosted"
+msgstr "просунуто"
+
+#: html.c:2097
+msgid "in reply to"
+msgstr "у відповідь на"
+
+#: html.c:2148
+msgid " [SENSITIVE CONTENT]"
+msgstr " [ДЕЛІКАТНИЙ ВМІСТ]"
+
+#: html.c:2325
+msgid "Vote"
+msgstr "Голос"
+
+#: html.c:2335
+msgid "Closed"
+msgstr "Завершено"
+
+#: html.c:2360
+msgid "Closes in"
+msgstr "Завершується через"
+
+#: html.c:2441
+msgid "Video"
+msgstr "Відео"
+
+#: html.c:2456
+msgid "Audio"
+msgstr "Аудіо"
+
+#: html.c:2484
+msgid "Attachment"
+msgstr "Вкладення"
+
+#: html.c:2498
+msgid "Alt..."
+msgstr "Опис..."
+
+#: html.c:2511
+msgid "Source channel or community"
+msgstr "Вихідний канал або спільнота"
+
+#: html.c:2605
+msgid "Time: "
+msgstr "Час: "
+
+#: html.c:2686
+msgid "Older..."
+msgstr "Раніше..."
+
+#: html.c:2788
+msgid "about this site"
+msgstr "про цей сайт"
+
+#: html.c:2790
+msgid "powered by "
+msgstr "на базі "
+
+#: html.c:2855
+msgid "Dismiss"
+msgstr "Відхилити"
+
+#: html.c:2872
+#, c-format
+msgid "Timeline for list '%s'"
+msgstr "Стрічки для списку '%s'"
+
+#: html.c:2891 html.c:3944
+msgid "Pinned posts"
+msgstr "Закріплені дописи"
+
+#: html.c:2903 html.c:3959
+msgid "Bookmarked posts"
+msgstr "Дописи у закладках"
+
+#: html.c:2915 html.c:3974
+msgid "Post drafts"
+msgstr "Чернетки дописів"
+
+#: html.c:2986
+msgid "No more unseen posts"
+msgstr "Усе переглянуто"
+
+#: html.c:2990 html.c:3090
+msgid "Back to top"
+msgstr "Повернутися до початку"
+
+#: html.c:3043
+msgid "History"
+msgstr "Історія"
+
+#: html.c:3095 html.c:3515
+msgid "More..."
+msgstr "Більше..."
+
+#: html.c:3184 html.c:4574
+msgid "Unlimit"
+msgstr "Без обмеження"
+
+#: html.c:3185
+msgid "Allow announces (boosts) from this user"
+msgstr "Дозволити просування від цього користувача"
+
+#: html.c:3188 html.c:4570
+msgid "Limit"
+msgstr "Обмежити"
+
+#: html.c:3189
+msgid "Block announces (boosts) from this user"
+msgstr "Заборонити просування від цього користувача"
+
+#: html.c:3198
+msgid "Delete this user"
+msgstr "Видалити користувача"
+
+#: html.c:3203 html.c:4688
+msgid "Approve"
+msgstr "Підтвердити"
+
+#: html.c:3204
+msgid "Approve this follow request"
+msgstr "Підтвердити цей запит на підписку"
+
+#: html.c:3207 html.c:4712
+msgid "Discard"
+msgstr "Відхилити"
+
+#: html.c:3207
+msgid "Discard this follow request"
+msgstr "Відхилити цей запит на підписку"
+
+#: html.c:3212 html.c:4556
+msgid "Unmute"
+msgstr "Скасувати глушіння"
+
+#: html.c:3213
+msgid "Stop blocking activities from this user"
+msgstr "Припинити глушіння дій цього користувача"
+
+#: html.c:3217
+msgid "Block any activity from this user"
+msgstr "Заглушити всі дії цього користувача"
+
+#: html.c:3225
+msgid "Direct Message..."
+msgstr "Особисте повідомлення..."
+
+#: html.c:3260
+msgid "Pending follow confirmations"
+msgstr "Запити на підписку очікують на розгляд"
+
+#: html.c:3264
+msgid "People you follow"
+msgstr "Ваші підписки"
+
+#: html.c:3265
+msgid "People that follow you"
+msgstr "Ваші підписники"
+
+#: html.c:3304
+msgid "Clear all"
+msgstr "Очистити все"
+
+#: html.c:3361
+msgid "Mention"
+msgstr "Згадка"
+
+#: html.c:3364
+msgid "Finished poll"
+msgstr "Завершене опитування"
+
+#: html.c:3379
+msgid "Follow Request"
+msgstr "Запит на підписку"
+
+#: html.c:3462
+msgid "Context"
+msgstr "Контекст"
+
+#: html.c:3473
+msgid "New"
+msgstr "Нове"
+
+#: html.c:3488
+msgid "Already seen"
+msgstr "Вже переглянуто"
+
+#: html.c:3503
+msgid "None"
+msgstr "Нема"
+
+#: html.c:3769
+#, c-format
+msgid "Search results for account %s"
+msgstr "Результати пошуку для облікового запису %s"
+
+#: html.c:3776
+#, c-format
+msgid "Account %s not found"
+msgstr "Обліковий запис %s не знайдений"
+
+#: html.c:3807
+#, c-format
+msgid "Search results for tag %s"
+msgstr "Результати пошуку тега %s"
+
+#: html.c:3807
+#, c-format
+msgid "Nothing found for tag %s"
+msgstr "Нічого не знайдено за тегом %s"
+
+#: html.c:3823
+#, c-format
+msgid "Search results for '%s' (may be more)"
+msgstr "Результати пошуку для '%s' (можливо є більше)"
+
+#: html.c:3826
+#, c-format
+msgid "Search results for '%s'"
+msgstr "Результати пошуку для '%s'"
+
+#: html.c:3829
+#, c-format
+msgid "No more matches for '%s'"
+msgstr "Немає більше збігів для '%s'"
+
+#: html.c:3831
+#, c-format
+msgid "Nothing found for '%s'"
+msgstr "Нічого не знайдено для '%s'"
+
+#: html.c:3929
+msgid "Showing instance timeline"
+msgstr "Показуємо стрічку сервера"
+
+#: html.c:4012
+#, c-format
+msgid "Showing timeline for list '%s'"
+msgstr "Показуємо стрічку для списку '%s'"
+
+#: httpd.c:250
+#, c-format
+msgid "Search results for tag #%s"
+msgstr "Результати пошуку для тега #%s"
+
+#: httpd.c:259
+msgid "Recent posts by users in this instance"
+msgstr "Останні повідомлення на цьому сервері"
+
+#: html.c:1603
+msgid "Blocked hashtags..."
+msgstr "Заблоковані теги..."
+
+#: html.c:432
+msgid "Optional URL to reply to"
+msgstr "Необов'язковий URL для відповіді"
+
+#: html.c:575
+msgid ""
+"Option 1...\n"
+"Option 2...\n"
+"Option 3...\n"
+"..."
+msgstr ""
+"Варіант 1...\n"
+"Варіант 2...\n"
+"Варіант 3...\n"
+"..."
+
+#: html.c:1415
+msgid "Bot API key"
+msgstr "Ключ API для бота"
+
+#: html.c:1421
+msgid "Chat id"
+msgstr "Id чату"
+
+#: html.c:1429
+msgid "ntfy server - full URL (example: https://ntfy.sh/YourTopic)"
+msgstr "повна URL сервера ntfy (наприклад https://ntfy.sh/YourTopic)"
+
+#: html.c:1435
+msgid "ntfy token - if needed"
+msgstr "токен ntfy - якщо потрібно"
+
+#: html.c:2892
+msgid "pinned"
+msgstr "закріплено"
+
+#: html.c:2904
+msgid "bookmarks"
+msgstr "закладки"
+
+#: html.c:2916
+msgid "drafts"
+msgstr "чернетки"
+
+#: html.c:464
+msgid "Scheduled post..."
+msgstr "Запланувати..."
+
+msgid "Post date and time:"
+msgstr "Час допису:"
+
+#: html.c:2927 html.c:3989
+msgid "Scheduled posts"
+msgstr "Заплановані дописи"
+
+#: html.c:2928
+msgid "scheduled posts"
+msgstr "заплановані дописи"
+
+#: html.c:458
+#, c-format
+msgid "Post date and time (timezone: %s):"
+msgstr "Дата та час допису (часовий пояс: %s):"
+
+#: html.c:1538
+msgid "Time zone:"
+msgstr "Часовий пояс:"

+ 274 - 0
rss.c

@@ -0,0 +1,274 @@
+/* snac - A simple, minimalistic ActivityPub instance */
+/* copyright (c) 2025 grunfink et al. / MIT license */
+
+#include "xs.h"
+#include "xs_html.h"
+#include "xs_regex.h"
+#include "xs_time.h"
+#include "xs_match.h"
+#include "xs_curl.h"
+#include "xs_openssl.h"
+#include "xs_json.h"
+
+#include "snac.h"
+
+xs_str *rss_from_timeline(snac *user, const xs_list *timeline,
+                        const char *title, const char *link, const char *desc)
+/* converts a timeline to rss */
+{
+    xs_html *rss = xs_html_tag("rss",
+        xs_html_attr("xmlns:content", "http:/" "/purl.org/rss/1.0/modules/content/"),
+        xs_html_attr("version",       "2.0"),
+        xs_html_attr("xmlns:atom",    "http:/" "/www.w3.org/2005/Atom"));
+
+    xs_html *channel = xs_html_tag("channel",
+        xs_html_tag("title",
+            xs_html_text(title)),
+        xs_html_tag("language",
+            xs_html_text("en")),
+        xs_html_tag("link",
+            xs_html_text(link)),
+        xs_html_sctag("atom:link",
+            xs_html_attr("href", link),
+            xs_html_attr("rel", "self"),
+            xs_html_attr("type", "application/rss+xml")),
+        xs_html_tag("generator",
+            xs_html_text(USER_AGENT)),
+        xs_html_tag("description",
+            xs_html_text(desc)));
+
+    xs_html_add(rss, channel);
+
+    int cnt = 0;
+    const char *v;
+
+    xs_list_foreach(timeline, v) {
+        xs *msg = NULL;
+
+        if (user) {
+            if (!valid_status(timeline_get_by_md5(user, v, &msg)))
+                continue;
+        }
+        else {
+            if (!valid_status(object_get_by_md5(v, &msg)))
+                continue;
+        }
+
+        const char *id = xs_dict_get(msg, "id");
+        const char *content = xs_dict_get(msg, "content");
+        const char *published = xs_dict_get(msg, "published");
+
+        if (user && !xs_startswith(id, user->actor))
+            continue;
+
+        if (!id || !content || !published)
+            continue;
+
+        /* create a title with the first line of the content */
+        xs *title = xs_replace(content, "<br>", "\n");
+        title = xs_regex_replace_i(title, "<[^>]+>", " ");
+        title = xs_regex_replace_i(title, "&[^;]+;", " ");
+        int i;
+
+        for (i = 0; title[i] && title[i] != '\n' && i < 50; i++);
+
+        if (title[i] != '\0') {
+            title[i] = '\0';
+            title = xs_str_cat(title, "...");
+        }
+
+        title = xs_strip_i(title);
+
+        /* convert the date */
+        time_t t = xs_parse_iso_date(published, 0);
+        xs *rss_date = xs_str_utctime(t, "%a, %d %b %Y %T +0000");
+
+        /* if it's the first one, add it to the header */
+        if (cnt == 0)
+            xs_html_add(channel,
+                xs_html_tag("lastBuildDate",
+                    xs_html_text(rss_date)));
+
+        xs_html_add(channel,
+            xs_html_tag("item",
+                xs_html_tag("title",
+                    xs_html_text(title)),
+                xs_html_tag("link",
+                    xs_html_text(id)),
+                xs_html_tag("guid",
+                    xs_html_text(id)),
+                xs_html_tag("pubDate",
+                    xs_html_text(rss_date)),
+                xs_html_tag("description",
+                    xs_html_text(content))));
+
+        cnt++;
+    }
+
+    return xs_html_render_s(rss, "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
+}
+
+
+void rss_to_timeline(snac *user, const char *url)
+/* reads an RSS and inserts all ActivityPub posts into the user's timeline */
+{
+    if (!xs_startswith(url, "https:/") && !xs_startswith(url, "http:/"))
+        return;
+
+    xs *hdrs = xs_dict_new();
+    hdrs = xs_dict_set(hdrs, "accept",     "application/rss+xml");
+    hdrs = xs_dict_set(hdrs, "user-agent", USER_AGENT);
+
+    /* get the RSS metadata */
+    xs *md5 = xs_md5_hex(url, strlen(url));
+    xs *rss_md_fn = xs_fmt("%s/rss", user->basedir);
+    mkdirx(rss_md_fn);
+    rss_md_fn = xs_str_cat(rss_md_fn, "/", md5, ".json");
+
+    xs *rss_md = NULL;
+    const char *etag = NULL;
+
+    FILE *f;
+    if ((f = fopen(rss_md_fn, "r")) != NULL) {
+        rss_md = xs_json_load(f);
+        fclose(f);
+
+        etag = xs_dict_get(rss_md, "etag");
+
+        if (xs_is_string(etag))
+            hdrs = xs_dict_set(hdrs, "if-none-match", etag);
+    }
+
+    if (rss_md == NULL)
+        rss_md = xs_dict_new();
+
+    xs *payload = NULL;
+    int status;
+    int p_size;
+
+    xs *rsp = xs_http_request("GET", url, hdrs, NULL, 0, &status, &payload, &p_size, 0);
+
+    snac_log(user, xs_fmt("parsing RSS %s %d", url, status));
+
+    if (!valid_status(status) || !xs_is_string(payload))
+        return;
+
+    /* not an RSS? done */
+    const char *ctype = xs_dict_get(rsp, "content-type");
+    if (!xs_is_string(ctype) || xs_str_in(ctype, "application/rss+xml") == -1)
+        return;
+
+    /* yes, parsing is done with regexes (now I have two problems blah blah blah) */
+    xs *links = xs_regex_select(payload, "<link>[^<]+</link>");
+    const char *link;
+
+    xs_list_foreach(links, link) {
+        xs *l = xs_replace(link, "<link>", "");
+        char *p = strchr(l, '<');
+
+        if (p == NULL)
+            continue;
+        *p = '\0';
+
+        /* skip this same URL */
+        if (strcmp(l, url) == 0)
+            continue;
+
+        /* skip crap */
+        if (!xs_startswith(l, "https:/") && !xs_startswith(l, "http:/"))
+            continue;
+
+        snac_debug(user, 1, xs_fmt("RSS link: %s", l));
+
+        if (timeline_here(user, l)) {
+            snac_debug(user, 1, xs_fmt("RSS entry already in timeline %s", l));
+            continue;
+        }
+
+        /* special trick for Mastodon: convert from the alternate format */
+        if (strchr(l, '@') != NULL) {
+            xs *l2 = xs_split(l, "/");
+
+            if (xs_list_len(l2) == 5) {
+                const char *uid = xs_list_get(l2, 3);
+                if (*uid == '@') {
+                    xs *guessed_id = xs_fmt("https:/" "/%s/users/%s/statuses/%s",
+                        xs_list_get(l2, 2), uid + 1, xs_list_get(l2, -1));
+
+                    if (timeline_here(user, guessed_id)) {
+                        snac_debug(user, 1, xs_fmt("RSS entry already in timeline (alt) %s", guessed_id));
+                        continue;
+                    }
+                }
+            }
+        }
+
+        xs *obj = NULL;
+
+        if (!valid_status(object_get(l, &obj))) {
+            /* object is not here: bring it */
+            if (!valid_status(activitypub_request(user, l, &obj)))
+                continue;
+        }
+
+        if (xs_is_dict(obj)) {
+            const char *id      = xs_dict_get(obj, "id");
+            const char *type    = xs_dict_get(obj, "type");
+            const char *attr_to = get_atto(obj);
+
+            if (!xs_is_string(id) || !xs_is_string(type) || !xs_is_string(attr_to))
+                continue;
+
+            if (!xs_match(type, POSTLIKE_OBJECT_TYPE))
+                continue;
+
+            if (timeline_here(user, id)) {
+                snac_debug(user, 1, xs_fmt("RSS entry already in timeline (id) %s", id));
+                continue;
+            }
+
+            enqueue_actor_refresh(user, attr_to, 0);
+
+            timeline_add(user, id, obj);
+
+            snac_log(user, xs_fmt("new '%s' (RSS) %s %s", type, attr_to, id));
+        }
+    }
+
+    /* update the RSS metadata */
+    etag = xs_dict_get(rsp, "etag");
+
+    if (xs_is_string(etag)) {
+        rss_md = xs_dict_set(rss_md, "etag", etag);
+        rss_md = xs_dict_set(rss_md, "url", url);
+        if ((f = fopen(rss_md_fn, "w")) != NULL) {
+            xs_json_dump(rss_md, 4, f);
+            fclose(f);
+        }
+    }
+}
+
+
+void rss_poll_hashtags(void)
+/* parses all RSS from all users */
+{
+    xs *list = user_list();
+    const char *uid;
+
+    xs_list_foreach(list, uid) {
+        snac user;
+
+        if (user_open(&user, uid)) {
+            const xs_list *rss = xs_dict_get(user.config, "followed_hashtags");
+
+            if (xs_is_list(rss)) {
+                const char *url;
+
+                xs_list_foreach(rss, url)
+                    rss_to_timeline(&user, url);
+            }
+
+            user_free(&user);
+        }
+    }
+}

+ 14 - 0
sandbox.c

@@ -13,6 +13,14 @@ void sbox_enter(const char *basedir)
         return;
     }
 
+    int smail;
+    const char *url = xs_dict_get(srv_config, "smtp_url");
+
+    if (xs_is_string(url) && *url)
+        smail = 0;
+    else
+        smail = !xs_is_true(xs_dict_get(srv_config, "disable_email_notifications"));
+
     srv_debug(1, xs_fmt("Calling unveil()"));
     unveil(basedir,                "rwc");
     unveil("/tmp",                 "rwc");
@@ -25,6 +33,9 @@ void sbox_enter(const char *basedir)
     if (*address == '/')
         unveil(address, "rwc");
 
+    if (smail)
+        unveil("/usr/sbin/sendmail",   "x");
+
     unveil(NULL,                   NULL);
 
     srv_debug(1, xs_fmt("Calling pledge()"));
@@ -34,6 +45,9 @@ void sbox_enter(const char *basedir)
     if (*address == '/')
         p = xs_str_cat(p, " unix");
 
+    if (smail)
+        p = xs_str_cat(p, " exec");
+
     pledge(p, NULL);
 }
 

+ 13 - 5
snac.h

@@ -1,7 +1,7 @@
 /* snac - A simple, minimalistic ActivityPub instance */
 /* copyright (c) 2022 - 2025 grunfink et al. / MIT license */
 
-#define VERSION "2.77"
+#define VERSION "2.81-dev"
 
 #define USER_AGENT "snac/" VERSION
 
@@ -164,7 +164,8 @@ int pending_count(snac *user);
 
 double timeline_mtime(snac *snac);
 int timeline_touch(snac *snac);
-int timeline_here(snac *snac, const char *md5);
+int timeline_here_by_md5(snac *snac, const char *md5);
+int timeline_here(snac *snac, const char *id);
 int timeline_get_by_md5(snac *snac, const char *md5, xs_dict **msg);
 int timeline_del(snac *snac, const char *id);
 xs_str *user_index_fn(snac *user, const char *idx_name);
@@ -293,6 +294,8 @@ void enqueue_object_request(snac *user, const char *id, int forward_secs);
 void enqueue_verify_links(snac *user);
 void enqueue_actor_refresh(snac *user, const char *actor, int forward_secs);
 void enqueue_webmention(const xs_dict *msg);
+void enqueue_notify_webhook(snac *user, const xs_dict *noti, int retries);
+
 int was_question_voted(snac *user, const char *id);
 
 xs_list *user_queue(snac *snac);
@@ -322,7 +325,7 @@ void httpd(void);
 int webfinger_request_signed(snac *snac, const char *qs, xs_str **actor, xs_str **user);
 int webfinger_request(const char *qs, xs_str **actor, xs_str **user);
 int webfinger_request_fake(const char *qs, xs_str **actor, xs_str **user);
-int webfinger_get_handler(xs_dict *req, const char *q_path,
+int webfinger_get_handler(const xs_dict *req, const char *q_path,
                           xs_val **body, int *b_size, char **ctype);
 
 const char *default_avatar_base64(void);
@@ -394,8 +397,6 @@ int html_get_handler(const xs_dict *req, const char *q_path,
 int html_post_handler(const xs_dict *req, const char *q_path,
                       char *payload, int p_size,
                       char **body, int *b_size, char **ctype);
-xs_str *timeline_to_rss(snac *user, const xs_list *timeline,
-                        const char *title, const char *link, const char *desc);
 
 int write_default_css(void);
 int snac_init(const char *_basedir);
@@ -433,6 +434,8 @@ void mastoapi_purge(void);
 void verify_links(snac *user);
 
 void export_csv(snac *user);
+void export_posts(snac *user);
+
 int migrate_account(snac *user);
 
 void import_blocked_accounts_csv(snac *user, const char *fn);
@@ -461,3 +464,8 @@ int badlogin_check(const char *user, const char *addr);
 void badlogin_inc(const char *user, const char *addr);
 
 const char *lang_str(const char *str, const snac *user);
+
+xs_str *rss_from_timeline(snac *user, const xs_list *timeline,
+                        const char *title, const char *link, const char *desc);
+void rss_to_timeline(snac *user, const char *url);
+void rss_poll_hashtags(void);

+ 76 - 0
utils.c

@@ -45,6 +45,7 @@ static const char *default_srv_config = "{"
 static const char *default_css =
     "body { max-width: 48em; margin: auto; line-height: 1.5; padding: 0.8em; word-wrap: break-word; }\n"
     "pre { overflow-x: scroll; }\n"
+    "blockquote { font-style: italic; }\n"
     ".snac-embedded-video, img { max-width: 100% }\n"
     ".snac-origin { font-size: 85% }\n"
     ".snac-score { float: right; font-size: 85% }\n"
@@ -488,6 +489,26 @@ void verify_links(snac *user)
 
     int c = 0;
     while (metadata && xs_dict_next(metadata, &k, &v, &c)) {
+        xs *wfinger = NULL;
+        const char *ov = NULL;
+
+        /* is it an account handle? */
+        if (*v == '@' && strchr(v + 1, '@')) {
+            /* resolve it via webfinger */
+            if (valid_status(webfinger_request(v, &wfinger, NULL)) && xs_is_string(wfinger)) {
+                ov = v;
+                v = wfinger;
+
+                /* store the alias */
+                if (user->links == NULL)
+                    user->links = xs_dict_new();
+
+                user->links = xs_dict_set(user->links, ov, v);
+
+                changed++;
+            }
+        }
+
         /* not an https link? skip */
         if (!xs_startswith(v, "https:/" "/"))
             continue;
@@ -705,6 +726,61 @@ void export_csv(snac *user)
 }
 
 
+void export_posts(snac *user)
+/* exports all posts to an OrderedCollection */
+{
+    xs *ifn = xs_fmt("%s/public.idx", user->basedir);
+    xs *index = index_list(ifn, XS_ALL);
+    xs *ofn = xs_fmt("%s/export/outbox.json", user->basedir);
+    FILE *f;
+
+    if ((f = fopen(ofn, "w")) == NULL) {
+        snac_log(user, xs_fmt("Cannot create file %s", ofn));
+        return;
+    }
+
+    int cnt = 0;
+
+    /* raw output */
+    fprintf(f, "{\"@context\": \"https:/" "/www.w3.org/ns/activitystreams\",");
+    fprintf(f, "\"id\": \"outbox.json\",");
+    fprintf(f, "\"type\": \"OrderedCollection\",");
+    fprintf(f, "\"orderedItems\": [");
+
+    const char *md5;
+
+    snac_log(user, xs_fmt("Creating %s...", ofn));
+
+    xs_list_foreach(index, md5) {
+        xs *obj = NULL;
+
+        if (!valid_status(object_get_by_md5(md5, &obj)))
+            continue;
+
+        const char *type = xs_dict_get(obj, "type");
+
+        if (!xs_is_string(type) || strcmp(type, "Note"))
+            continue;
+
+        const char *atto = get_atto(obj);
+
+        if (!xs_is_string(atto) || strcmp(atto, user->actor))
+            continue;
+
+        if (cnt)
+            fprintf(f, ",");
+
+        xs *c_msg = msg_create(user, obj);
+        xs_json_dump(c_msg, 0, f);
+        cnt++;
+    }
+
+    fprintf(f, "], \"totalItems\": %d}", cnt);
+
+    fclose(f);
+}
+
+
 void import_blocked_accounts_csv(snac *user, const char *ifn)
 /* imports a Mastodon CSV file of blocked accounts */
 {

+ 2 - 2
webfinger.c

@@ -93,7 +93,7 @@ int webfinger_request_signed(snac *snac, const char *qs, xs_str **actor, xs_str
         if (user != NULL) {
             const char *subject = xs_dict_get(obj, "subject");
 
-            if (subject)
+            if (subject && xs_startswith(subject, "acct:"))
                 *user = xs_replace_n(subject, "acct:", "", 1);
         }
 
@@ -152,7 +152,7 @@ int webfinger_request_fake(const char *qs, xs_str **actor, xs_str **user)
 }
 
 
-int webfinger_get_handler(xs_dict *req, const char *q_path,
+int webfinger_get_handler(const xs_dict *req, const char *q_path,
                            xs_val **body, int *b_size, char **ctype)
 /* serves webfinger queries */
 {

+ 15 - 0
xs.h

@@ -90,6 +90,7 @@ xs_str *xs_rstrip_chars_i(xs_str *str, const char *chars);
 xs_str *xs_strip_chars_i(xs_str *str, const char *chars);
 #define xs_strip_i(str) xs_strip_chars_i(str, " \r\n\t\v\f")
 xs_str *xs_tolower_i(xs_str *str);
+xs_str *xs_toupper_i(xs_str *str);
 
 xs_list *xs_list_new(void);
 xs_list *xs_list_append_m(xs_list *list, const char *mem, int dsz);
@@ -692,6 +693,20 @@ xs_str *xs_tolower_i(xs_str *str)
 }
 
 
+xs_str *xs_toupper_i(xs_str *str)
+/* convert to lowercase */
+{
+    XS_ASSERT_TYPE(str, XSTYPE_STRING);
+
+    int n;
+
+    for (n = 0; str[n]; n++)
+        str[n] = toupper(str[n]);
+
+    return str;
+}
+
+
 /** lists **/
 
 xs_list *xs_list_new(void)

+ 2 - 2
xs_fcgi.h

@@ -13,7 +13,7 @@
 #define _XS_FCGI_H
 
  xs_dict *xs_fcgi_request(FILE *f, xs_str **payload, int *p_size, int *id);
- void xs_fcgi_response(FILE *f, int status, xs_dict *headers, xs_str *body, int b_size, int id);
+ void xs_fcgi_response(FILE *f, int status, const xs_dict *headers, const xs_str *body, int b_size, int id);
 
 
 #ifdef XS_IMPLEMENTATION
@@ -290,7 +290,7 @@ end:
 }
 
 
-void xs_fcgi_response(FILE *f, int status, xs_dict *headers, xs_str *body, int b_size, int fcgi_id)
+void xs_fcgi_response(FILE *f, int status, const xs_dict *headers, const xs_str *body, int b_size, int fcgi_id)
 /* writes an FCGI response */
 {
     struct fcgi_record_header hdr = {0};

+ 6 - 6
xs_httpd.h

@@ -5,7 +5,8 @@
 #define _XS_HTTPD_H
 
 xs_dict *xs_httpd_request(FILE *f, xs_str **payload, int *p_size);
-void xs_httpd_response(FILE *f, int status, const char *status_text, xs_dict *headers, xs_str *body, int b_size);
+void xs_httpd_response(FILE *f, int status, const char *status_text,
+                        const xs_dict *headers, const xs_val *body, int b_size);
 
 
 #ifdef XS_IMPLEMENTATION
@@ -109,16 +110,15 @@ xs_dict *xs_httpd_request(FILE *f, xs_str **payload, int *p_size)
 }
 
 
-void xs_httpd_response(FILE *f, int status, const char *status_text, xs_dict *headers, xs_str *body, int b_size)
+void xs_httpd_response(FILE *f, int status, const char *status_text,
+                        const xs_dict *headers, const xs_val *body, int b_size)
 /* sends an httpd response */
 {
-    xs *proto;
+    fprintf(f, "HTTP/1.1 %d %s\r\n", status, status_text ? status_text : "");
+
     const xs_str *k;
     const xs_val *v;
 
-    proto = xs_fmt("HTTP/1.1 %d %s", status, status_text);
-    fprintf(f, "%s\r\n", proto);
-
     xs_dict_foreach(headers, k, v) {
         fprintf(f, "%s: %s\r\n", k, v);
     }

+ 94 - 69
xs_json.h

@@ -8,6 +8,7 @@
 #define MAX_JSON_DEPTH 32
 #endif
 
+void xs_json_dump_value(const xs_val *data, int level, int indent, FILE *f);
 int xs_json_dump(const xs_val *data, int indent, FILE *f);
 xs_str *xs_json_dumps(const xs_val *data, int indent);
 
@@ -19,8 +20,8 @@ xs_val *xs_json_loads_full(const xs_str *json, int maxdepth);
 xstype xs_json_load_type(FILE *f);
 int xs_json_load_array_iter(FILE *f, xs_val **value, xstype *pt, int *c);
 int xs_json_load_object_iter(FILE *f, xs_str **key, xs_val **value, xstype *pt, int *c);
-xs_list *xs_json_load_array(FILE *f, int maxdepth);
-xs_dict *xs_json_load_object(FILE *f, int maxdepth);
+int xs_json_load_array(FILE *f, int maxdepth, xs_list **l);
+int xs_json_load_object(FILE *f, int maxdepth, xs_dict **d);
 
 
 #ifdef XS_IMPLEMENTATION
@@ -77,7 +78,7 @@ static void _xs_json_indent(int level, int indent, FILE *f)
 }
 
 
-static void _xs_json_dump(const xs_val *data, int level, int indent, FILE *f)
+void xs_json_dump_value(const xs_val *data, int level, int indent, FILE *f)
 /* dumps partial data as JSON */
 {
     int c = 0;
@@ -108,7 +109,7 @@ static void _xs_json_dump(const xs_val *data, int level, int indent, FILE *f)
                 fputc(',', f);
 
             _xs_json_indent(level + 1, indent, f);
-            _xs_json_dump(v, level + 1, indent, f);
+            xs_json_dump_value(v, level + 1, indent, f);
 
             c++;
         }
@@ -135,7 +136,7 @@ static void _xs_json_dump(const xs_val *data, int level, int indent, FILE *f)
             if (indent)
                 fputc(' ', f);
 
-            _xs_json_dump(v, level + 1, indent, f);
+            xs_json_dump_value(v, level + 1, indent, f);
 
             c++;
         }
@@ -154,6 +155,20 @@ static void _xs_json_dump(const xs_val *data, int level, int indent, FILE *f)
 }
 
 
+int xs_json_dump(const xs_val *data, int indent, FILE *f)
+/* dumps data into a file as JSON */
+{
+    xstype t = xs_type(data);
+
+    if (t == XSTYPE_LIST || t == XSTYPE_DICT) {
+        xs_json_dump_value(data, 0, indent, f);
+        return 1;
+    }
+
+    return 0;
+}
+
+
 xs_str *xs_json_dumps(const xs_val *data, int indent)
 /* dumps data as a JSON string */
 {
@@ -173,20 +188,6 @@ xs_str *xs_json_dumps(const xs_val *data, int indent)
 }
 
 
-int xs_json_dump(const xs_val *data, int indent, FILE *f)
-/* dumps data into a file as JSON */
-{
-    xstype t = xs_type(data);
-
-    if (t == XSTYPE_LIST || t == XSTYPE_DICT) {
-        _xs_json_dump(data, 0, indent, f);
-        return 1;
-    }
-
-    return 0;
-}
-
-
 /** JSON loads **/
 
 typedef enum {
@@ -370,6 +371,8 @@ int xs_json_load_array_iter(FILE *f, xs_val **value, xstype *pt, int *c)
         else
             return -1;
     }
+    else
+        *pt = xs_type(*value);
 
     *c = *c + 1;
 
@@ -377,43 +380,51 @@ int xs_json_load_array_iter(FILE *f, xs_val **value, xstype *pt, int *c)
 }
 
 
-xs_list *xs_json_load_array(FILE *f, int maxdepth)
+int xs_json_load_array(FILE *f, int maxdepth, xs_list **l)
 /* loads a full JSON array (after the initial OBRACK) */
+/* l can be NULL for the content to be dropped */
 {
     xstype t;
-    xs_list *l = xs_list_new();
+    int r = 0;
     int c = 0;
 
     for (;;) {
         xs *v = NULL;
-        int r = xs_json_load_array_iter(f, &v, &t, &c);
-
-        if (r == -1)
-            l = xs_free(l);
+        r = xs_json_load_array_iter(f, &v, &t, &c);
 
         if (r == 1) {
             /* partial load? */
             if (v == NULL && maxdepth != 0) {
-                if (t == XSTYPE_LIST)
-                    v = xs_json_load_array(f, maxdepth - 1);
+                if (t == XSTYPE_LIST) {
+                    if (l)
+                        v = xs_list_new();
+
+                    r = xs_json_load_array(f, maxdepth - 1, &v);
+                }
                 else
-                if (t == XSTYPE_DICT)
-                    v = xs_json_load_object(f, maxdepth - 1);
+                if (t == XSTYPE_DICT) {
+                    if (l)
+                        v = xs_dict_new();
+
+                    r = xs_json_load_object(f, maxdepth - 1, &v);
+                }
             }
 
-            /* still null? fail */
-            if (v == NULL) {
-                l = xs_free(l);
+            /* error? */
+            if (r < 0)
                 break;
-            }
 
-            l = xs_list_append(l, v);
+            if (l)
+                *l = xs_list_append(*l, v);
         }
         else
             break;
     }
 
-    return l;
+    if (r < 0 && l)
+        *l = xs_free(*l);
+
+    return r;
 }
 
 
@@ -458,6 +469,8 @@ int xs_json_load_object_iter(FILE *f, xs_str **key, xs_val **value, xstype *pt,
         else
             return -1;
     }
+    else
+        *pt = xs_type(*value);
 
     *c = *c + 1;
 
@@ -465,59 +478,52 @@ int xs_json_load_object_iter(FILE *f, xs_str **key, xs_val **value, xstype *pt,
 }
 
 
-xs_dict *xs_json_load_object(FILE *f, int maxdepth)
+int xs_json_load_object(FILE *f, int maxdepth, xs_dict **d)
 /* loads a full JSON object (after the initial OCURLY) */
+/* d can be NULL for the content to be dropped */
 {
     xstype t;
-    xs_dict *d = xs_dict_new();
+    int r = 0;
     int c = 0;
 
     for (;;) {
         xs *k = NULL;
         xs *v = NULL;
-        int r = xs_json_load_object_iter(f, &k, &v, &t, &c);
-
-        if (r == -1)
-            d = xs_free(d);
+        r = xs_json_load_object_iter(f, &k, &v, &t, &c);
 
         if (r == 1) {
             /* partial load? */
             if (v == NULL && maxdepth != 0) {
-                if (t == XSTYPE_LIST)
-                    v = xs_json_load_array(f, maxdepth - 1);
+                if (t == XSTYPE_LIST) {
+                    if (d)
+                        v = xs_list_new();
+
+                    r = xs_json_load_array(f, maxdepth - 1, &v);
+                }
                 else
-                if (t == XSTYPE_DICT)
-                    v = xs_json_load_object(f, maxdepth - 1);
+                if (t == XSTYPE_DICT) {
+                    if (d)
+                        v = xs_dict_new();
+
+                    r = xs_json_load_object(f, maxdepth - 1, &v);
+                }
             }
 
-            /* still null? fail */
-            if (v == NULL) {
-                d = xs_free(d);
+            /* error? */
+            if (r < 0)
                 break;
-            }
 
-            d = xs_dict_append(d, k, v);
+            if (d)
+                *d = xs_dict_append(*d, k, v);
         }
         else
             break;
     }
 
-    return d;
-}
-
-
-xs_val *xs_json_loads_full(const xs_str *json, int maxdepth)
-/* loads a string in JSON format and converts to a multiple data */
-{
-    FILE *f;
-    xs_val *v = NULL;
-
-    if ((f = fmemopen((char *)json, strlen(json), "r")) != NULL) {
-        v = xs_json_load_full(f, maxdepth);
-        fclose(f);
-    }
+    if (r < 0 && d)
+        *d = xs_free(*d);
 
-    return v;
+    return r;
 }
 
 
@@ -545,11 +551,30 @@ xs_val *xs_json_load_full(FILE *f, int maxdepth)
     xs_val *v = NULL;
     xstype t = xs_json_load_type(f);
 
-    if (t == XSTYPE_LIST)
-        v = xs_json_load_array(f, maxdepth);
+    if (t == XSTYPE_LIST) {
+        v = xs_list_new();
+        xs_json_load_array(f, maxdepth, &v);
+    }
     else
-    if (t == XSTYPE_DICT)
-        v = xs_json_load_object(f, maxdepth);
+    if (t == XSTYPE_DICT) {
+        v = xs_dict_new();
+        xs_json_load_object(f, maxdepth, &v);
+    }
+
+    return v;
+}
+
+
+xs_val *xs_json_loads_full(const xs_str *json, int maxdepth)
+/* loads a string in JSON format and converts to a multiple data */
+{
+    FILE *f;
+    xs_val *v = NULL;
+
+    if ((f = fmemopen((char *)json, strlen(json), "r")) != NULL) {
+        v = xs_json_load_full(f, maxdepth);
+        fclose(f);
+    }
 
     return v;
 }

+ 1 - 1
xs_version.h

@@ -1 +1 @@
-/* 871d420cef893b6efe32869407294baf084ce3ab 2025-05-04T11:01:01+02:00 */
+/* 401d229ffbec89b4a5cf97793926b7afb84a4f26 2025-07-08T15:44:54+02:00 */

+ 42 - 0
xs_webmention.h

@@ -5,6 +5,7 @@
 #define _XS_WEBMENTION_H
 
 int xs_webmention_send(const char *source, const char *target, const char *user_agent);
+int xs_webmention_hook(const char *source, const char *target, const char *user_agent);
 
 
 #ifdef XS_IMPLEMENTATION
@@ -118,6 +119,47 @@ int xs_webmention_send(const char *source, const char *target, const char *user_
 }
 
 
+int xs_webmention_hook(const char *source, const char *target, const char *user_agent)
+/* a Webmention has been received for a target that is ours; check if the source
+   really contains a link to our target */
+{
+    int status = 0;
+
+    xs *ua = xs_fmt("%s (Webmention)", user_agent ? user_agent : "xs_webmention");
+    xs *headers = xs_dict_new();
+    headers = xs_dict_set(headers, "accept", "text/html");
+    headers = xs_dict_set(headers, "user-agent", ua);
+
+    xs *g_req = NULL;
+    xs *payload = NULL;
+    int p_size = 0;
+
+    g_req = xs_http_request("GET", source, headers, NULL, 0, &status, &payload, &p_size, 0);
+
+    if (status < 200 || status > 299)
+        return -1;
+
+    if (!xs_is_string(payload))
+        return -2;
+
+    /* note: a "rogue" webmention can include a link to our target in commented-out HTML code */
+
+    xs *links = xs_regex_select(payload, "<(a +|link +)[^>]+>");
+    const char *link;
+
+    status = 0;
+    xs_list_foreach(links, link) {
+        /* if the link contains our target, it's valid */
+        if (xs_str_in(link, target) != -1) {
+            status = 1;
+            break;
+        }
+    }
+
+    return status;
+}
+
+
 #endif /* XS_IMPLEMENTATION */
 
 #endif /* _XS_WEBMENTION_H */