فهرست منبع

Merge tag '2.72' into curl-smtp

Version 2.72 RELEASED.
shtrophic 1 سال پیش
والد
کامیت
7611a6bee4
28فایلهای تغییر یافته به همراه636 افزوده شده و 225 حذف شده
  1. 2 0
      README.md
  2. 38 0
      RELEASE_NOTES.md
  3. 5 3
      TODO.md
  4. 1 1
      activitypub.c
  5. 68 14
      data.c
  6. 5 0
      doc/snac.1
  7. 1 1
      doc/snac.5
  8. 6 0
      doc/snac.8
  9. 1 0
      doc/style.css
  10. 4 2
      format.c
  11. 282 111
      html.c
  12. 2 1
      httpd.c
  13. 37 12
      main.c
  14. 7 2
      mastoapi.c
  15. 1 1
      snac.c
  16. 7 3
      snac.h
  17. 3 0
      utils.c
  18. 28 8
      xs.h
  19. 3 0
      xs_fcgi.h
  20. 2 2
      xs_html.h
  21. 34 20
      xs_httpd.h
  22. 3 4
      xs_io.h
  23. 6 0
      xs_json.h
  24. 6 1
      xs_match.h
  25. 1 1
      xs_openssl.h
  26. 2 0
      xs_socket.h
  27. 80 37
      xs_url.h
  28. 1 1
      xs_version.h

+ 2 - 0
README.md

@@ -107,6 +107,8 @@ This will:
 - [How to install snac on OpenBSD without relayd (by @antics@mastodon.nu)](https://chai.guru/pub/openbsd/snac.html).
 - [How to install snac on OpenBSD without relayd (by @antics@mastodon.nu)](https://chai.guru/pub/openbsd/snac.html).
 - [Setting up Snac in OpenBSD (by Yonle)](https://wiki.ircnow.org/index.php?n=Openbsd.Snac).
 - [Setting up Snac in OpenBSD (by Yonle)](https://wiki.ircnow.org/index.php?n=Openbsd.Snac).
 - [How to run your own social network with snac (by Giacomo Tesio)](https://encrypted.tesio.it/2024/12/18/how-to-run-your-own-social-network.html). Includes information on how to run snac as a CGI.
 - [How to run your own social network with snac (by Giacomo Tesio)](https://encrypted.tesio.it/2024/12/18/how-to-run-your-own-social-network.html). Includes information on how to run snac as a CGI.
+- [Improving snac Performance with Nginx Proxy Cache (by Stefano Marinelli)](https://it-notes.dragas.net/2025/01/29/improving-snac-performance-with-nginx-proxy-cache/).
+- [Caching Snac Proxied Media With Nginx (by Stefano Marinelli)](https://it-notes.dragas.net/2025/02/08/caching-snac-proxied-media-with-nginx/).
 
 
 ## Incredibly awesome CSS themes for snac
 ## Incredibly awesome CSS themes for snac
 
 

+ 38 - 0
RELEASE_NOTES.md

@@ -1,5 +1,43 @@
 # Release Notes
 # Release Notes
 
 
+## 2.72
+
+Each post can have more than one attachment from the web UI. The maximum number can be configured in `server.json` via the `max_attachments` value (default: 4).
+
+Each notification includes a link labelled `Context`, that leads to a page with the full conversation tree the post is a part of.
+
+Each followed hashtag has now a directly accesible link.
+
+Fixed a search bug (some matches were missed).
+
+Fixed more crashes (contributed by inz).
+
+Fixed link detection in posts (contributed by inz).
+
+Allow multiple editors for command-line posts (contributed by inz).
+
+Separated maximum and default timeline entry count, allowing larger timelines to be requested without having to increase the default (contributed by lxo).
+
+Turned message date into a link to the local post, so that it can be loaded into a separate tab for interacting with (contributed by lxo).
+
+Special thanks to fellow developer inz for bringing my attention to code places where I should have been more careful.
+
+## 2.71
+
+Fixed memory leak (contributed by inz).
+
+Fixed crash.
+
+## 2.70
+
+Notifications are now shown in a more compact way (i.e. all reactions are shown just above your post, instead of repeating the post *ad nauseam* for every reaction).
+
+New command-line option `unmute` to, well, no-longer-mute an actor.
+
+The private timeline now includes an approximate mark between new posts and "already seen" ones.
+
+Fixed a spurious 404 error in the instance root URL for some configurations.
+
 ## 2.69 "Yin/Yang of Love"
 ## 2.69 "Yin/Yang of Love"
 
 
 Added support for subscribing to LitePub (Pleroma-style) Fediverse Relays like e.g. https://fedi-relay.gyptazy.com to improve federation. See `snac(8)` (the Administrator Manual) for more information on how to use this feature.
 Added support for subscribing to LitePub (Pleroma-style) Fediverse Relays like e.g. https://fedi-relay.gyptazy.com to improve federation. See `snac(8)` (the Administrator Manual) for more information on how to use this feature.

+ 5 - 3
TODO.md

@@ -14,14 +14,12 @@ Important: deleting a follower should do more that just delete the object, see h
 
 
 ## Wishlist
 ## Wishlist
 
 
-Add support for subscribing and posting to relays (see https://codeberg.org/grunfink/snac2/issues/216 for more information).
+Each notification should show a link to the full thread, to see it in context.
 
 
 The instance timeline should also show boosts from users.
 The instance timeline should also show boosts from users.
 
 
 Mastoapi: implement /v1/conversations.
 Mastoapi: implement /v1/conversations.
 
 
-Implement following of hashtags (this is not trivial).
-
 Track 'Event' data types standardization; how to add plan-to-attend and similar activities (more info: https://event-federation.eu/). 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).
 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
 Implement "FEP-3b86: Activity Intents" https://codeberg.org/fediverse/fep/src/branch/main/fep/3b86/fep-3b86.md
@@ -365,3 +363,7 @@ CSV import/export does not work with OpenBSD security on; document it or fix it
 Add support for /share?text=tt&website=url (whatever it is, see https://mastodonshare.com/ for details) (2025-01-06T18:43:52+0100).
 Add support for /share?text=tt&website=url (whatever it is, see https://mastodonshare.com/ for details) (2025-01-06T18:43:52+0100).
 
 
 Add support for /authorize_interaction (whatever it is) (2025-01-16T14:45:28+0100).
 Add support for /authorize_interaction (whatever it is) (2025-01-16T14:45:28+0100).
+
+Implement following of hashtags (this is not trivial) (2025-01-30T16:12:16+0100).
+
+Add support for subscribing and posting to relays (see https://codeberg.org/grunfink/snac2/issues/216 for more information) (2025-01-30T16:12:34+0100).

+ 1 - 1
activitypub.c

@@ -3072,7 +3072,7 @@ int activitypub_get_handler(const xs_dict *req, const char *q_path,
         int cnt = xs_number_get(xs_dict_get_def(srv_config, "max_public_entries", "20"));
         int cnt = xs_number_get(xs_dict_get_def(srv_config, "max_public_entries", "20"));
 
 
         /* get the public outbox or the pinned list */
         /* get the public outbox or the pinned list */
-        xs *elems = *p_path == 'o' ? timeline_simple_list(&snac, "public", 0, cnt) : pinned_list(&snac);
+        xs *elems = *p_path == 'o' ? timeline_simple_list(&snac, "public", 0, cnt, NULL) : pinned_list(&snac);
 
 
         xs_list_foreach(elems, v) {
         xs_list_foreach(elems, v) {
             xs *i = NULL;
             xs *i = NULL;

+ 68 - 14
data.c

@@ -1399,11 +1399,13 @@ void timeline_update_indexes(snac *snac, const char *id)
         if (valid_status(object_get(id, &msg))) {
         if (valid_status(object_get(id, &msg))) {
             /* if its ours and is public, also store in public */
             /* if its ours and is public, also store in public */
             if (is_msg_public(msg)) {
             if (is_msg_public(msg)) {
-                object_user_cache_add(snac, id, "public");
-
-                /* also add it to the instance public timeline */
-                xs *ipt = xs_fmt("%s/public.idx", srv_basedir);
-                index_add(ipt, id);
+                if (object_user_cache_add(snac, id, "public") >= 0) {
+                    /* also add it to the instance public timeline */
+                    xs *ipt = xs_fmt("%s/public.idx", srv_basedir);
+                    index_add(ipt, id);
+                }
+                else
+                    srv_debug(1, xs_fmt("Not added to public instance index %s", id));
             }
             }
         }
         }
     }
     }
@@ -1487,16 +1489,28 @@ xs_str *user_index_fn(snac *user, const char *idx_name)
 }
 }
 
 
 
 
-xs_list *timeline_simple_list(snac *user, const char *idx_name, int skip, int show)
+xs_list *timeline_simple_list(snac *user, const char *idx_name, int skip, int show, int *more)
 /* returns a timeline (with all entries) */
 /* returns a timeline (with all entries) */
 {
 {
     xs *idx = user_index_fn(user, idx_name);
     xs *idx = user_index_fn(user, idx_name);
 
 
-    return index_list_desc(idx, skip, show);
+    /* if a more flag is sent, request one more */
+    xs_list *lst = index_list_desc(idx, skip, show + (more != NULL ? 1 : 0));
+
+    if (more != NULL) {
+        if (xs_list_len(lst) > show) {
+            *more = 1;
+            lst = xs_list_del(lst, -1);
+        }
+        else
+            *more = 0;
+    }
+
+    return lst;
 }
 }
 
 
 
 
-xs_list *timeline_list(snac *snac, const char *idx_name, int skip, int show)
+xs_list *timeline_list(snac *snac, const char *idx_name, int skip, int show, int *more)
 /* returns a timeline (only top level entries) */
 /* returns a timeline (only top level entries) */
 {
 {
     int c_max;
     int c_max;
@@ -1508,12 +1522,33 @@ xs_list *timeline_list(snac *snac, const char *idx_name, int skip, int show)
     if (show > c_max)
     if (show > c_max)
         show = c_max;
         show = c_max;
 
 
-    xs *list = timeline_simple_list(snac, idx_name, skip, show);
+    xs *list = timeline_simple_list(snac, idx_name, skip, show, more);
 
 
     return timeline_top_level(snac, list);
     return timeline_top_level(snac, list);
 }
 }
 
 
 
 
+void timeline_add_mark(snac *user)
+/* adds an "already seen" mark to the private timeline */
+{
+    xs *fn = xs_fmt("%s/private.idx", user->basedir);
+    char last_entry[MD5_HEX_SIZE] = "";
+    FILE *f;
+
+    /* get the last entry in the index */
+    if ((f = fopen(fn, "r")) != NULL) {
+        index_desc_first(f, last_entry, 0);
+        fclose(f);
+    }
+
+    /* is the last entry *not* a mark? */
+    if (strcmp(last_entry, MD5_ALREADY_SEEN_MARK) != 0) {
+        /* add it */
+        index_add_md5(fn, MD5_ALREADY_SEEN_MARK);
+    }
+}
+
+
 xs_str *instance_index_fn(void)
 xs_str *instance_index_fn(void)
 {
 {
     return xs_fmt("%s/public.idx", srv_basedir);
     return xs_fmt("%s/public.idx", srv_basedir);
@@ -1524,8 +1559,17 @@ xs_list *timeline_instance_list(int skip, int show)
 /* returns the timeline for the full instance */
 /* returns the timeline for the full instance */
 {
 {
     xs *idx = instance_index_fn();
     xs *idx = instance_index_fn();
+    xs *lst = index_list_desc(idx, skip, show);
 
 
-    return index_list_desc(idx, skip, show);
+    /* make the list unique */
+    xs_set rep;
+    xs_set_init(&rep);
+    const char *md5;
+
+    xs_list_foreach(lst, md5)
+        xs_set_add(&rep, md5);
+
+    return xs_set_result(&rep);
 }
 }
 
 
 
 
@@ -2557,7 +2601,7 @@ xs_list *inbox_list(void)
         if ((f = fopen(v, "r")) != NULL) {
         if ((f = fopen(v, "r")) != NULL) {
             xs *line = xs_readline(f);
             xs *line = xs_readline(f);
 
 
-            if (line) {
+            if (line && *line) {
                 line = xs_strip_i(line);
                 line = xs_strip_i(line);
                 ibl  = xs_list_append(ibl, line);
                 ibl  = xs_list_append(ibl, line);
             }
             }
@@ -2698,9 +2742,9 @@ xs_list *content_search(snac *user, const char *regex,
     const char *md5s[3] = {0};
     const char *md5s[3] = {0};
     int c[3] = {0};
     int c[3] = {0};
 
 
-    tls[0] = timeline_simple_list(user, "public", 0, XS_ALL);   /* public */
+    tls[0] = timeline_simple_list(user, "public", 0, XS_ALL, NULL);   /* public */
     tls[1] = timeline_instance_list(0, XS_ALL); /* instance */
     tls[1] = timeline_instance_list(0, XS_ALL); /* instance */
-    tls[2] = priv ? timeline_simple_list(user, "private", 0, XS_ALL) : xs_list_new(); /* private or none */
+    tls[2] = priv ? timeline_simple_list(user, "private", 0, XS_ALL, NULL) : xs_list_new(); /* private or none */
 
 
     /* first positioning */
     /* first positioning */
     for (int n = 0; n < 3; n++)
     for (int n = 0; n < 3; n++)
@@ -2722,7 +2766,17 @@ xs_list *content_search(snac *user, const char *regex,
         for (int n = 0; n < 3; n++) {
         for (int n = 0; n < 3; n++) {
             if (md5s[n] != NULL) {
             if (md5s[n] != NULL) {
                 xs *fn = _object_fn_by_md5(md5s[n], "content_search");
                 xs *fn = _object_fn_by_md5(md5s[n], "content_search");
-                double mt = mtime(fn);
+                double mt;
+
+                while ((mt = mtime(fn)) == 0 && md5s[n] != NULL) {
+                    /* object is not here: move to the next one */
+                    if (xs_list_next(tls[n], &md5s[n], &c[n])) {
+                        xs_free(fn);
+                        fn = _object_fn_by_md5(md5s[n], "content_search_2");
+                    }
+                    else
+                        md5s[n] = NULL;
+                }
 
 
                 if (mt > mtime) {
                 if (mt > mtime) {
                     newest = n;
                     newest = n;

+ 5 - 0
doc/snac.1

@@ -234,6 +234,8 @@ Purges old data from the timeline of all users.
 .It Cm adduser Ar basedir Op uid
 .It Cm adduser Ar basedir Op uid
 Adds a new user to the server. This is an interactive command;
 Adds a new user to the server. This is an interactive command;
 necessary information will be prompted for.
 necessary information will be prompted for.
+.It Cm deluser Ar basedir Ar uid
+Deletes a user, unfollowing all accounts first.
 .It Cm resetpwd Ar basedir Ar uid
 .It Cm resetpwd Ar basedir Ar uid
 Resets a user's password to a new, random one.
 Resets a user's password to a new, random one.
 .It Cm queue Ar basedir Ar uid
 .It Cm queue Ar basedir Ar uid
@@ -257,6 +259,9 @@ The rest of command line arguments are treated as media files to be
 attached to the post.
 attached to the post.
 .It Cm note_unlisted Ar basedir Ar uid Ar text Op file file ...
 .It Cm note_unlisted Ar basedir Ar uid Ar text Op file file ...
 Like the previous one, but creates an "unlisted" (or "quiet public") post.
 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 ...
+Like the previous one, but creates a post only for accounts mentioned
+in the post body.
 .It Cm block Ar basedir Ar instance_url
 .It Cm block Ar basedir Ar instance_url
 Blocks a full instance, given its URL or domain name. All subsequent
 Blocks a full instance, given its URL or domain name. All subsequent
 incoming activities with identifiers from that instance will be immediately
 incoming activities with identifiers from that instance will be immediately

+ 1 - 1
doc/snac.5

@@ -78,7 +78,7 @@ converted to related emojis:
 .Ss Accepted HTML
 .Ss Accepted HTML
 All HTML tags in entries are neutered except the following ones:
 All HTML tags in entries are neutered except the following ones:
 .Bd -literal
 .Bd -literal
-a p br blockquote ul ol li cite small
+a p br blockquote ul ol li cite small h2 h3
 span i b u s pre code em strong hr img del
 span i b u s pre code em strong hr img del
 .Ed
 .Ed
 .Pp
 .Pp

+ 6 - 0
doc/snac.8

@@ -154,6 +154,8 @@ to those servers that went timeout in the previous retry. If you want to
 give slow servers a chance to receive your messages, you can increase this
 give slow servers a chance to receive your messages, you can increase this
 value (but also take into account that processing the queue will take longer
 value (but also take into account that processing the queue will take longer
 while waiting for these molasses to respond).
 while waiting for these molasses to respond).
+.It Ic def_timeline_entries
+This is the default timeline entries shown in the web interface.
 .It Ic max_timeline_entries
 .It Ic max_timeline_entries
 This is the maximum timeline entries shown in the web interface.
 This is the maximum timeline entries shown in the web interface.
 .It Ic timeline_purge_days
 .It Ic timeline_purge_days
@@ -205,6 +207,8 @@ The email address of the instance administrator (optional).
 The user name of the instance administrator (optional).
 The user name of the instance administrator (optional).
 .It Ic short_description
 .It Ic short_description
 A textual short description about the instance (optional).
 A textual short description about the instance (optional).
+.It Ic short_description_raw
+Whether to interpret short_descript as raw string or convert to HTML (optional).
 .It Ic fastcgi
 .It Ic fastcgi
 If set to true,
 If set to true,
 .Nm
 .Nm
@@ -256,6 +260,8 @@ need at least a Linux kernel version 5.13.0.
 .It Ic max_public_entries
 .It Ic max_public_entries
 The maximum number of entries (posts) to be returned in user RSS feeds and outboxes
 The maximum number of entries (posts) to be returned in user RSS feeds and outboxes
 (default: 20).
 (default: 20).
+.It Ic max_attachments
+The maximum number of attachments per post (default: 4).
 .El
 .El
 .Pp
 .Pp
 You must restart the server to make effective these changes.
 You must restart the server to make effective these changes.

+ 1 - 0
doc/style.css

@@ -29,6 +29,7 @@ pre { overflow-x: scroll; }
 .snac-list-of-lists { padding-left: 0; }
 .snac-list-of-lists { padding-left: 0; }
 .snac-list-of-lists li { display: inline; border: 1px solid #a0a0a0; border-radius: 25px;
 .snac-list-of-lists li { display: inline; border: 1px solid #a0a0a0; border-radius: 25px;
   margin-right: 0.5em; padding-left: 0.5em; padding-right: 0.5em; }
   margin-right: 0.5em; padding-left: 0.5em; padding-right: 0.5em; }
+.snac-no-more-unseen-posts { border-top: 1px solid #a0a0a0; border-bottom: 1px solid #a0a0a0; padding: 0.5em 0; margin: 1em 0; }
 @media (prefers-color-scheme: dark) { 
 @media (prefers-color-scheme: dark) { 
   body, input, textarea { background-color: #000; color: #fff; }
   body, input, textarea { background-color: #000; color: #fff; }
   a { color: #7799dd }
   a { color: #7799dd }

+ 4 - 2
format.c

@@ -78,6 +78,8 @@ xs_dict *emojis(void)
     return d;
     return d;
 }
 }
 
 
+/* Non-whitespace without trailing comma, period or closing paren */
+#define NOSPACE "([^[:space:],.)]+|[,.)]+[^[:space:],.)])+"
 
 
 static xs_str *format_line(const char *line, xs_list **attach)
 static xs_str *format_line(const char *line, xs_list **attach)
 /* formats a line */
 /* formats a line */
@@ -96,8 +98,8 @@ static xs_str *format_line(const char *line, xs_list **attach)
             "__[^_]+__"                         "|" //anzu
             "__[^_]+__"                         "|" //anzu
             "!\\[[^]]+\\]\\([^\\)]+\\)"         "|"
             "!\\[[^]]+\\]\\([^\\)]+\\)"         "|"
             "\\[[^]]+\\]\\([^\\)]+\\)"          "|"
             "\\[[^]]+\\]\\([^\\)]+\\)"          "|"
-            "[a-z]+:/" "/[^[:space:]]+"         "|"
-            "(mailto|xmpp):[^@[:space:]]+@[^[:space:]]+"
+            "[a-z]+:/" "/" NOSPACE              "|"
+            "(mailto|xmpp):[^@[:space:]]+@" NOSPACE
         ")");
         ")");
     int n = 0;
     int n = 0;
 
 

+ 282 - 111
html.c

@@ -13,6 +13,7 @@
 #include "xs_html.h"
 #include "xs_html.h"
 #include "xs_curl.h"
 #include "xs_curl.h"
 #include "xs_unicode.h"
 #include "xs_unicode.h"
+#include "xs_url.h"
 
 
 #include "snac.h"
 #include "snac.h"
 
 
@@ -115,7 +116,8 @@ xs_str *actor_name(xs_dict *actor, const char *proxy)
 
 
 xs_html *html_actor_icon(snac *user, xs_dict *actor, const char *date,
 xs_html *html_actor_icon(snac *user, xs_dict *actor, const char *date,
                         const char *udate, const char *url, int priv,
                         const char *udate, const char *url, int priv,
-                        int in_people, const char *proxy, const char *lang)
+                        int in_people, const char *proxy, const char *lang,
+                        const char *md5)
 {
 {
     xs_html *actor_icon = xs_html_tag("p", NULL);
     xs_html *actor_icon = xs_html_tag("p", NULL);
 
 
@@ -224,12 +226,31 @@ xs_html *html_actor_icon(snac *user, xs_dict *actor, const char *date,
         if (xs_is_string(lang))
         if (xs_is_string(lang))
             date_title = xs_str_cat(date_title, " (", lang, ")");
             date_title = xs_str_cat(date_title, " (", lang, ")");
 
 
+        xs_html *date_text = xs_html_text(date_label);
+
+        if (user && md5) {
+            xs *lpost_url = xs_fmt("%s/admin/p/%s#%s_entry",
+                                   user->actor, md5, md5);
+            date_text = xs_html_tag("a",
+                                    xs_html_attr("href", lpost_url),
+                                    xs_html_attr("class", "snac-pubdate"),
+                                    date_text);
+        }
+        else if (user && url) {
+            xs *lpost_url = xs_fmt("%s/admin?q=%s",
+                                   user->actor, xs_url_enc(url));
+            date_text = xs_html_tag("a",
+                                    xs_html_attr("href", lpost_url),
+                                    xs_html_attr("class", "snac-pubdate"),
+                                    date_text);
+        }
+
         xs_html_add(actor_icon,
         xs_html_add(actor_icon,
             xs_html_text(" "),
             xs_html_text(" "),
             xs_html_tag("time",
             xs_html_tag("time",
                 xs_html_attr("class", "dt-published snac-pubdate"),
                 xs_html_attr("class", "dt-published snac-pubdate"),
                 xs_html_attr("title", date_title),
                 xs_html_attr("title", date_title),
-                xs_html_text(date_label)));
+                date_text));
     }
     }
 
 
     {
     {
@@ -261,7 +282,7 @@ xs_html *html_actor_icon(snac *user, xs_dict *actor, const char *date,
 }
 }
 
 
 
 
-xs_html *html_msg_icon(snac *user, const char *actor_id, const xs_dict *msg, const char *proxy)
+xs_html *html_msg_icon(snac *user, const char *actor_id, const xs_dict *msg, const char *proxy, const char *md5)
 {
 {
     xs *actor = NULL;
     xs *actor = NULL;
     xs_html *actor_icon = NULL;
     xs_html *actor_icon = NULL;
@@ -292,7 +313,7 @@ xs_html *html_msg_icon(snac *user, const char *actor_id, const xs_dict *msg, con
         else
         else
             lang = NULL;
             lang = NULL;
 
 
-        actor_icon = html_actor_icon(user, actor, date, udate, url, priv, 0, proxy, lang);
+        actor_icon = html_actor_icon(user, actor, date, udate, url, priv, 0, proxy, lang, md5);
     }
     }
 
 
     return actor_icon;
     return actor_icon;
@@ -306,7 +327,7 @@ xs_html *html_note(snac *user, const char *summary,
                    const xs_val *cw_yn, const char *cw_text,
                    const xs_val *cw_yn, const char *cw_text,
                    const xs_val *mnt_only, const char *redir,
                    const xs_val *mnt_only, const char *redir,
                    const char *in_reply_to, int poll,
                    const char *in_reply_to, int poll,
-                   const char *att_file, const char *att_alt_text,
+                   const xs_list *att_files, const xs_list *att_alt_texts,
                    int is_draft)
                    int is_draft)
 /* Yes, this is a FUCKTON of arguments and I'm a bit embarrased */
 /* Yes, this is a FUCKTON of arguments and I'm a bit embarrased */
 {
 {
@@ -411,30 +432,71 @@ xs_html *html_note(snac *user, const char *summary,
         xs_html_tag("p", NULL),
         xs_html_tag("p", NULL),
         att = xs_html_tag("details",
         att = xs_html_tag("details",
             xs_html_tag("summary",
             xs_html_tag("summary",
-                xs_html_text(L("Attachment..."))),
+                xs_html_text(L("Attachments..."))),
             xs_html_tag("p", NULL)));
             xs_html_tag("p", NULL)));
 
 
-    if (att_file && *att_file)
+    int max_attachments = xs_number_get(xs_dict_get_def(srv_config, "max_attachments", "4"));
+    int att_n = 0;
+
+    /* fields for the currently existing attachments */
+    if (xs_is_list(att_files) && xs_is_list(att_alt_texts)) {
+        while (att_n < max_attachments) {
+            const char *att_file = xs_list_get(att_files, att_n);
+            const char *att_alt_text = xs_list_get(att_alt_texts, att_n);
+
+            if (!xs_is_string(att_file) || !xs_is_string(att_alt_text))
+                break;
+
+            xs *att_lbl = xs_fmt("attach_url_%d", att_n);
+            xs *alt_lbl = xs_fmt("alt_text_%d", att_n);
+
+            if (att_n)
+                xs_html_add(att,
+                    xs_html_sctag("br", NULL));
+
+            xs_html_add(att,
+                xs_html_text(L("File:")),
+                xs_html_sctag("input",
+                    xs_html_attr("type", "text"),
+                    xs_html_attr("name", att_lbl),
+                    xs_html_attr("title", L("Clear this field to delete the attachment")),
+                    xs_html_attr("value", att_file)));
+
+            xs_html_add(att,
+                xs_html_text(" "),
+                xs_html_sctag("input",
+                    xs_html_attr("type",    "text"),
+                    xs_html_attr("name",    alt_lbl),
+                    xs_html_attr("value",   att_alt_text),
+                    xs_html_attr("placeholder", L("Attachment description"))));
+
+            att_n++;
+        }
+    }
+
+    /* the rest of possible attachments */
+    while (att_n < max_attachments) {
+        xs *att_lbl = xs_fmt("attach_%d", att_n);
+        xs *alt_lbl = xs_fmt("alt_text_%d", att_n);
+
+        if (att_n)
+            xs_html_add(att,
+                xs_html_sctag("br", NULL));
+
         xs_html_add(att,
         xs_html_add(att,
-            xs_html_text(L("File:")),
             xs_html_sctag("input",
             xs_html_sctag("input",
-                xs_html_attr("type", "text"),
-                xs_html_attr("name", "attach_url"),
-                xs_html_attr("title", L("Clear this field to delete the attachment")),
-                xs_html_attr("value", att_file)));
-    else
+                xs_html_attr("type",    "file"),
+                xs_html_attr("name",    att_lbl)));
+
         xs_html_add(att,
         xs_html_add(att,
+            xs_html_text(" "),
             xs_html_sctag("input",
             xs_html_sctag("input",
-                xs_html_attr("type",    "file"),
-                xs_html_attr("name",    "attach")));
+                xs_html_attr("type",    "text"),
+                xs_html_attr("name",    alt_lbl),
+                xs_html_attr("placeholder", L("Attachment description"))));
 
 
-    xs_html_add(att,
-        xs_html_text(" "),
-        xs_html_sctag("input",
-            xs_html_attr("type",    "text"),
-            xs_html_attr("name",    "alt_text"),
-            xs_html_attr("value",   att_alt_text),
-            xs_html_attr("placeholder", L("Attachment description"))));
+        att_n++;
+    }
 
 
     /* add poll controls */
     /* add poll controls */
     if (poll) {
     if (poll) {
@@ -553,10 +615,11 @@ xs_html *html_instance_head(void)
 
 
 static xs_html *html_instance_body(void)
 static xs_html *html_instance_body(void)
 {
 {
-    const char *host  = xs_dict_get(srv_config, "host");
-    const char *sdesc = xs_dict_get(srv_config, "short_description");
-    const char *email = xs_dict_get(srv_config, "admin_email");
-    const char *acct  = xs_dict_get(srv_config, "admin_account");
+    const char *host     = xs_dict_get(srv_config, "host");
+    const char *sdesc    = xs_dict_get(srv_config, "short_description");
+    const char *sdescraw = xs_dict_get(srv_config, "short_description_raw");
+    const char *email    = xs_dict_get(srv_config, "admin_email");
+    const char *acct     = xs_dict_get(srv_config, "admin_account");
 
 
     xs *blurb = xs_replace(snac_blurb, "%host%", host);
     xs *blurb = xs_replace(snac_blurb, "%host%", host);
 
 
@@ -569,12 +632,21 @@ static xs_html *html_instance_body(void)
             dl = xs_html_tag("dl", NULL)));
             dl = xs_html_tag("dl", NULL)));
 
 
     if (sdesc && *sdesc) {
     if (sdesc && *sdesc) {
-        xs_html_add(dl,
-            xs_html_tag("di",
-                xs_html_tag("dt",
-                    xs_html_text(L("Site description"))),
-                xs_html_tag("dd",
-                    xs_html_text(sdesc))));
+        if (!xs_is_null(sdescraw) && xs_type(sdescraw) == XSTYPE_TRUE) {
+            xs_html_add(dl,
+                xs_html_tag("di",
+                    xs_html_tag("dt",
+                        xs_html_text(L("Site description"))),
+                    xs_html_tag("dd",
+                        xs_html_raw(sdesc))));
+        } else {
+            xs_html_add(dl,
+                xs_html_tag("di",
+                    xs_html_tag("dt",
+                        xs_html_text(L("Site description"))),
+                    xs_html_tag("dd",
+                        xs_html_text(sdesc))));
+        }
     }
     }
     if (email && *email) {
     if (email && *email) {
         xs *mailto = xs_fmt("mailto:%s", email);
         xs *mailto = xs_fmt("mailto:%s", email);
@@ -1028,7 +1100,7 @@ xs_html *html_top_controls(snac *snac)
             NULL, NULL,
             NULL, NULL,
             xs_stock(XSTYPE_FALSE), "",
             xs_stock(XSTYPE_FALSE), "",
             xs_stock(XSTYPE_FALSE), NULL,
             xs_stock(XSTYPE_FALSE), NULL,
-            NULL, 1, "", "", 0),
+            NULL, 1, NULL, NULL, 0),
 
 
         /** operations **/
         /** operations **/
         xs_html_tag("details",
         xs_html_tag("details",
@@ -1600,17 +1672,22 @@ xs_html *html_entry_controls(snac *snac, const char *actor,
         xs *form_id = xs_fmt("%s_edit_form", md5);
         xs *form_id = xs_fmt("%s_edit_form", md5);
         xs *redir   = xs_fmt("%s_entry", md5);
         xs *redir   = xs_fmt("%s_entry", md5);
 
 
-        const char *att_file = "";
-        const char *att_alt_text = "";
+        xs *att_files = xs_list_new();
+        xs *att_alt_texts = xs_list_new();
+
         const xs_list *att_list = xs_dict_get(msg, "attachment");
         const xs_list *att_list = xs_dict_get(msg, "attachment");
 
 
-        /* does it have an attachment? */
-        if (xs_type(att_list) == XSTYPE_LIST && xs_list_len(att_list)) {
-            const xs_dict *d = xs_list_get(att_list, 0);
+        if (xs_is_list(att_list)) {
+            const xs_dict *d;
+
+            xs_list_foreach(att_list, d) {
+                const char *att_file = xs_dict_get(d, "url");
+                const char *att_alt_text = xs_dict_get(d, "name");
 
 
-            if (xs_type(d) == XSTYPE_DICT) {
-                att_file = xs_dict_get_def(d, "url", "");
-                att_alt_text = xs_dict_get_def(d, "name", "");
+                if (xs_is_string(att_file) && xs_is_string(att_alt_text)) {
+                    att_files = xs_list_append(att_files, att_file);
+                    att_alt_texts = xs_list_append(att_alt_texts, att_alt_text);
+                }
             }
             }
         }
         }
 
 
@@ -1622,7 +1699,7 @@ xs_html *html_entry_controls(snac *snac, const char *actor,
                 id, NULL,
                 id, NULL,
                 xs_dict_get(msg, "sensitive"), xs_dict_get(msg, "summary"),
                 xs_dict_get(msg, "sensitive"), xs_dict_get(msg, "summary"),
                 xs_stock(is_msg_public(msg) ? XSTYPE_FALSE : XSTYPE_TRUE), redir,
                 xs_stock(is_msg_public(msg) ? XSTYPE_FALSE : XSTYPE_TRUE), redir,
-                NULL, 0, att_file, att_alt_text, is_draft(snac, id))),
+                NULL, 0, att_files, att_alt_texts, is_draft(snac, id))),
             xs_html_tag("p", NULL));
             xs_html_tag("p", NULL));
     }
     }
 
 
@@ -1641,7 +1718,7 @@ xs_html *html_entry_controls(snac *snac, const char *actor,
                 NULL, NULL,
                 NULL, NULL,
                 xs_dict_get(msg, "sensitive"), xs_dict_get(msg, "summary"),
                 xs_dict_get(msg, "sensitive"), xs_dict_get(msg, "summary"),
                 xs_stock(is_msg_public(msg) ? XSTYPE_FALSE : XSTYPE_TRUE), redir,
                 xs_stock(is_msg_public(msg) ? XSTYPE_FALSE : XSTYPE_TRUE), redir,
-                id, 0, "", "", 0)),
+                id, 0, NULL, NULL, 0)),
             xs_html_tag("p", NULL));
             xs_html_tag("p", NULL));
     }
     }
 
 
@@ -1696,7 +1773,7 @@ xs_html *html_entry(snac *user, xs_dict *msg, int read_only,
                 xs_html_tag("div",
                 xs_html_tag("div",
                     xs_html_attr("class", "snac-origin"),
                     xs_html_attr("class", "snac-origin"),
                     xs_html_text(L("follows you"))),
                     xs_html_text(L("follows you"))),
-                html_msg_icon(read_only ? NULL : user, xs_dict_get(msg, "actor"), msg, proxy)));
+                html_msg_icon(read_only ? NULL : user, xs_dict_get(msg, "actor"), msg, proxy, NULL)));
     }
     }
     else
     else
     if (!xs_match(type, POSTLIKE_OBJECT_TYPE)) {
     if (!xs_match(type, POSTLIKE_OBJECT_TYPE)) {
@@ -1877,7 +1954,7 @@ xs_html *html_entry(snac *user, xs_dict *msg, int read_only,
     }
     }
 
 
     xs_html_add(post_header,
     xs_html_add(post_header,
-        html_msg_icon(read_only ? NULL : user, actor, msg, proxy));
+        html_msg_icon(read_only ? NULL : user, actor, msg, proxy, md5));
 
 
     /** post content **/
     /** post content **/
 
 
@@ -2022,16 +2099,17 @@ xs_html *html_entry(snac *user, xs_dict *msg, int read_only,
                 const char *name       = xs_dict_get(v, "name");
                 const char *name       = xs_dict_get(v, "name");
                 const xs_dict *replies = xs_dict_get(v, "replies");
                 const xs_dict *replies = xs_dict_get(v, "replies");
 
 
-                if (name && replies) {
-                    char *ti = (char *)xs_number_str(xs_dict_get(replies, "totalItems"));
+                if (xs_is_string(name) && xs_is_dict(replies)) {
+                    const char *ti = xs_number_str(xs_dict_get(replies, "totalItems"));
 
 
-                    xs_html_add(poll_result,
-                        xs_html_tag("tr",
-                            xs_html_tag("td",
-                                xs_html_text(name),
-                                xs_html_text(":")),
-                            xs_html_tag("td",
-                                xs_html_text(ti))));
+                    if (xs_is_string(ti))
+                        xs_html_add(poll_result,
+                            xs_html_tag("tr",
+                                xs_html_tag("td",
+                                    xs_html_text(name),
+                                    xs_html_text(":")),
+                                xs_html_tag("td",
+                                    xs_html_text(ti))));
                 }
                 }
             }
             }
 
 
@@ -2629,6 +2707,29 @@ xs_str *html_timeline(snac *user, const xs_list *list, int read_only,
                         xs_html_attr("title", L("Post drafts")),
                         xs_html_attr("title", L("Post drafts")),
                         xs_html_text("drafts"))));
                         xs_html_text("drafts"))));
         }
         }
+
+        /* the list of followed hashtags */
+        const char *followed_hashtags = xs_dict_get(user->config, "followed_hashtags");
+
+        if (xs_is_list(followed_hashtags) && xs_list_len(followed_hashtags)) {
+            xs_html *loht = xs_html_tag("ul",
+                xs_html_attr("class", "snac-list-of-lists"));
+            xs_html_add(body, loht);
+
+            const char *ht;
+
+            xs_list_foreach(followed_hashtags, ht) {
+                xs *url = xs_fmt("%s/admin?q=%s", user->actor, ht);
+                url = xs_replace_i(url, "#", "%23");
+
+                xs_html_add(loht,
+                    xs_html_tag("li",
+                        xs_html_tag("a",
+                            xs_html_attr("href", url),
+                            xs_html_attr("class", "snac-list-link"),
+                            xs_html_text(ht))));
+            }
+        }
     }
     }
 
 
     xs_html_add(body,
     xs_html_add(body,
@@ -2648,10 +2749,32 @@ xs_str *html_timeline(snac *user, const xs_list *list, int read_only,
     xs_html_add(body,
     xs_html_add(body,
         posts);
         posts);
 
 
+    int mark_shown = 0;
+
     while (xs_list_iter(&p, &v)) {
     while (xs_list_iter(&p, &v)) {
         xs *msg = NULL;
         xs *msg = NULL;
         int status;
         int status;
 
 
+        /* "already seen" mark? */
+        if (strcmp(v, MD5_ALREADY_SEEN_MARK) == 0) {
+            if (skip == 0 && !mark_shown) {
+                xs *s = xs_fmt("%s/admin", user->actor);
+
+                xs_html_add(posts,
+                    xs_html_tag("div",
+                        xs_html_attr("class", "snac-no-more-unseen-posts"),
+                        xs_html_text(L("No more unseen posts")),
+                        xs_html_text(" - "),
+                        xs_html_tag("a",
+                            xs_html_attr("href", s),
+                            xs_html_text(L("Back to top")))));
+            }
+
+            mark_shown = 1;
+
+            continue;
+        }
+
         if (utl && user && !is_pinned_by_md5(user, v))
         if (utl && user && !is_pinned_by_md5(user, v))
             status = timeline_get_by_md5(user, v, &msg);
             status = timeline_get_by_md5(user, v, &msg);
         else
         else
@@ -2788,7 +2911,7 @@ xs_html *html_people_list(snac *snac, xs_list *list, char *header, char *t, cons
                 xs_html_tag("div",
                 xs_html_tag("div",
                     xs_html_attr("class", "snac-post-header"),
                     xs_html_attr("class", "snac-post-header"),
                     html_actor_icon(snac, actor, xs_dict_get(actor, "published"),
                     html_actor_icon(snac, actor, xs_dict_get(actor, "published"),
-                                    NULL, NULL, 0, 1, proxy, NULL)));
+                                    NULL, NULL, 0, 1, proxy, NULL, NULL)));
 
 
             /* content (user bio) */
             /* content (user bio) */
             const char *c = xs_dict_get(actor, "summary");
             const char *c = xs_dict_get(actor, "summary");
@@ -2885,7 +3008,7 @@ xs_html *html_people_list(snac *snac, xs_list *list, char *header, char *t, cons
                     NULL, actor_id,
                     NULL, actor_id,
                     xs_stock(XSTYPE_FALSE), "",
                     xs_stock(XSTYPE_FALSE), "",
                     xs_stock(XSTYPE_FALSE), NULL,
                     xs_stock(XSTYPE_FALSE), NULL,
-                    NULL, 0, "", "", 0),
+                    NULL, 0, NULL, NULL, 0),
                 xs_html_tag("p", NULL));
                 xs_html_tag("p", NULL));
 
 
             xs_html_add(snac_post, snac_controls);
             xs_html_add(snac_post, snac_controls);
@@ -2970,6 +3093,9 @@ xs_str *html_notifications(snac *user, int skip, int show)
     xs_set rep;
     xs_set rep;
     xs_set_init(&rep);
     xs_set_init(&rep);
 
 
+    /* dict to store previous notification labels */
+    xs *admiration_labels = xs_dict_new();
+
     const xs_str *v;
     const xs_str *v;
 
 
     xs_list_foreach(n_list, v) {
     xs_list_foreach(n_list, v) {
@@ -2983,6 +3109,7 @@ xs_str *html_notifications(snac *user, int skip, int show)
         const char *utype = xs_dict_get(noti, "utype");
         const char *utype = xs_dict_get(noti, "utype");
         const char *id    = xs_dict_get(noti, "objid");
         const char *id    = xs_dict_get(noti, "objid");
         const char *date  = xs_dict_get(noti, "date");
         const char *date  = xs_dict_get(noti, "date");
+        const char *id2   = xs_dict_get_path(noti, "msg.id");
         xs *wrk = NULL;
         xs *wrk = NULL;
 
 
         if (xs_is_null(id))
         if (xs_is_null(id))
@@ -2991,12 +3118,15 @@ xs_str *html_notifications(snac *user, int skip, int show)
         if (is_hidden(user, id))
         if (is_hidden(user, id))
             continue;
             continue;
 
 
+        if (xs_is_string(id2) && xs_set_add(&rep, id2) != 1)
+            continue;
+
         object_get(id, &obj);
         object_get(id, &obj);
 
 
         const char *msg_id = NULL;
         const char *msg_id = NULL;
 
 
-        if (xs_is_dict(obj) && (msg_id = xs_dict_get(obj, "id")) && xs_set_add(&rep, msg_id) != 1)
-            continue;
+        if (xs_is_dict(obj))
+            msg_id = xs_dict_get(obj, "id");
 
 
         const char *actor_id = xs_dict_get(noti, "actor");
         const char *actor_id = xs_dict_get(noti, "actor");
         xs *actor = NULL;
         xs *actor = NULL;
@@ -3030,9 +3160,7 @@ xs_str *html_notifications(snac *user, int skip, int show)
 
 
         xs *s_date = xs_crop_i(xs_dup(date), 0, 10);
         xs *s_date = xs_crop_i(xs_dup(date), 0, 10);
 
 
-        xs_html *entry = xs_html_tag("div",
-            xs_html_attr("class", "snac-post-with-desc"),
-            xs_html_tag("p",
+        xs_html *this_html_label = xs_html_container(
                 xs_html_tag("b",
                 xs_html_tag("b",
                     xs_html_text(label),
                     xs_html_text(label),
                     xs_html_text(" by "),
                     xs_html_text(" by "),
@@ -3043,13 +3171,45 @@ xs_str *html_notifications(snac *user, int skip, int show)
                 xs_html_tag("time",
                 xs_html_tag("time",
                     xs_html_attr("class", "dt-published snac-pubdate"),
                     xs_html_attr("class", "dt-published snac-pubdate"),
                     xs_html_attr("title", date),
                     xs_html_attr("title", date),
-                    xs_html_text(s_date))));
+                    xs_html_text(s_date)));
+
+        xs_html *html_label = NULL;
+
+        if (xs_is_string(msg_id)) {
+            const xs_val *prev_label = xs_dict_get(admiration_labels, msg_id);
+
+            if (xs_type(prev_label) == XSTYPE_DATA) {
+                /* there is a previous list of admiration labels! */
+                xs_data_get(&html_label, prev_label);
+
+                xs_html_add(html_label,
+                    xs_html_sctag("br", NULL),
+                    this_html_label);
+
+                continue;
+            }
+        }
+
+        xs_html *entry = NULL;
+
+        html_label = xs_html_tag("p",
+            this_html_label);
+
+        /* store in the admiration labels dict */
+        xs *pl = xs_data_new(&html_label, sizeof(html_label));
+
+        if (xs_is_string(msg_id))
+            admiration_labels = xs_dict_set(admiration_labels, msg_id, pl);
+
+        entry = xs_html_tag("div",
+            xs_html_attr("class", "snac-post-with-desc"),
+            html_label);
 
 
         if (strcmp(type, "Follow") == 0 || strcmp(utype, "Follow") == 0 || strcmp(type, "Block") == 0) {
         if (strcmp(type, "Follow") == 0 || strcmp(utype, "Follow") == 0 || strcmp(type, "Block") == 0) {
             xs_html_add(entry,
             xs_html_add(entry,
                 xs_html_tag("div",
                 xs_html_tag("div",
                     xs_html_attr("class", "snac-post"),
                     xs_html_attr("class", "snac-post"),
-                    html_actor_icon(user, actor, NULL, NULL, NULL, 0, 0, proxy, NULL)));
+                    html_actor_icon(user, actor, NULL, NULL, NULL, 0, 0, proxy, NULL, NULL)));
         }
         }
         else
         else
         if (strcmp(type, "Move") == 0) {
         if (strcmp(type, "Move") == 0) {
@@ -3063,18 +3223,23 @@ xs_str *html_notifications(snac *user, int skip, int show)
                     xs_html_add(entry,
                     xs_html_add(entry,
                         xs_html_tag("div",
                         xs_html_tag("div",
                             xs_html_attr("class", "snac-post"),
                             xs_html_attr("class", "snac-post"),
-                            html_actor_icon(user, old_actor, NULL, NULL, NULL, 0, 0, proxy, NULL)));
+                            html_actor_icon(user, old_actor, NULL, NULL, NULL, 0, 0, proxy, NULL, NULL)));
                 }
                 }
             }
             }
         }
         }
         else
         else
         if (obj != NULL) {
         if (obj != NULL) {
             xs *md5 = xs_md5_hex(id, strlen(id));
             xs *md5 = xs_md5_hex(id, strlen(id));
+            xs *ctxt = xs_fmt("%s/admin/p/%s#%s_entry", user->actor, md5, md5);
 
 
             xs_html *h = html_entry(user, obj, 0, 0, md5, 1);
             xs_html *h = html_entry(user, obj, 0, 0, md5, 1);
 
 
             if (h != NULL) {
             if (h != NULL) {
                 xs_html_add(entry,
                 xs_html_add(entry,
+                    xs_html_tag("p",
+                        xs_html_tag("a",
+                            xs_html_attr("href", ctxt),
+                            xs_html_text(L("Context")))),
                     h);
                     h);
             }
             }
         }
         }
@@ -3111,8 +3276,6 @@ xs_str *html_notifications(snac *user, int skip, int show)
         }
         }
     }
     }
 
 
-    xs_set_free(&rep);
-
     if (noti_new == NULL && noti_seen == NULL)
     if (noti_new == NULL && noti_seen == NULL)
         xs_html_add(body,
         xs_html_add(body,
             xs_html_tag("h2",
             xs_html_tag("h2",
@@ -3132,6 +3295,8 @@ xs_str *html_notifications(snac *user, int skip, int show)
                     xs_html_text(L("More...")))));
                     xs_html_text(L("More...")))));
     }
     }
 
 
+    xs_set_free(&rep);
+
     xs_html_add(body,
     xs_html_add(body,
         html_footer());
         html_footer());
 
 
@@ -3232,7 +3397,8 @@ int html_get_handler(const xs_dict *req, const char *q_path,
         cache = 0;
         cache = 0;
 
 
     int skip = 0;
     int skip = 0;
-    int def_show = xs_number_get(xs_dict_get(srv_config, "max_timeline_entries"));
+    int def_show = xs_number_get(xs_dict_get_def(srv_config, "def_timeline_entries",
+                                 xs_dict_get_def(srv_config, "max_timeline_entries", "50")));
     int show = def_show;
     int show = def_show;
 
 
     if ((v = xs_dict_get(q_vars, "skip")) != NULL)
     if ((v = xs_dict_get(q_vars, "skip")) != NULL)
@@ -3277,21 +3443,17 @@ int html_get_handler(const xs_dict *req, const char *q_path,
         }
         }
         else {
         else {
             xs *list = NULL;
             xs *list = NULL;
-            xs *next = NULL;
+            int more = 0;
 
 
-            if (xs_is_true(xs_dict_get(srv_config, "strict_public_timelines"))) {
-                list = timeline_simple_list(&snac, "public", skip, show);
-                next = timeline_simple_list(&snac, "public", skip + show, 1);
-            }
-            else {
-                list = timeline_list(&snac, "public", skip, show);
-                next = timeline_list(&snac, "public", skip + show, 1);
-            }
+            if (xs_is_true(xs_dict_get(srv_config, "strict_public_timelines")))
+                list = timeline_simple_list(&snac, "public", skip, show, &more);
+            else 
+                list = timeline_list(&snac, "public", skip, show, &more);
 
 
             xs *pins = pinned_list(&snac);
             xs *pins = pinned_list(&snac);
             pins = xs_list_cat(pins, list);
             pins = xs_list_cat(pins, list);
 
 
-            *body = html_timeline(&snac, pins, 1, skip, show, xs_list_len(next), NULL, "", 1, error);
+            *body = html_timeline(&snac, pins, 1, skip, show, more, NULL, "", 1, error);
 
 
             *b_size = strlen(*body);
             *b_size = strlen(*body);
             status  = HTTP_STATUS_OK;
             status  = HTTP_STATUS_OK;
@@ -3440,6 +3602,7 @@ int html_get_handler(const xs_dict *req, const char *q_path,
                 }
                 }
             }
             }
             else {
             else {
+                /** the private timeline **/
                 double t = history_mtime(&snac, "timeline.html_");
                 double t = history_mtime(&snac, "timeline.html_");
 
 
                 /* if enabled by admin, return a cached page if its timestamp is:
                 /* if enabled by admin, return a cached page if its timestamp is:
@@ -3453,19 +3616,22 @@ int html_get_handler(const xs_dict *req, const char *q_path,
                                 xs_dict_get(req, "if-none-match"), etag);
                                 xs_dict_get(req, "if-none-match"), etag);
                 }
                 }
                 else {
                 else {
+                    int more = 0;
+
                     snac_debug(&snac, 1, xs_fmt("building timeline"));
                     snac_debug(&snac, 1, xs_fmt("building timeline"));
 
 
-                    xs *list = timeline_list(&snac, "private", skip, show);
-                    xs *next = timeline_list(&snac, "private", skip + show, 1);
+                    xs *list = timeline_list(&snac, "private", skip, show, &more);
 
 
                     *body = html_timeline(&snac, list, 0, skip, show,
                     *body = html_timeline(&snac, list, 0, skip, show,
-                            xs_list_len(next), NULL, "/admin", 1, error);
+                            more, NULL, "/admin", 1, error);
 
 
                     *b_size = strlen(*body);
                     *b_size = strlen(*body);
                     status  = HTTP_STATUS_OK;
                     status  = HTTP_STATUS_OK;
 
 
                     if (save)
                     if (save)
                         history_add(&snac, "timeline.html_", *body, *b_size, etag);
                         history_add(&snac, "timeline.html_", *body, *b_size, etag);
+
+                    timeline_add_mark(&snac);
                 }
                 }
             }
             }
         }
         }
@@ -3481,7 +3647,8 @@ int html_get_handler(const xs_dict *req, const char *q_path,
             const char *md5 = xs_list_get(l, -1);
             const char *md5 = xs_list_get(l, -1);
 
 
             if (md5 && *md5 && timeline_here(&snac, md5)) {
             if (md5 && *md5 && timeline_here(&snac, md5)) {
-                xs *list = xs_list_append(xs_list_new(), md5);
+                xs *list0 = xs_list_append(xs_list_new(), md5);
+                xs *list  = timeline_top_level(&snac, list0);
 
 
                 *body   = html_timeline(&snac, list, 0, 0, 0, 0, NULL, "/admin", 1, error);
                 *body   = html_timeline(&snac, list, 0, 0, 0, 0, NULL, "/admin", 1, error);
                 *b_size = strlen(*body);
                 *b_size = strlen(*body);
@@ -3665,7 +3832,7 @@ int html_get_handler(const xs_dict *req, const char *q_path,
 
 
         int cnt = xs_number_get(xs_dict_get_def(srv_config, "max_public_entries", "20"));
         int cnt = xs_number_get(xs_dict_get_def(srv_config, "max_public_entries", "20"));
 
 
-        xs *elems = timeline_simple_list(&snac, "public", 0, cnt);
+        xs *elems = timeline_simple_list(&snac, "public", 0, cnt, NULL);
         xs *bio   = xs_dup(xs_dict_get(snac.config, "bio"));
         xs *bio   = xs_dup(xs_dict_get(snac.config, "bio"));
 
 
         xs *rss_title = xs_fmt("%s (@%s@%s)",
         xs *rss_title = xs_fmt("%s (@%s@%s)",
@@ -3869,52 +4036,56 @@ int html_post_handler(const xs_dict *req, const char *q_path,
         /* post note */
         /* post note */
         const xs_str *content      = xs_dict_get(p_vars, "content");
         const xs_str *content      = xs_dict_get(p_vars, "content");
         const xs_str *in_reply_to  = xs_dict_get(p_vars, "in_reply_to");
         const xs_str *in_reply_to  = xs_dict_get(p_vars, "in_reply_to");
-        const xs_str *attach_url   = xs_dict_get(p_vars, "attach_url");
-        const xs_list *attach_file = xs_dict_get(p_vars, "attach");
         const xs_str *to           = xs_dict_get(p_vars, "to");
         const xs_str *to           = xs_dict_get(p_vars, "to");
         const xs_str *sensitive    = xs_dict_get(p_vars, "sensitive");
         const xs_str *sensitive    = xs_dict_get(p_vars, "sensitive");
         const xs_str *summary      = xs_dict_get(p_vars, "summary");
         const xs_str *summary      = xs_dict_get(p_vars, "summary");
         const xs_str *edit_id      = xs_dict_get(p_vars, "edit_id");
         const xs_str *edit_id      = xs_dict_get(p_vars, "edit_id");
-        const xs_str *alt_text     = xs_dict_get(p_vars, "alt_text");
         int priv             = !xs_is_null(xs_dict_get(p_vars, "mentioned_only"));
         int priv             = !xs_is_null(xs_dict_get(p_vars, "mentioned_only"));
         int store_as_draft   = !xs_is_null(xs_dict_get(p_vars, "is_draft"));
         int store_as_draft   = !xs_is_null(xs_dict_get(p_vars, "is_draft"));
         xs *attach_list      = xs_list_new();
         xs *attach_list      = xs_list_new();
 
 
-        /* default alt text */
-        if (xs_is_null(alt_text))
-            alt_text = "";
+        /* iterate the attachments */
+        int max_attachments = xs_number_get(xs_dict_get_def(srv_config, "max_attachments", "4"));
 
 
-        /* is attach_url set? */
-        if (!xs_is_null(attach_url) && *attach_url != '\0') {
-            xs *l = xs_list_new();
+        for (int att_n = 0; att_n < max_attachments; att_n++) {
+            xs *url_lbl = xs_fmt("attach_url_%d", att_n);
+            xs *att_lbl = xs_fmt("attach_%d", att_n);
+            xs *alt_lbl = xs_fmt("alt_text_%d", att_n);
 
 
-            l = xs_list_append(l, attach_url);
-            l = xs_list_append(l, alt_text);
+            const char *attach_url     = xs_dict_get(p_vars, url_lbl);
+            const xs_list *attach_file = xs_dict_get(p_vars, att_lbl);
+            const char *alt_text       = xs_dict_get_def(p_vars, alt_lbl, "");
 
 
-            attach_list = xs_list_append(attach_list, l);
-        }
+            if (xs_is_string(attach_url) && *attach_url != '\0') {
+                xs *l = xs_list_new();
 
 
-        /* is attach_file set? */
-        if (!xs_is_null(attach_file) && xs_type(attach_file) == XSTYPE_LIST) {
-            const char *fn = xs_list_get(attach_file, 0);
+                l = xs_list_append(l, attach_url);
+                l = xs_list_append(l, alt_text);
 
 
-            if (*fn != '\0') {
-                char *ext = strrchr(fn, '.');
-                xs *hash  = xs_md5_hex(fn, strlen(fn));
-                xs *id    = xs_fmt("%s%s", hash, ext);
-                xs *url   = xs_fmt("%s/s/%s", snac.actor, id);
-                int fo    = xs_number_get(xs_list_get(attach_file, 1));
-                int fs    = xs_number_get(xs_list_get(attach_file, 2));
+                attach_list = xs_list_append(attach_list, l);
+            }
+            else
+            if (xs_is_list(attach_file)) {
+                const char *fn = xs_list_get(attach_file, 0);
 
 
-                /* store */
-                static_put(&snac, id, payload + fo, fs);
+                if (xs_is_string(fn) && *fn != '\0') {
+                    char *ext = strrchr(fn, '.');
+                    xs *hash  = xs_md5_hex(fn, strlen(fn));
+                    xs *id    = xs_fmt("%s%s", hash, ext);
+                    xs *url   = xs_fmt("%s/s/%s", snac.actor, id);
+                    int fo    = xs_number_get(xs_list_get(attach_file, 1));
+                    int fs    = xs_number_get(xs_list_get(attach_file, 2));
 
 
-                xs *l = xs_list_new();
+                    /* store */
+                    static_put(&snac, id, payload + fo, fs);
 
 
-                l = xs_list_append(l, url);
-                l = xs_list_append(l, alt_text);
+                    xs *l = xs_list_new();
 
 
-                attach_list = xs_list_append(attach_list, l);
+                    l = xs_list_append(l, url);
+                    l = xs_list_append(l, alt_text);
+
+                    attach_list = xs_list_append(attach_list, l);
+                }
             }
             }
         }
         }
 
 

+ 2 - 1
httpd.c

@@ -219,7 +219,8 @@ int server_get_handler(xs_dict *req, const char *q_path,
         if (xs_type(q_vars) == XSTYPE_DICT && (t = xs_dict_get(q_vars, "t"))) {
         if (xs_type(q_vars) == XSTYPE_DICT && (t = xs_dict_get(q_vars, "t"))) {
             /** search by tag **/
             /** search by tag **/
             int skip = 0;
             int skip = 0;
-            int show = xs_number_get(xs_dict_get(srv_config, "max_timeline_entries"));
+            int show = xs_number_get(xs_dict_get_def(srv_config, "def_timeline_entries",
+                                 xs_dict_get_def(srv_config, "max_timeline_entries", "50")));
             const char *v;
             const char *v;
 
 
             if ((v = xs_dict_get(q_vars, "skip")) != NULL)
             if ((v = xs_dict_get(q_vars, "skip")) != NULL)

+ 37 - 12
main.c

@@ -11,6 +11,7 @@
 #include "snac.h"
 #include "snac.h"
 
 
 #include <sys/stat.h>
 #include <sys/stat.h>
+#include <sys/wait.h>
 
 
 int usage(void)
 int usage(void)
 {
 {
@@ -49,6 +50,7 @@ int usage(void)
     printf("unblock {basedir} {instance_url}     Unblocks 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("limit {basedir} {uid} {actor}        Limits an actor (drops their announces)\n");
     printf("unlimit {basedir} {uid} {actor}      Unlimits an actor\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("verify_links {basedir} {uid}         Verifies a user's links (in the metadata)\n");
     printf("search {basedir} {uid} {regex}       Searches posts by content\n");
     printf("search {basedir} {uid} {regex}       Searches posts by content\n");
     printf("export_csv {basedir} {uid}           Exports data as CSV files\n");
     printf("export_csv {basedir} {uid}           Exports data as CSV files\n");
@@ -446,6 +448,18 @@ int main(int argc, char *argv[])
         return 0;
         return 0;
     }
     }
 
 
+    if (strcmp(cmd, "unmute") == 0) { /** **/
+        if (is_muted(&snac, url)) {
+            unmute(&snac, url);
+
+            printf("%s unmuted\n", url);
+        }
+        else
+            printf("%s actor is not muted\n", url);
+
+        return 0;
+    }
+
     if (strcmp(cmd, "search") == 0) { /** **/
     if (strcmp(cmd, "search") == 0) { /** **/
         int to;
         int to;
 
 
@@ -663,19 +677,25 @@ int main(int argc, char *argv[])
 
 
         if (strcmp(url, "-e") == 0) {
         if (strcmp(url, "-e") == 0) {
             /* get the content from an editor */
             /* get the content from an editor */
+#define EDITOR "$EDITOR "
+            char cmd[] = EDITOR "/tmp/snac-XXXXXX";
             FILE *f;
             FILE *f;
-
-            unlink("/tmp/snac-edit.txt");
-            system("$EDITOR /tmp/snac-edit.txt");
-
-            if ((f = fopen("/tmp/snac-edit.txt", "r")) != NULL) {
-                content = xs_readall(f);
-                fclose(f);
-
-                unlink("/tmp/snac-edit.txt");
-            }
-            else {
-                printf("Nothing to send\n");
+            int fd = mkstemp(cmd + strlen(EDITOR));
+
+            if (fd >= 0) {
+                int status = system(cmd);
+
+                if (WIFEXITED(status) && WEXITSTATUS(status) == 0 && (f = fdopen(fd, "r")) != NULL) {
+                    content = xs_readall(f);
+                    fclose(f);
+                    unlink(cmd + strlen(EDITOR));
+                } else {
+                    printf("Nothing to send\n");
+                    close(fd);
+                    return 1;
+                }
+            } else {
+                fprintf(stderr, "Temp file creation failed\n");
                 return 1;
                 return 1;
             }
             }
         }
         }
@@ -687,6 +707,11 @@ int main(int argc, char *argv[])
         else
         else
             content = xs_dup(url);
             content = xs_dup(url);
 
 
+        if (!content || !*content) {
+            printf("Nothing to send\n");
+            return 1;
+        }
+
         int scope = 0;
         int scope = 0;
         if (strcmp(cmd, "note_mention") == 0)
         if (strcmp(cmd, "note_mention") == 0)
             scope = 1;
             scope = 1;

+ 7 - 2
mastoapi.c

@@ -1676,7 +1676,7 @@ int mastoapi_get_handler(const xs_dict *req, const char *q_path,
                 else
                 else
                 if (strcmp(opt, "statuses") == 0) { /** **/
                 if (strcmp(opt, "statuses") == 0) { /** **/
                     /* the public list of posts of a user */
                     /* the public list of posts of a user */
-                    xs *timeline = timeline_simple_list(&snac2, "public", 0, 256);
+                    xs *timeline = timeline_simple_list(&snac2, "public", 0, 256, NULL);
                     xs_list *p   = timeline;
                     xs_list *p   = timeline;
                     const xs_str *v;
                     const xs_str *v;
 
 
@@ -2171,7 +2171,12 @@ int mastoapi_get_handler(const xs_dict *req, const char *q_path,
 
 
         {
         {
             xs *d11 = xs_json_loads("{\"characters_reserved_per_url\":32,"
             xs *d11 = xs_json_loads("{\"characters_reserved_per_url\":32,"
-                "\"max_characters\":100000,\"max_media_attachments\":8}");
+                "\"max_characters\":100000,\"max_media_attachments\":4}");
+
+            const xs_number *max_attachments = xs_dict_get(srv_config, "max_attachments");
+            if (xs_type(max_attachments) == XSTYPE_NUMBER)
+                d11 = xs_dict_set(d11, "max_media_attachments", max_attachments);
+
             cfg = xs_dict_append(cfg, "statuses", d11);
             cfg = xs_dict_append(cfg, "statuses", d11);
 
 
             xs *d12 = xs_json_loads("{\"max_featured_tags\":0}");
             xs *d12 = xs_json_loads("{\"max_featured_tags\":0}");

+ 1 - 1
snac.c

@@ -69,7 +69,7 @@ xs_str *tid(int offset)
 
 
     gettimeofday(&tv, NULL);
     gettimeofday(&tv, NULL);
 
 
-    return xs_fmt("%10d.%06d", tv.tv_sec + offset, tv.tv_usec);
+    return xs_fmt("%010ld.%06ld", (long)tv.tv_sec + (long)offset, (long)tv.tv_usec);
 }
 }
 
 
 
 

+ 7 - 3
snac.h

@@ -1,7 +1,7 @@
 /* snac - A simple, minimalistic ActivityPub instance */
 /* snac - A simple, minimalistic ActivityPub instance */
 /* copyright (c) 2022 - 2025 grunfink et al. / MIT license */
 /* copyright (c) 2022 - 2025 grunfink et al. / MIT license */
 
 
-#define VERSION "2.69"
+#define VERSION "2.72"
 
 
 #define USER_AGENT "snac/" VERSION
 #define USER_AGENT "snac/" VERSION
 
 
@@ -22,6 +22,8 @@
 
 
 #define MD5_HEX_SIZE 33
 #define MD5_HEX_SIZE 33
 
 
+#define MD5_ALREADY_SEEN_MARK "00000000000000000000000000000000"
+
 extern double disk_layout;
 extern double disk_layout;
 extern xs_str *srv_basedir;
 extern xs_str *srv_basedir;
 extern xs_dict *srv_config;
 extern xs_dict *srv_config;
@@ -157,12 +159,14 @@ int timeline_here(snac *snac, const char *md5);
 int timeline_get_by_md5(snac *snac, const char *md5, xs_dict **msg);
 int timeline_get_by_md5(snac *snac, const char *md5, xs_dict **msg);
 int timeline_del(snac *snac, const char *id);
 int timeline_del(snac *snac, const char *id);
 xs_str *user_index_fn(snac *user, const char *idx_name);
 xs_str *user_index_fn(snac *user, const char *idx_name);
-xs_list *timeline_simple_list(snac *user, const char *idx_name, int skip, int show);
-xs_list *timeline_list(snac *snac, const char *idx_name, int skip, int show);
+xs_list *timeline_simple_list(snac *user, const char *idx_name, int skip, int show, int *more);
+xs_list *timeline_list(snac *snac, const char *idx_name, int skip, int show, int *more);
 int timeline_add(snac *snac, const char *id, const xs_dict *o_msg);
 int timeline_add(snac *snac, const char *id, const xs_dict *o_msg);
 int timeline_admire(snac *snac, const char *id, const char *admirer, int like);
 int timeline_admire(snac *snac, const char *id, const char *admirer, int like);
 
 
 xs_list *timeline_top_level(snac *snac, const xs_list *list);
 xs_list *timeline_top_level(snac *snac, const xs_list *list);
+void timeline_add_mark(snac *user);
+
 xs_list *local_list(snac *snac, int max);
 xs_list *local_list(snac *snac, int max);
 xs_str *instance_index_fn(void);
 xs_str *instance_index_fn(void);
 xs_list *timeline_instance_list(int skip, int show);
 xs_list *timeline_instance_list(int skip, int show);

+ 3 - 0
utils.c

@@ -28,6 +28,7 @@ static const char *default_srv_config = "{"
     "\"queue_timeout\":        6,"
     "\"queue_timeout\":        6,"
     "\"queue_timeout_2\":      8,"
     "\"queue_timeout_2\":      8,"
     "\"cssurls\":              [\"\"],"
     "\"cssurls\":              [\"\"],"
+    "\"def_timeline_entries\": 50,"
     "\"max_timeline_entries\": 50,"
     "\"max_timeline_entries\": 50,"
     "\"timeline_purge_days\":  120,"
     "\"timeline_purge_days\":  120,"
     "\"local_purge_days\":     0,"
     "\"local_purge_days\":     0,"
@@ -36,6 +37,7 @@ static const char *default_srv_config = "{"
     "\"admin_account\":        \"\","
     "\"admin_account\":        \"\","
     "\"title\":                \"\","
     "\"title\":                \"\","
     "\"short_description\":    \"\","
     "\"short_description\":    \"\","
+    "\"short_description_raw\": false,"
     "\"protocol\":             \"https\","
     "\"protocol\":             \"https\","
     "\"fastcgi\":              false"
     "\"fastcgi\":              false"
     "}";
     "}";
@@ -72,6 +74,7 @@ static const char *default_css =
     ".snac-list-of-lists { padding-left: 0; }\n"
     ".snac-list-of-lists { padding-left: 0; }\n"
     ".snac-list-of-lists li { display: inline; border: 1px solid #a0a0a0; border-radius: 25px;\n"
     ".snac-list-of-lists li { display: inline; border: 1px solid #a0a0a0; border-radius: 25px;\n"
     "  margin-right: 0.5em; padding-left: 0.5em; padding-right: 0.5em; }\n"
     "  margin-right: 0.5em; padding-left: 0.5em; padding-right: 0.5em; }\n"
+    ".snac-no-more-unseen-posts { border-top: 1px solid #a0a0a0; border-bottom: 1px solid #a0a0a0; padding: 0.5em 0; margin: 1em 0; }\n"
     "@media (prefers-color-scheme: dark) { \n"
     "@media (prefers-color-scheme: dark) { \n"
     "  body, input, textarea { background-color: #000; color: #fff; }\n"
     "  body, input, textarea { background-color: #000; color: #fff; }\n"
     "  a { color: #7799dd }\n"
     "  a { color: #7799dd }\n"

+ 28 - 8
xs.h

@@ -12,6 +12,7 @@
 #include <stdarg.h>
 #include <stdarg.h>
 #include <signal.h>
 #include <signal.h>
 #include <errno.h>
 #include <errno.h>
+#include <stdint.h>
 
 
 typedef enum {
 typedef enum {
     XSTYPE_STRING = 0x02,       /* C string (\0 delimited) (NOT STORED) */
     XSTYPE_STRING = 0x02,       /* C string (\0 delimited) (NOT STORED) */
@@ -142,6 +143,7 @@ void xs_data_get(void *data, const xs_data *value);
 void *xs_memmem(const char *haystack, int h_size, const char *needle, int n_size);
 void *xs_memmem(const char *haystack, int h_size, const char *needle, int n_size);
 
 
 unsigned int xs_hash_func(const char *data, int size);
 unsigned int xs_hash_func(const char *data, int size);
+uint64_t xs_hash64_func(const char *data, int size);
 
 
 #ifdef XS_ASSERT
 #ifdef XS_ASSERT
 #include <assert.h>
 #include <assert.h>
@@ -632,7 +634,7 @@ xs_str *xs_crop_i(xs_str *str, int start, int end)
         end = sz + end;
         end = sz + end;
 
 
     /* crop from the top */
     /* crop from the top */
-    if (end > 0 && end < sz)
+    if (end >= 0 && end < sz)
         str[end] = '\0';
         str[end] = '\0';
 
 
     /* crop from the bottom */
     /* crop from the bottom */
@@ -989,16 +991,20 @@ xs_str *xs_join(const xs_list *list, const char *sep)
 xs_list *xs_split_n(const char *str, const char *sep, int times)
 xs_list *xs_split_n(const char *str, const char *sep, int times)
 /* splits a string into a list upto n times */
 /* splits a string into a list upto n times */
 {
 {
+    xs_list *list = xs_list_new();
+
+    if (!xs_is_string(str) || !xs_is_string(sep))
+        return list;
+
     int sz = strlen(sep);
     int sz = strlen(sep);
     char *ss;
     char *ss;
-    xs_list *list;
-
-    list = xs_list_new();
 
 
     while (times > 0 && (ss = strstr(str, sep)) != NULL) {
     while (times > 0 && (ss = strstr(str, sep)) != NULL) {
         /* create a new string with this slice and add it to the list */
         /* create a new string with this slice and add it to the list */
         xs *s = xs_str_new_sz(str, ss - str);
         xs *s = xs_str_new_sz(str, ss - str);
-        list = xs_list_append(list, s);
+
+        if (xs_is_string(s))
+            list = xs_list_append(list, s);
 
 
         /* skip past the separator */
         /* skip past the separator */
         str = ss + sz;
         str = ss + sz;
@@ -1007,7 +1013,8 @@ xs_list *xs_split_n(const char *str, const char *sep, int times)
     }
     }
 
 
     /* add the rest of the string */
     /* add the rest of the string */
-    list = xs_list_append(list, str);
+    if (xs_is_string(str))
+        list = xs_list_append(list, str);
 
 
     return list;
     return list;
 }
 }
@@ -1487,9 +1494,8 @@ unsigned int xs_hash_func(const char *data, int size)
 /* a general purpose hashing function */
 /* a general purpose hashing function */
 {
 {
     unsigned int hash = 0x666;
     unsigned int hash = 0x666;
-    int n;
 
 
-    for (n = 0; n < size; n++) {
+    for (int n = 0; n < size; n++) {
         hash ^= (unsigned char)data[n];
         hash ^= (unsigned char)data[n];
         hash *= 111111111;
         hash *= 111111111;
     }
     }
@@ -1498,6 +1504,20 @@ unsigned int xs_hash_func(const char *data, int size)
 }
 }
 
 
 
 
+uint64_t xs_hash64_func(const char *data, int size)
+/* a general purpose hashing function (64 bit) */
+{
+    uint64_t hash = 0x100;
+
+    for (int n = 0; n < size; n++) {
+        hash ^= (unsigned char)data[n];
+        hash *= 1111111111111111111;
+    }
+
+    return hash;
+}
+
+
 #endif /* XS_IMPLEMENTATION */
 #endif /* XS_IMPLEMENTATION */
 
 
 #endif /* _XS_H */
 #endif /* _XS_H */

+ 3 - 0
xs_fcgi.h

@@ -173,6 +173,9 @@ xs_dict *xs_fcgi_request(FILE *f, xs_str **payload, int *p_size, int *fcgi_id)
                     xs *v = xs_str_new_sz((char *)&buf[offset], vsz);
                     xs *v = xs_str_new_sz((char *)&buf[offset], vsz);
                     offset += vsz;
                     offset += vsz;
 
 
+                    if (!xs_is_string(k) || !xs_is_string(v))
+                        continue;
+
                     cgi_vars = xs_dict_append(cgi_vars, k, v);
                     cgi_vars = xs_dict_append(cgi_vars, k, v);
 
 
                     if (strcmp(k, "REQUEST_METHOD") == 0)
                     if (strcmp(k, "REQUEST_METHOD") == 0)

+ 2 - 2
xs_html.h

@@ -114,7 +114,7 @@ xs_html *xs_html_text(const char *content)
     xs_html *a = XS_HTML_NEW();
     xs_html *a = XS_HTML_NEW();
 
 
     a->type    = XS_HTML_TEXT;
     a->type    = XS_HTML_TEXT;
-    a->content = xs_html_encode(content);
+    a->content = xs_is_string(content) ? xs_html_encode(content) : xs_str_new(NULL);
 
 
     return a;
     return a;
 }
 }
@@ -126,7 +126,7 @@ xs_html *xs_html_raw(const char *content)
     xs_html *a = XS_HTML_NEW();
     xs_html *a = XS_HTML_NEW();
 
 
     a->type    = XS_HTML_TEXT;
     a->type    = XS_HTML_TEXT;
-    a->content = xs_dup(content);
+    a->content = xs_is_string(content) ? xs_dup(content) : xs_str_new(NULL);
 
 
     return a;
     return a;
 }
 }

+ 34 - 20
xs_httpd.h

@@ -15,41 +15,48 @@ xs_dict *xs_httpd_request(FILE *f, xs_str **payload, int *p_size)
 {
 {
     xs *q_vars = NULL;
     xs *q_vars = NULL;
     xs *p_vars = NULL;
     xs *p_vars = NULL;
-    xs *l1, *l2;
+    xs *l1;
     const char *v;
     const char *v;
+    char *saveptr;
 
 
     xs_socket_timeout(fileno(f), 2.0, 0.0);
     xs_socket_timeout(fileno(f), 2.0, 0.0);
 
 
     /* read the first line and split it */
     /* read the first line and split it */
     l1 = xs_strip_i(xs_readline(f));
     l1 = xs_strip_i(xs_readline(f));
-    l2 = xs_split(l1, " ");
+    char *raw_path;
+    const char *mtd;
+    const char *proto;
+
+    if (!(mtd = strtok_r(l1, " ", &saveptr)) ||
+        !(raw_path = strtok_r(NULL, " ", &saveptr)) ||
+        !(proto = strtok_r(NULL, " ", &saveptr)) ||
+        strtok_r(NULL, " ", &saveptr))
+        return NULL;
 
 
-    if (xs_list_len(l2) != 3) {
-        /* error or timeout */
+    if (!xs_is_string(mtd) || !xs_is_string(raw_path) || !xs_is_string(proto))
         return NULL;
         return NULL;
-    }
 
 
     xs_dict *req = xs_dict_new();
     xs_dict *req = xs_dict_new();
 
 
-    req = xs_dict_append(req, "method", xs_list_get(l2, 0));
-    req = xs_dict_append(req, "raw_path", xs_list_get(l2, 1));
-    req = xs_dict_append(req, "proto",  xs_list_get(l2, 2));
+    req = xs_dict_append(req, "method", mtd);
+    req = xs_dict_append(req, "raw_path", raw_path);
+    req = xs_dict_append(req, "proto",  proto);
 
 
     {
     {
-        /* split the path with its optional variables */
-        const xs_val *udp = xs_list_get(l2, 1);
-        xs *pnv = xs_split_n(udp, "?", 1);
-
-        /* store the path */
-        req = xs_dict_append(req, "path", xs_list_get(pnv, 0));
+        char *q = strchr(raw_path, '?');
 
 
         /* get the variables */
         /* get the variables */
-        q_vars = xs_url_vars(xs_list_get(pnv, 1));
+        if (q) {
+                *q++ = '\0';
+                q_vars = xs_url_vars(q);
+        }
+        /* store the path */
+        req = xs_dict_append(req, "path", raw_path);
     }
     }
 
 
     /* read the headers */
     /* read the headers */
     for (;;) {
     for (;;) {
-        xs *l, *p = NULL;
+        xs *l;
 
 
         l = xs_strip_i(xs_readline(f));
         l = xs_strip_i(xs_readline(f));
 
 
@@ -58,11 +65,18 @@ xs_dict *xs_httpd_request(FILE *f, xs_str **payload, int *p_size)
             break;
             break;
 
 
         /* split header and content */
         /* split header and content */
-        p = xs_split_n(l, ": ", 1);
+        char *cnt = strchr(l, ':');
+        if (!cnt)
+            continue;
+
+        *cnt++ = '\0';
+        cnt += strspn(cnt, " \r\n\t\v\f");
+        l = xs_rstrip_chars_i(l, " \r\n\t\v\f");
+
+        if (!xs_is_string(cnt))
+            continue;
 
 
-        if (xs_list_len(p) == 2)
-            req = xs_dict_append(req, xs_tolower_i(
-                    (xs_str *)xs_list_get(p, 0)), xs_list_get(p, 1));
+        req = xs_dict_append(req, xs_tolower_i(l), cnt);
     }
     }
 
 
     xs_socket_timeout(fileno(f), 5.0, 0.0);
     xs_socket_timeout(fileno(f), 5.0, 0.0);

+ 3 - 4
xs_io.h

@@ -14,7 +14,7 @@ xs_val *xs_readall(FILE *f);
 xs_str *xs_readline(FILE *f)
 xs_str *xs_readline(FILE *f)
 /* reads a line from a file */
 /* reads a line from a file */
 {
 {
-    xs_str *s = NULL;
+    xs_str *s = xs_str_new(NULL);
 
 
     errno = 0;
     errno = 0;
 
 
@@ -22,12 +22,11 @@ xs_str *xs_readline(FILE *f)
     if (!feof(f)) {
     if (!feof(f)) {
         int c;
         int c;
 
 
-        s = xs_str_new(NULL);
-
         while ((c = fgetc(f)) != EOF) {
         while ((c = fgetc(f)) != EOF) {
             unsigned char rc = c;
             unsigned char rc = c;
 
 
-            s = xs_append_m(s, (char *)&rc, 1);
+            if (xs_is_string((char *)&rc))
+                s = xs_append_m(s, (char *)&rc, 1);
 
 
             if (c == '\n')
             if (c == '\n')
                 break;
                 break;

+ 6 - 0
xs_json.h

@@ -280,6 +280,12 @@ static xs_val *_xs_json_load_lexer(FILE *f, js_type *t)
             else {
             else {
                 char cc = c;
                 char cc = c;
                 v = xs_insert_m(v, offset, &cc, 1);
                 v = xs_insert_m(v, offset, &cc, 1);
+
+                if (!xs_is_string(v)) {
+                    *t = JS_ERROR;
+                    break;
+                }
+
                 offset++;
                 offset++;
             }
             }
         }
         }

+ 6 - 1
xs_match.h

@@ -24,6 +24,7 @@ int xs_match(const char *str, const char *spec)
 retry:
 retry:
 
 
     for (;;) {
     for (;;) {
+        const char *q = spec;
         char c = *str++;
         char c = *str++;
         char p = *spec++;
         char p = *spec++;
 
 
@@ -63,8 +64,12 @@ retry:
                     spec = b_spec;
                     spec = b_spec;
                     str  = ++b_str;
                     str  = ++b_str;
                 }
                 }
-                else
+                else {
+                    if (*q == '|')
+                        spec = q;
+
                     break;
                     break;
+                }
             }
             }
         }
         }
     }
     }

+ 1 - 1
xs_openssl.h

@@ -83,7 +83,7 @@ xs_val *xs_base64_dec(const xs_str *data, int *size)
     s = xs_realloc(s, _xs_blk_size(*size + 1));
     s = xs_realloc(s, _xs_blk_size(*size + 1));
     s[*size] = '\0';
     s[*size] = '\0';
 
 
-    BIO_free_all(mem);
+    BIO_free_all(b64);
 
 
     return s;
     return s;
 }
 }

+ 2 - 0
xs_socket.h

@@ -85,6 +85,8 @@ int xs_socket_server(const char *addr, const char *serv)
       listen(rs, SOMAXCONN);
       listen(rs, SOMAXCONN);
     }
     }
 
 
+    freeaddrinfo(res);
+
 #else /* WITHOUT_GETADDRINFO */
 #else /* WITHOUT_GETADDRINFO */
    struct sockaddr_in host;
    struct sockaddr_in host;
 
 

+ 80 - 37
xs_url.h

@@ -11,18 +11,57 @@ xs_dict *xs_multipart_form_data(const char *payload, int p_size, const char *hea
 
 
 #ifdef XS_IMPLEMENTATION
 #ifdef XS_IMPLEMENTATION
 
 
+char *xs_url_dec_in(char *str, int qs)
+{
+    char *w = str;
+    char *r;
+
+    for (r = str; *r != '\0'; r++) {
+        switch (*r) {
+        case '%': {
+            unsigned hex;
+            if (!r[1] || !r[2])
+                return NULL;
+            if (sscanf(r + 1, "%2x", &hex) != 1)
+                return NULL;
+            *w++ = hex;
+            r += 2;
+            break;
+        }
+
+        case '+':
+            if (qs) {
+                *w++ = ' ';
+                break;
+            }
+            /* fall-through */
+        default:
+            *w++ = *r;
+        }
+    }
+
+    *w++ = '\0';
+    return str;
+}
+
 xs_str *xs_url_dec(const char *str)
 xs_str *xs_url_dec(const char *str)
 /* decodes an URL */
 /* decodes an URL */
 {
 {
     xs_str *s = xs_str_new(NULL);
     xs_str *s = xs_str_new(NULL);
 
 
     while (*str) {
     while (*str) {
+        if (!xs_is_string(str))
+            break;
+
         if (*str == '%') {
         if (*str == '%') {
             unsigned int i;
             unsigned int i;
 
 
             if (sscanf(str + 1, "%02x", &i) == 1) {
             if (sscanf(str + 1, "%02x", &i) == 1) {
                 unsigned char uc = i;
                 unsigned char uc = i;
 
 
+                if (!xs_is_string((char *)&uc))
+                    break;
+
                 s = xs_append_m(s, (char *)&uc, 1);
                 s = xs_append_m(s, (char *)&uc, 1);
                 str += 2;
                 str += 2;
             }
             }
@@ -69,43 +108,45 @@ xs_dict *xs_url_vars(const char *str)
 
 
     vars = xs_dict_new();
     vars = xs_dict_new();
 
 
-    if (str != NULL) {
-        /* split by arguments */
-        xs *args = xs_split(str, "&");
-
-        const xs_val *v;
-
-        xs_list_foreach(args, v) {
-            xs *dv = xs_url_dec(v);
-            xs *kv = xs_split_n(dv, "=", 1);
-
-            if (xs_list_len(kv) == 2) {
-                const char *key = xs_list_get(kv, 0);
-                const char *pv  = xs_dict_get(vars, key);
-
-                if (!xs_is_null(pv)) {
-                    /* there is a previous value: convert to a list and append */
-                    xs *vlist = NULL;
-                    if (xs_type(pv) == XSTYPE_LIST)
-                        vlist = xs_dup(pv);
-                    else {
-                        vlist = xs_list_new();
-                        vlist = xs_list_append(vlist, pv);
-                    }
-
-                    vlist = xs_list_append(vlist, xs_list_get(kv, 1));
-                    vars  = xs_dict_set(vars, key, vlist);
-                }
+    if (xs_is_string(str)) {
+        xs *dup = xs_dup(str);
+        char *k;
+        char *saveptr;
+        for (k = strtok_r(dup, "&", &saveptr);
+             k;
+             k = strtok_r(NULL, "&", &saveptr)) {
+            char *v = strchr(k, '=');
+            if (!v)
+                continue;
+            *v++ = '\0';
+            k = xs_url_dec_in(k, 1);
+            v = xs_url_dec_in(v, 1);
+            if (!xs_is_string(k) || !xs_is_string(v))
+                continue;
+
+            const char *pv  = xs_dict_get(vars, k);
+            if (!xs_is_null(pv)) {
+                /* there is a previous value: convert to a list and append */
+                xs *vlist = NULL;
+                if (xs_type(pv) == XSTYPE_LIST)
+                    vlist = xs_dup(pv);
                 else {
                 else {
-                    /* ends with []? force to always be a list */
-                    if (xs_endswith(key, "[]")) {
-                        xs *vlist = xs_list_new();
-                        vlist = xs_list_append(vlist, xs_list_get(kv, 1));
-                        vars = xs_dict_append(vars, key, vlist);
-                    }
-                    else
-                        vars = xs_dict_append(vars, key, xs_list_get(kv, 1));
+                    vlist = xs_list_new();
+                    vlist = xs_list_append(vlist, pv);
+                }
+
+                vlist = xs_list_append(vlist, v);
+                vars  = xs_dict_set(vars, k, vlist);
+            }
+            else {
+                /* ends with []? force to always be a list */
+                if (xs_endswith(k, "[]")) {
+                    xs *vlist = xs_list_new();
+                    vlist = xs_list_append(vlist, v);
+                    vars = xs_dict_append(vars, k, vlist);
                 }
                 }
+                else
+                    vars = xs_dict_append(vars, k, v);
             }
             }
         }
         }
     }
     }
@@ -233,7 +274,8 @@ xs_dict *xs_multipart_form_data(const char *payload, int p_size, const char *hea
             l1 = xs_list_append(l1, vpo);
             l1 = xs_list_append(l1, vpo);
             l1 = xs_list_append(l1, vps);
             l1 = xs_list_append(l1, vps);
 
 
-            p_vars = xs_dict_append(p_vars, vn, l1);
+            if (xs_is_string(vn))
+                p_vars = xs_dict_append(p_vars, vn, l1);
         }
         }
         else {
         else {
             /* regular variable; just copy */
             /* regular variable; just copy */
@@ -241,7 +283,8 @@ xs_dict *xs_multipart_form_data(const char *payload, int p_size, const char *hea
             memcpy(vc, payload + po, ps);
             memcpy(vc, payload + po, ps);
             vc[ps] = '\0';
             vc[ps] = '\0';
 
 
-            p_vars = xs_dict_append(p_vars, vn, vc);
+            if (xs_is_string(vn) && xs_is_string(vc))
+                p_vars = xs_dict_append(p_vars, vn, vc);
         }
         }
 
 
         /* move on */
         /* move on */

+ 1 - 1
xs_version.h

@@ -1 +1 @@
-/* b865e89769aedfdbc61251e94451e9d37579f52e 2025-01-12T16:17:47+01:00 */
+/* 2f43b93e9d2b63360c802e09f4c68adfef74c673 2025-01-28T07:40:50+01:00 */