commit 7611a6b

shtrophic  ·  2025-02-15 13:37:36 +0000 UTC
parents 3d96c57, 3d705b2
Merge tag '2.72' into curl-smtp

Version 2.72 RELEASED.
28 files changed,  +636, -225
M data.c
M html.c
M main.c
M snac.c
M snac.h
M xs.h
+2, -0
1@@ -107,6 +107,8 @@ This will:
2 - [How to install snac on OpenBSD without relayd (by @antics@mastodon.nu)](https://chai.guru/pub/openbsd/snac.html).
3 - [Setting up Snac in OpenBSD (by Yonle)](https://wiki.ircnow.org/index.php?n=Openbsd.Snac).
4 - [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.
5+- [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/).
6+- [Caching Snac Proxied Media With Nginx (by Stefano Marinelli)](https://it-notes.dragas.net/2025/02/08/caching-snac-proxied-media-with-nginx/).
7 
8 ## Incredibly awesome CSS themes for snac
9 
+38, -0
 1@@ -1,5 +1,43 @@
 2 # Release Notes
 3 
 4+## 2.72
 5+
 6+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).
 7+
 8+Each notification includes a link labelled `Context`, that leads to a page with the full conversation tree the post is a part of.
 9+
10+Each followed hashtag has now a directly accesible link.
11+
12+Fixed a search bug (some matches were missed).
13+
14+Fixed more crashes (contributed by inz).
15+
16+Fixed link detection in posts (contributed by inz).
17+
18+Allow multiple editors for command-line posts (contributed by inz).
19+
20+Separated maximum and default timeline entry count, allowing larger timelines to be requested without having to increase the default (contributed by lxo).
21+
22+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).
23+
24+Special thanks to fellow developer inz for bringing my attention to code places where I should have been more careful.
25+
26+## 2.71
27+
28+Fixed memory leak (contributed by inz).
29+
30+Fixed crash.
31+
32+## 2.70
33+
34+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).
35+
36+New command-line option `unmute` to, well, no-longer-mute an actor.
37+
38+The private timeline now includes an approximate mark between new posts and "already seen" ones.
39+
40+Fixed a spurious 404 error in the instance root URL for some configurations.
41+
42 ## 2.69 "Yin/Yang of Love"
43 
44 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
 1@@ -14,14 +14,12 @@ Important: deleting a follower should do more that just delete the object, see h
 2 
 3 ## Wishlist
 4 
 5-Add support for subscribing and posting to relays (see https://codeberg.org/grunfink/snac2/issues/216 for more information).
 6+Each notification should show a link to the full thread, to see it in context.
 7 
 8 The instance timeline should also show boosts from users.
 9 
10 Mastoapi: implement /v1/conversations.
11 
12-Implement following of hashtags (this is not trivial).
13-
14 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).
15 
16 Implement "FEP-3b86: Activity Intents" https://codeberg.org/fediverse/fep/src/branch/main/fep/3b86/fep-3b86.md
17@@ -365,3 +363,7 @@ CSV import/export does not work with OpenBSD security on; document it or fix it
18 Add support for /share?text=tt&website=url (whatever it is, see https://mastodonshare.com/ for details) (2025-01-06T18:43:52+0100).
19 
20 Add support for /authorize_interaction (whatever it is) (2025-01-16T14:45:28+0100).
21+
22+Implement following of hashtags (this is not trivial) (2025-01-30T16:12:16+0100).
23+
24+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
1@@ -3072,7 +3072,7 @@ int activitypub_get_handler(const xs_dict *req, const char *q_path,
2         int cnt = xs_number_get(xs_dict_get_def(srv_config, "max_public_entries", "20"));
3 
4         /* get the public outbox or the pinned list */
5-        xs *elems = *p_path == 'o' ? timeline_simple_list(&snac, "public", 0, cnt) : pinned_list(&snac);
6+        xs *elems = *p_path == 'o' ? timeline_simple_list(&snac, "public", 0, cnt, NULL) : pinned_list(&snac);
7 
8         xs_list_foreach(elems, v) {
9             xs *i = NULL;
M data.c
+68, -14
  1@@ -1399,11 +1399,13 @@ void timeline_update_indexes(snac *snac, const char *id)
  2         if (valid_status(object_get(id, &msg))) {
  3             /* if its ours and is public, also store in public */
  4             if (is_msg_public(msg)) {
  5-                object_user_cache_add(snac, id, "public");
  6-
  7-                /* also add it to the instance public timeline */
  8-                xs *ipt = xs_fmt("%s/public.idx", srv_basedir);
  9-                index_add(ipt, id);
 10+                if (object_user_cache_add(snac, id, "public") >= 0) {
 11+                    /* also add it to the instance public timeline */
 12+                    xs *ipt = xs_fmt("%s/public.idx", srv_basedir);
 13+                    index_add(ipt, id);
 14+                }
 15+                else
 16+                    srv_debug(1, xs_fmt("Not added to public instance index %s", id));
 17             }
 18         }
 19     }
 20@@ -1487,16 +1489,28 @@ xs_str *user_index_fn(snac *user, const char *idx_name)
 21 }
 22 
 23 
 24-xs_list *timeline_simple_list(snac *user, const char *idx_name, int skip, int show)
 25+xs_list *timeline_simple_list(snac *user, const char *idx_name, int skip, int show, int *more)
 26 /* returns a timeline (with all entries) */
 27 {
 28     xs *idx = user_index_fn(user, idx_name);
 29 
 30-    return index_list_desc(idx, skip, show);
 31+    /* if a more flag is sent, request one more */
 32+    xs_list *lst = index_list_desc(idx, skip, show + (more != NULL ? 1 : 0));
 33+
 34+    if (more != NULL) {
 35+        if (xs_list_len(lst) > show) {
 36+            *more = 1;
 37+            lst = xs_list_del(lst, -1);
 38+        }
 39+        else
 40+            *more = 0;
 41+    }
 42+
 43+    return lst;
 44 }
 45 
 46 
 47-xs_list *timeline_list(snac *snac, const char *idx_name, int skip, int show)
 48+xs_list *timeline_list(snac *snac, const char *idx_name, int skip, int show, int *more)
 49 /* returns a timeline (only top level entries) */
 50 {
 51     int c_max;
 52@@ -1508,12 +1522,33 @@ xs_list *timeline_list(snac *snac, const char *idx_name, int skip, int show)
 53     if (show > c_max)
 54         show = c_max;
 55 
 56-    xs *list = timeline_simple_list(snac, idx_name, skip, show);
 57+    xs *list = timeline_simple_list(snac, idx_name, skip, show, more);
 58 
 59     return timeline_top_level(snac, list);
 60 }
 61 
 62 
 63+void timeline_add_mark(snac *user)
 64+/* adds an "already seen" mark to the private timeline */
 65+{
 66+    xs *fn = xs_fmt("%s/private.idx", user->basedir);
 67+    char last_entry[MD5_HEX_SIZE] = "";
 68+    FILE *f;
 69+
 70+    /* get the last entry in the index */
 71+    if ((f = fopen(fn, "r")) != NULL) {
 72+        index_desc_first(f, last_entry, 0);
 73+        fclose(f);
 74+    }
 75+
 76+    /* is the last entry *not* a mark? */
 77+    if (strcmp(last_entry, MD5_ALREADY_SEEN_MARK) != 0) {
 78+        /* add it */
 79+        index_add_md5(fn, MD5_ALREADY_SEEN_MARK);
 80+    }
 81+}
 82+
 83+
 84 xs_str *instance_index_fn(void)
 85 {
 86     return xs_fmt("%s/public.idx", srv_basedir);
 87@@ -1524,8 +1559,17 @@ xs_list *timeline_instance_list(int skip, int show)
 88 /* returns the timeline for the full instance */
 89 {
 90     xs *idx = instance_index_fn();
 91+    xs *lst = index_list_desc(idx, skip, show);
 92 
 93-    return index_list_desc(idx, skip, show);
 94+    /* make the list unique */
 95+    xs_set rep;
 96+    xs_set_init(&rep);
 97+    const char *md5;
 98+
 99+    xs_list_foreach(lst, md5)
100+        xs_set_add(&rep, md5);
101+
102+    return xs_set_result(&rep);
103 }
104 
105 
106@@ -2557,7 +2601,7 @@ xs_list *inbox_list(void)
107         if ((f = fopen(v, "r")) != NULL) {
108             xs *line = xs_readline(f);
109 
110-            if (line) {
111+            if (line && *line) {
112                 line = xs_strip_i(line);
113                 ibl  = xs_list_append(ibl, line);
114             }
115@@ -2698,9 +2742,9 @@ xs_list *content_search(snac *user, const char *regex,
116     const char *md5s[3] = {0};
117     int c[3] = {0};
118 
119-    tls[0] = timeline_simple_list(user, "public", 0, XS_ALL);   /* public */
120+    tls[0] = timeline_simple_list(user, "public", 0, XS_ALL, NULL);   /* public */
121     tls[1] = timeline_instance_list(0, XS_ALL); /* instance */
122-    tls[2] = priv ? timeline_simple_list(user, "private", 0, XS_ALL) : xs_list_new(); /* private or none */
123+    tls[2] = priv ? timeline_simple_list(user, "private", 0, XS_ALL, NULL) : xs_list_new(); /* private or none */
124 
125     /* first positioning */
126     for (int n = 0; n < 3; n++)
127@@ -2722,7 +2766,17 @@ xs_list *content_search(snac *user, const char *regex,
128         for (int n = 0; n < 3; n++) {
129             if (md5s[n] != NULL) {
130                 xs *fn = _object_fn_by_md5(md5s[n], "content_search");
131-                double mt = mtime(fn);
132+                double mt;
133+
134+                while ((mt = mtime(fn)) == 0 && md5s[n] != NULL) {
135+                    /* object is not here: move to the next one */
136+                    if (xs_list_next(tls[n], &md5s[n], &c[n])) {
137+                        xs_free(fn);
138+                        fn = _object_fn_by_md5(md5s[n], "content_search_2");
139+                    }
140+                    else
141+                        md5s[n] = NULL;
142+                }
143 
144                 if (mt > mtime) {
145                     newest = n;
+5, -0
 1@@ -234,6 +234,8 @@ Purges old data from the timeline of all users.
 2 .It Cm adduser Ar basedir Op uid
 3 Adds a new user to the server. This is an interactive command;
 4 necessary information will be prompted for.
 5+.It Cm deluser Ar basedir Ar uid
 6+Deletes a user, unfollowing all accounts first.
 7 .It Cm resetpwd Ar basedir Ar uid
 8 Resets a user's password to a new, random one.
 9 .It Cm queue Ar basedir Ar uid
10@@ -257,6 +259,9 @@ The rest of command line arguments are treated as media files to be
11 attached to the post.
12 .It Cm note_unlisted Ar basedir Ar uid Ar text Op file file ...
13 Like the previous one, but creates an "unlisted" (or "quiet public") post.
14+.It Cm note_mention Ar basedir Ar uid Ar text Op file file ...
15+Like the previous one, but creates a post only for accounts mentioned
16+in the post body.
17 .It Cm block Ar basedir Ar instance_url
18 Blocks a full instance, given its URL or domain name. All subsequent
19 incoming activities with identifiers from that instance will be immediately
+1, -1
1@@ -78,7 +78,7 @@ converted to related emojis:
2 .Ss Accepted HTML
3 All HTML tags in entries are neutered except the following ones:
4 .Bd -literal
5-a p br blockquote ul ol li cite small
6+a p br blockquote ul ol li cite small h2 h3
7 span i b u s pre code em strong hr img del
8 .Ed
9 .Pp
+6, -0
 1@@ -154,6 +154,8 @@ to those servers that went timeout in the previous retry. If you want to
 2 give slow servers a chance to receive your messages, you can increase this
 3 value (but also take into account that processing the queue will take longer
 4 while waiting for these molasses to respond).
 5+.It Ic def_timeline_entries
 6+This is the default timeline entries shown in the web interface.
 7 .It Ic max_timeline_entries
 8 This is the maximum timeline entries shown in the web interface.
 9 .It Ic timeline_purge_days
10@@ -205,6 +207,8 @@ The email address of the instance administrator (optional).
11 The user name of the instance administrator (optional).
12 .It Ic short_description
13 A textual short description about the instance (optional).
14+.It Ic short_description_raw
15+Whether to interpret short_descript as raw string or convert to HTML (optional).
16 .It Ic fastcgi
17 If set to true,
18 .Nm
19@@ -256,6 +260,8 @@ need at least a Linux kernel version 5.13.0.
20 .It Ic max_public_entries
21 The maximum number of entries (posts) to be returned in user RSS feeds and outboxes
22 (default: 20).
23+.It Ic max_attachments
24+The maximum number of attachments per post (default: 4).
25 .El
26 .Pp
27 You must restart the server to make effective these changes.
+1, -0
1@@ -29,6 +29,7 @@ pre { overflow-x: scroll; }
2 .snac-list-of-lists { padding-left: 0; }
3 .snac-list-of-lists li { display: inline; border: 1px solid #a0a0a0; border-radius: 25px;
4   margin-right: 0.5em; padding-left: 0.5em; padding-right: 0.5em; }
5+.snac-no-more-unseen-posts { border-top: 1px solid #a0a0a0; border-bottom: 1px solid #a0a0a0; padding: 0.5em 0; margin: 1em 0; }
6 @media (prefers-color-scheme: dark) { 
7   body, input, textarea { background-color: #000; color: #fff; }
8   a { color: #7799dd }
+4, -2
 1@@ -78,6 +78,8 @@ xs_dict *emojis(void)
 2     return d;
 3 }
 4 
 5+/* Non-whitespace without trailing comma, period or closing paren */
 6+#define NOSPACE "([^[:space:],.)]+|[,.)]+[^[:space:],.)])+"
 7 
 8 static xs_str *format_line(const char *line, xs_list **attach)
 9 /* formats a line */
10@@ -96,8 +98,8 @@ static xs_str *format_line(const char *line, xs_list **attach)
11             "__[^_]+__"                         "|" //anzu
12             "!\\[[^]]+\\]\\([^\\)]+\\)"         "|"
13             "\\[[^]]+\\]\\([^\\)]+\\)"          "|"
14-            "[a-z]+:/" "/[^[:space:]]+"         "|"
15-            "(mailto|xmpp):[^@[:space:]]+@[^[:space:]]+"
16+            "[a-z]+:/" "/" NOSPACE              "|"
17+            "(mailto|xmpp):[^@[:space:]]+@" NOSPACE
18         ")");
19     int n = 0;
20 
M html.c
+282, -111
  1@@ -13,6 +13,7 @@
  2 #include "xs_html.h"
  3 #include "xs_curl.h"
  4 #include "xs_unicode.h"
  5+#include "xs_url.h"
  6 
  7 #include "snac.h"
  8 
  9@@ -115,7 +116,8 @@ xs_str *actor_name(xs_dict *actor, const char *proxy)
 10 
 11 xs_html *html_actor_icon(snac *user, xs_dict *actor, const char *date,
 12                         const char *udate, const char *url, int priv,
 13-                        int in_people, const char *proxy, const char *lang)
 14+                        int in_people, const char *proxy, const char *lang,
 15+                        const char *md5)
 16 {
 17     xs_html *actor_icon = xs_html_tag("p", NULL);
 18 
 19@@ -224,12 +226,31 @@ xs_html *html_actor_icon(snac *user, xs_dict *actor, const char *date,
 20         if (xs_is_string(lang))
 21             date_title = xs_str_cat(date_title, " (", lang, ")");
 22 
 23+        xs_html *date_text = xs_html_text(date_label);
 24+
 25+        if (user && md5) {
 26+            xs *lpost_url = xs_fmt("%s/admin/p/%s#%s_entry",
 27+                                   user->actor, md5, md5);
 28+            date_text = xs_html_tag("a",
 29+                                    xs_html_attr("href", lpost_url),
 30+                                    xs_html_attr("class", "snac-pubdate"),
 31+                                    date_text);
 32+        }
 33+        else if (user && url) {
 34+            xs *lpost_url = xs_fmt("%s/admin?q=%s",
 35+                                   user->actor, xs_url_enc(url));
 36+            date_text = xs_html_tag("a",
 37+                                    xs_html_attr("href", lpost_url),
 38+                                    xs_html_attr("class", "snac-pubdate"),
 39+                                    date_text);
 40+        }
 41+
 42         xs_html_add(actor_icon,
 43             xs_html_text(" "),
 44             xs_html_tag("time",
 45                 xs_html_attr("class", "dt-published snac-pubdate"),
 46                 xs_html_attr("title", date_title),
 47-                xs_html_text(date_label)));
 48+                date_text));
 49     }
 50 
 51     {
 52@@ -261,7 +282,7 @@ xs_html *html_actor_icon(snac *user, xs_dict *actor, const char *date,
 53 }
 54 
 55 
 56-xs_html *html_msg_icon(snac *user, const char *actor_id, const xs_dict *msg, const char *proxy)
 57+xs_html *html_msg_icon(snac *user, const char *actor_id, const xs_dict *msg, const char *proxy, const char *md5)
 58 {
 59     xs *actor = NULL;
 60     xs_html *actor_icon = NULL;
 61@@ -292,7 +313,7 @@ xs_html *html_msg_icon(snac *user, const char *actor_id, const xs_dict *msg, con
 62         else
 63             lang = NULL;
 64 
 65-        actor_icon = html_actor_icon(user, actor, date, udate, url, priv, 0, proxy, lang);
 66+        actor_icon = html_actor_icon(user, actor, date, udate, url, priv, 0, proxy, lang, md5);
 67     }
 68 
 69     return actor_icon;
 70@@ -306,7 +327,7 @@ xs_html *html_note(snac *user, const char *summary,
 71                    const xs_val *cw_yn, const char *cw_text,
 72                    const xs_val *mnt_only, const char *redir,
 73                    const char *in_reply_to, int poll,
 74-                   const char *att_file, const char *att_alt_text,
 75+                   const xs_list *att_files, const xs_list *att_alt_texts,
 76                    int is_draft)
 77 /* Yes, this is a FUCKTON of arguments and I'm a bit embarrased */
 78 {
 79@@ -411,30 +432,71 @@ xs_html *html_note(snac *user, const char *summary,
 80         xs_html_tag("p", NULL),
 81         att = xs_html_tag("details",
 82             xs_html_tag("summary",
 83-                xs_html_text(L("Attachment..."))),
 84+                xs_html_text(L("Attachments..."))),
 85             xs_html_tag("p", NULL)));
 86 
 87-    if (att_file && *att_file)
 88+    int max_attachments = xs_number_get(xs_dict_get_def(srv_config, "max_attachments", "4"));
 89+    int att_n = 0;
 90+
 91+    /* fields for the currently existing attachments */
 92+    if (xs_is_list(att_files) && xs_is_list(att_alt_texts)) {
 93+        while (att_n < max_attachments) {
 94+            const char *att_file = xs_list_get(att_files, att_n);
 95+            const char *att_alt_text = xs_list_get(att_alt_texts, att_n);
 96+
 97+            if (!xs_is_string(att_file) || !xs_is_string(att_alt_text))
 98+                break;
 99+
100+            xs *att_lbl = xs_fmt("attach_url_%d", att_n);
101+            xs *alt_lbl = xs_fmt("alt_text_%d", att_n);
102+
103+            if (att_n)
104+                xs_html_add(att,
105+                    xs_html_sctag("br", NULL));
106+
107+            xs_html_add(att,
108+                xs_html_text(L("File:")),
109+                xs_html_sctag("input",
110+                    xs_html_attr("type", "text"),
111+                    xs_html_attr("name", att_lbl),
112+                    xs_html_attr("title", L("Clear this field to delete the attachment")),
113+                    xs_html_attr("value", att_file)));
114+
115+            xs_html_add(att,
116+                xs_html_text(" "),
117+                xs_html_sctag("input",
118+                    xs_html_attr("type",    "text"),
119+                    xs_html_attr("name",    alt_lbl),
120+                    xs_html_attr("value",   att_alt_text),
121+                    xs_html_attr("placeholder", L("Attachment description"))));
122+
123+            att_n++;
124+        }
125+    }
126+
127+    /* the rest of possible attachments */
128+    while (att_n < max_attachments) {
129+        xs *att_lbl = xs_fmt("attach_%d", att_n);
130+        xs *alt_lbl = xs_fmt("alt_text_%d", att_n);
131+
132+        if (att_n)
133+            xs_html_add(att,
134+                xs_html_sctag("br", NULL));
135+
136         xs_html_add(att,
137-            xs_html_text(L("File:")),
138             xs_html_sctag("input",
139-                xs_html_attr("type", "text"),
140-                xs_html_attr("name", "attach_url"),
141-                xs_html_attr("title", L("Clear this field to delete the attachment")),
142-                xs_html_attr("value", att_file)));
143-    else
144+                xs_html_attr("type",    "file"),
145+                xs_html_attr("name",    att_lbl)));
146+
147         xs_html_add(att,
148+            xs_html_text(" "),
149             xs_html_sctag("input",
150-                xs_html_attr("type",    "file"),
151-                xs_html_attr("name",    "attach")));
152+                xs_html_attr("type",    "text"),
153+                xs_html_attr("name",    alt_lbl),
154+                xs_html_attr("placeholder", L("Attachment description"))));
155 
156-    xs_html_add(att,
157-        xs_html_text(" "),
158-        xs_html_sctag("input",
159-            xs_html_attr("type",    "text"),
160-            xs_html_attr("name",    "alt_text"),
161-            xs_html_attr("value",   att_alt_text),
162-            xs_html_attr("placeholder", L("Attachment description"))));
163+        att_n++;
164+    }
165 
166     /* add poll controls */
167     if (poll) {
168@@ -553,10 +615,11 @@ xs_html *html_instance_head(void)
169 
170 static xs_html *html_instance_body(void)
171 {
172-    const char *host  = xs_dict_get(srv_config, "host");
173-    const char *sdesc = xs_dict_get(srv_config, "short_description");
174-    const char *email = xs_dict_get(srv_config, "admin_email");
175-    const char *acct  = xs_dict_get(srv_config, "admin_account");
176+    const char *host     = xs_dict_get(srv_config, "host");
177+    const char *sdesc    = xs_dict_get(srv_config, "short_description");
178+    const char *sdescraw = xs_dict_get(srv_config, "short_description_raw");
179+    const char *email    = xs_dict_get(srv_config, "admin_email");
180+    const char *acct     = xs_dict_get(srv_config, "admin_account");
181 
182     xs *blurb = xs_replace(snac_blurb, "%host%", host);
183 
184@@ -569,12 +632,21 @@ static xs_html *html_instance_body(void)
185             dl = xs_html_tag("dl", NULL)));
186 
187     if (sdesc && *sdesc) {
188-        xs_html_add(dl,
189-            xs_html_tag("di",
190-                xs_html_tag("dt",
191-                    xs_html_text(L("Site description"))),
192-                xs_html_tag("dd",
193-                    xs_html_text(sdesc))));
194+        if (!xs_is_null(sdescraw) && xs_type(sdescraw) == XSTYPE_TRUE) {
195+            xs_html_add(dl,
196+                xs_html_tag("di",
197+                    xs_html_tag("dt",
198+                        xs_html_text(L("Site description"))),
199+                    xs_html_tag("dd",
200+                        xs_html_raw(sdesc))));
201+        } else {
202+            xs_html_add(dl,
203+                xs_html_tag("di",
204+                    xs_html_tag("dt",
205+                        xs_html_text(L("Site description"))),
206+                    xs_html_tag("dd",
207+                        xs_html_text(sdesc))));
208+        }
209     }
210     if (email && *email) {
211         xs *mailto = xs_fmt("mailto:%s", email);
212@@ -1028,7 +1100,7 @@ xs_html *html_top_controls(snac *snac)
213             NULL, NULL,
214             xs_stock(XSTYPE_FALSE), "",
215             xs_stock(XSTYPE_FALSE), NULL,
216-            NULL, 1, "", "", 0),
217+            NULL, 1, NULL, NULL, 0),
218 
219         /** operations **/
220         xs_html_tag("details",
221@@ -1600,17 +1672,22 @@ xs_html *html_entry_controls(snac *snac, const char *actor,
222         xs *form_id = xs_fmt("%s_edit_form", md5);
223         xs *redir   = xs_fmt("%s_entry", md5);
224 
225-        const char *att_file = "";
226-        const char *att_alt_text = "";
227+        xs *att_files = xs_list_new();
228+        xs *att_alt_texts = xs_list_new();
229+
230         const xs_list *att_list = xs_dict_get(msg, "attachment");
231 
232-        /* does it have an attachment? */
233-        if (xs_type(att_list) == XSTYPE_LIST && xs_list_len(att_list)) {
234-            const xs_dict *d = xs_list_get(att_list, 0);
235+        if (xs_is_list(att_list)) {
236+            const xs_dict *d;
237+
238+            xs_list_foreach(att_list, d) {
239+                const char *att_file = xs_dict_get(d, "url");
240+                const char *att_alt_text = xs_dict_get(d, "name");
241 
242-            if (xs_type(d) == XSTYPE_DICT) {
243-                att_file = xs_dict_get_def(d, "url", "");
244-                att_alt_text = xs_dict_get_def(d, "name", "");
245+                if (xs_is_string(att_file) && xs_is_string(att_alt_text)) {
246+                    att_files = xs_list_append(att_files, att_file);
247+                    att_alt_texts = xs_list_append(att_alt_texts, att_alt_text);
248+                }
249             }
250         }
251 
252@@ -1622,7 +1699,7 @@ xs_html *html_entry_controls(snac *snac, const char *actor,
253                 id, NULL,
254                 xs_dict_get(msg, "sensitive"), xs_dict_get(msg, "summary"),
255                 xs_stock(is_msg_public(msg) ? XSTYPE_FALSE : XSTYPE_TRUE), redir,
256-                NULL, 0, att_file, att_alt_text, is_draft(snac, id))),
257+                NULL, 0, att_files, att_alt_texts, is_draft(snac, id))),
258             xs_html_tag("p", NULL));
259     }
260 
261@@ -1641,7 +1718,7 @@ xs_html *html_entry_controls(snac *snac, const char *actor,
262                 NULL, NULL,
263                 xs_dict_get(msg, "sensitive"), xs_dict_get(msg, "summary"),
264                 xs_stock(is_msg_public(msg) ? XSTYPE_FALSE : XSTYPE_TRUE), redir,
265-                id, 0, "", "", 0)),
266+                id, 0, NULL, NULL, 0)),
267             xs_html_tag("p", NULL));
268     }
269 
270@@ -1696,7 +1773,7 @@ xs_html *html_entry(snac *user, xs_dict *msg, int read_only,
271                 xs_html_tag("div",
272                     xs_html_attr("class", "snac-origin"),
273                     xs_html_text(L("follows you"))),
274-                html_msg_icon(read_only ? NULL : user, xs_dict_get(msg, "actor"), msg, proxy)));
275+                html_msg_icon(read_only ? NULL : user, xs_dict_get(msg, "actor"), msg, proxy, NULL)));
276     }
277     else
278     if (!xs_match(type, POSTLIKE_OBJECT_TYPE)) {
279@@ -1877,7 +1954,7 @@ xs_html *html_entry(snac *user, xs_dict *msg, int read_only,
280     }
281 
282     xs_html_add(post_header,
283-        html_msg_icon(read_only ? NULL : user, actor, msg, proxy));
284+        html_msg_icon(read_only ? NULL : user, actor, msg, proxy, md5));
285 
286     /** post content **/
287 
288@@ -2022,16 +2099,17 @@ xs_html *html_entry(snac *user, xs_dict *msg, int read_only,
289                 const char *name       = xs_dict_get(v, "name");
290                 const xs_dict *replies = xs_dict_get(v, "replies");
291 
292-                if (name && replies) {
293-                    char *ti = (char *)xs_number_str(xs_dict_get(replies, "totalItems"));
294+                if (xs_is_string(name) && xs_is_dict(replies)) {
295+                    const char *ti = xs_number_str(xs_dict_get(replies, "totalItems"));
296 
297-                    xs_html_add(poll_result,
298-                        xs_html_tag("tr",
299-                            xs_html_tag("td",
300-                                xs_html_text(name),
301-                                xs_html_text(":")),
302-                            xs_html_tag("td",
303-                                xs_html_text(ti))));
304+                    if (xs_is_string(ti))
305+                        xs_html_add(poll_result,
306+                            xs_html_tag("tr",
307+                                xs_html_tag("td",
308+                                    xs_html_text(name),
309+                                    xs_html_text(":")),
310+                                xs_html_tag("td",
311+                                    xs_html_text(ti))));
312                 }
313             }
314 
315@@ -2629,6 +2707,29 @@ xs_str *html_timeline(snac *user, const xs_list *list, int read_only,
316                         xs_html_attr("title", L("Post drafts")),
317                         xs_html_text("drafts"))));
318         }
319+
320+        /* the list of followed hashtags */
321+        const char *followed_hashtags = xs_dict_get(user->config, "followed_hashtags");
322+
323+        if (xs_is_list(followed_hashtags) && xs_list_len(followed_hashtags)) {
324+            xs_html *loht = xs_html_tag("ul",
325+                xs_html_attr("class", "snac-list-of-lists"));
326+            xs_html_add(body, loht);
327+
328+            const char *ht;
329+
330+            xs_list_foreach(followed_hashtags, ht) {
331+                xs *url = xs_fmt("%s/admin?q=%s", user->actor, ht);
332+                url = xs_replace_i(url, "#", "%23");
333+
334+                xs_html_add(loht,
335+                    xs_html_tag("li",
336+                        xs_html_tag("a",
337+                            xs_html_attr("href", url),
338+                            xs_html_attr("class", "snac-list-link"),
339+                            xs_html_text(ht))));
340+            }
341+        }
342     }
343 
344     xs_html_add(body,
345@@ -2648,10 +2749,32 @@ xs_str *html_timeline(snac *user, const xs_list *list, int read_only,
346     xs_html_add(body,
347         posts);
348 
349+    int mark_shown = 0;
350+
351     while (xs_list_iter(&p, &v)) {
352         xs *msg = NULL;
353         int status;
354 
355+        /* "already seen" mark? */
356+        if (strcmp(v, MD5_ALREADY_SEEN_MARK) == 0) {
357+            if (skip == 0 && !mark_shown) {
358+                xs *s = xs_fmt("%s/admin", user->actor);
359+
360+                xs_html_add(posts,
361+                    xs_html_tag("div",
362+                        xs_html_attr("class", "snac-no-more-unseen-posts"),
363+                        xs_html_text(L("No more unseen posts")),
364+                        xs_html_text(" - "),
365+                        xs_html_tag("a",
366+                            xs_html_attr("href", s),
367+                            xs_html_text(L("Back to top")))));
368+            }
369+
370+            mark_shown = 1;
371+
372+            continue;
373+        }
374+
375         if (utl && user && !is_pinned_by_md5(user, v))
376             status = timeline_get_by_md5(user, v, &msg);
377         else
378@@ -2788,7 +2911,7 @@ xs_html *html_people_list(snac *snac, xs_list *list, char *header, char *t, cons
379                 xs_html_tag("div",
380                     xs_html_attr("class", "snac-post-header"),
381                     html_actor_icon(snac, actor, xs_dict_get(actor, "published"),
382-                                    NULL, NULL, 0, 1, proxy, NULL)));
383+                                    NULL, NULL, 0, 1, proxy, NULL, NULL)));
384 
385             /* content (user bio) */
386             const char *c = xs_dict_get(actor, "summary");
387@@ -2885,7 +3008,7 @@ xs_html *html_people_list(snac *snac, xs_list *list, char *header, char *t, cons
388                     NULL, actor_id,
389                     xs_stock(XSTYPE_FALSE), "",
390                     xs_stock(XSTYPE_FALSE), NULL,
391-                    NULL, 0, "", "", 0),
392+                    NULL, 0, NULL, NULL, 0),
393                 xs_html_tag("p", NULL));
394 
395             xs_html_add(snac_post, snac_controls);
396@@ -2970,6 +3093,9 @@ xs_str *html_notifications(snac *user, int skip, int show)
397     xs_set rep;
398     xs_set_init(&rep);
399 
400+    /* dict to store previous notification labels */
401+    xs *admiration_labels = xs_dict_new();
402+
403     const xs_str *v;
404 
405     xs_list_foreach(n_list, v) {
406@@ -2983,6 +3109,7 @@ xs_str *html_notifications(snac *user, int skip, int show)
407         const char *utype = xs_dict_get(noti, "utype");
408         const char *id    = xs_dict_get(noti, "objid");
409         const char *date  = xs_dict_get(noti, "date");
410+        const char *id2   = xs_dict_get_path(noti, "msg.id");
411         xs *wrk = NULL;
412 
413         if (xs_is_null(id))
414@@ -2991,12 +3118,15 @@ xs_str *html_notifications(snac *user, int skip, int show)
415         if (is_hidden(user, id))
416             continue;
417 
418+        if (xs_is_string(id2) && xs_set_add(&rep, id2) != 1)
419+            continue;
420+
421         object_get(id, &obj);
422 
423         const char *msg_id = NULL;
424 
425-        if (xs_is_dict(obj) && (msg_id = xs_dict_get(obj, "id")) && xs_set_add(&rep, msg_id) != 1)
426-            continue;
427+        if (xs_is_dict(obj))
428+            msg_id = xs_dict_get(obj, "id");
429 
430         const char *actor_id = xs_dict_get(noti, "actor");
431         xs *actor = NULL;
432@@ -3030,9 +3160,7 @@ xs_str *html_notifications(snac *user, int skip, int show)
433 
434         xs *s_date = xs_crop_i(xs_dup(date), 0, 10);
435 
436-        xs_html *entry = xs_html_tag("div",
437-            xs_html_attr("class", "snac-post-with-desc"),
438-            xs_html_tag("p",
439+        xs_html *this_html_label = xs_html_container(
440                 xs_html_tag("b",
441                     xs_html_text(label),
442                     xs_html_text(" by "),
443@@ -3043,13 +3171,45 @@ xs_str *html_notifications(snac *user, int skip, int show)
444                 xs_html_tag("time",
445                     xs_html_attr("class", "dt-published snac-pubdate"),
446                     xs_html_attr("title", date),
447-                    xs_html_text(s_date))));
448+                    xs_html_text(s_date)));
449+
450+        xs_html *html_label = NULL;
451+
452+        if (xs_is_string(msg_id)) {
453+            const xs_val *prev_label = xs_dict_get(admiration_labels, msg_id);
454+
455+            if (xs_type(prev_label) == XSTYPE_DATA) {
456+                /* there is a previous list of admiration labels! */
457+                xs_data_get(&html_label, prev_label);
458+
459+                xs_html_add(html_label,
460+                    xs_html_sctag("br", NULL),
461+                    this_html_label);
462+
463+                continue;
464+            }
465+        }
466+
467+        xs_html *entry = NULL;
468+
469+        html_label = xs_html_tag("p",
470+            this_html_label);
471+
472+        /* store in the admiration labels dict */
473+        xs *pl = xs_data_new(&html_label, sizeof(html_label));
474+
475+        if (xs_is_string(msg_id))
476+            admiration_labels = xs_dict_set(admiration_labels, msg_id, pl);
477+
478+        entry = xs_html_tag("div",
479+            xs_html_attr("class", "snac-post-with-desc"),
480+            html_label);
481 
482         if (strcmp(type, "Follow") == 0 || strcmp(utype, "Follow") == 0 || strcmp(type, "Block") == 0) {
483             xs_html_add(entry,
484                 xs_html_tag("div",
485                     xs_html_attr("class", "snac-post"),
486-                    html_actor_icon(user, actor, NULL, NULL, NULL, 0, 0, proxy, NULL)));
487+                    html_actor_icon(user, actor, NULL, NULL, NULL, 0, 0, proxy, NULL, NULL)));
488         }
489         else
490         if (strcmp(type, "Move") == 0) {
491@@ -3063,18 +3223,23 @@ xs_str *html_notifications(snac *user, int skip, int show)
492                     xs_html_add(entry,
493                         xs_html_tag("div",
494                             xs_html_attr("class", "snac-post"),
495-                            html_actor_icon(user, old_actor, NULL, NULL, NULL, 0, 0, proxy, NULL)));
496+                            html_actor_icon(user, old_actor, NULL, NULL, NULL, 0, 0, proxy, NULL, NULL)));
497                 }
498             }
499         }
500         else
501         if (obj != NULL) {
502             xs *md5 = xs_md5_hex(id, strlen(id));
503+            xs *ctxt = xs_fmt("%s/admin/p/%s#%s_entry", user->actor, md5, md5);
504 
505             xs_html *h = html_entry(user, obj, 0, 0, md5, 1);
506 
507             if (h != NULL) {
508                 xs_html_add(entry,
509+                    xs_html_tag("p",
510+                        xs_html_tag("a",
511+                            xs_html_attr("href", ctxt),
512+                            xs_html_text(L("Context")))),
513                     h);
514             }
515         }
516@@ -3111,8 +3276,6 @@ xs_str *html_notifications(snac *user, int skip, int show)
517         }
518     }
519 
520-    xs_set_free(&rep);
521-
522     if (noti_new == NULL && noti_seen == NULL)
523         xs_html_add(body,
524             xs_html_tag("h2",
525@@ -3132,6 +3295,8 @@ xs_str *html_notifications(snac *user, int skip, int show)
526                     xs_html_text(L("More...")))));
527     }
528 
529+    xs_set_free(&rep);
530+
531     xs_html_add(body,
532         html_footer());
533 
534@@ -3232,7 +3397,8 @@ int html_get_handler(const xs_dict *req, const char *q_path,
535         cache = 0;
536 
537     int skip = 0;
538-    int def_show = xs_number_get(xs_dict_get(srv_config, "max_timeline_entries"));
539+    int def_show = xs_number_get(xs_dict_get_def(srv_config, "def_timeline_entries",
540+                                 xs_dict_get_def(srv_config, "max_timeline_entries", "50")));
541     int show = def_show;
542 
543     if ((v = xs_dict_get(q_vars, "skip")) != NULL)
544@@ -3277,21 +3443,17 @@ int html_get_handler(const xs_dict *req, const char *q_path,
545         }
546         else {
547             xs *list = NULL;
548-            xs *next = NULL;
549+            int more = 0;
550 
551-            if (xs_is_true(xs_dict_get(srv_config, "strict_public_timelines"))) {
552-                list = timeline_simple_list(&snac, "public", skip, show);
553-                next = timeline_simple_list(&snac, "public", skip + show, 1);
554-            }
555-            else {
556-                list = timeline_list(&snac, "public", skip, show);
557-                next = timeline_list(&snac, "public", skip + show, 1);
558-            }
559+            if (xs_is_true(xs_dict_get(srv_config, "strict_public_timelines")))
560+                list = timeline_simple_list(&snac, "public", skip, show, &more);
561+            else 
562+                list = timeline_list(&snac, "public", skip, show, &more);
563 
564             xs *pins = pinned_list(&snac);
565             pins = xs_list_cat(pins, list);
566 
567-            *body = html_timeline(&snac, pins, 1, skip, show, xs_list_len(next), NULL, "", 1, error);
568+            *body = html_timeline(&snac, pins, 1, skip, show, more, NULL, "", 1, error);
569 
570             *b_size = strlen(*body);
571             status  = HTTP_STATUS_OK;
572@@ -3440,6 +3602,7 @@ int html_get_handler(const xs_dict *req, const char *q_path,
573                 }
574             }
575             else {
576+                /** the private timeline **/
577                 double t = history_mtime(&snac, "timeline.html_");
578 
579                 /* if enabled by admin, return a cached page if its timestamp is:
580@@ -3453,19 +3616,22 @@ int html_get_handler(const xs_dict *req, const char *q_path,
581                                 xs_dict_get(req, "if-none-match"), etag);
582                 }
583                 else {
584+                    int more = 0;
585+
586                     snac_debug(&snac, 1, xs_fmt("building timeline"));
587 
588-                    xs *list = timeline_list(&snac, "private", skip, show);
589-                    xs *next = timeline_list(&snac, "private", skip + show, 1);
590+                    xs *list = timeline_list(&snac, "private", skip, show, &more);
591 
592                     *body = html_timeline(&snac, list, 0, skip, show,
593-                            xs_list_len(next), NULL, "/admin", 1, error);
594+                            more, NULL, "/admin", 1, error);
595 
596                     *b_size = strlen(*body);
597                     status  = HTTP_STATUS_OK;
598 
599                     if (save)
600                         history_add(&snac, "timeline.html_", *body, *b_size, etag);
601+
602+                    timeline_add_mark(&snac);
603                 }
604             }
605         }
606@@ -3481,7 +3647,8 @@ int html_get_handler(const xs_dict *req, const char *q_path,
607             const char *md5 = xs_list_get(l, -1);
608 
609             if (md5 && *md5 && timeline_here(&snac, md5)) {
610-                xs *list = xs_list_append(xs_list_new(), md5);
611+                xs *list0 = xs_list_append(xs_list_new(), md5);
612+                xs *list  = timeline_top_level(&snac, list0);
613 
614                 *body   = html_timeline(&snac, list, 0, 0, 0, 0, NULL, "/admin", 1, error);
615                 *b_size = strlen(*body);
616@@ -3665,7 +3832,7 @@ int html_get_handler(const xs_dict *req, const char *q_path,
617 
618         int cnt = xs_number_get(xs_dict_get_def(srv_config, "max_public_entries", "20"));
619 
620-        xs *elems = timeline_simple_list(&snac, "public", 0, cnt);
621+        xs *elems = timeline_simple_list(&snac, "public", 0, cnt, NULL);
622         xs *bio   = xs_dup(xs_dict_get(snac.config, "bio"));
623 
624         xs *rss_title = xs_fmt("%s (@%s@%s)",
625@@ -3869,52 +4036,56 @@ int html_post_handler(const xs_dict *req, const char *q_path,
626         /* post note */
627         const xs_str *content      = xs_dict_get(p_vars, "content");
628         const xs_str *in_reply_to  = xs_dict_get(p_vars, "in_reply_to");
629-        const xs_str *attach_url   = xs_dict_get(p_vars, "attach_url");
630-        const xs_list *attach_file = xs_dict_get(p_vars, "attach");
631         const xs_str *to           = xs_dict_get(p_vars, "to");
632         const xs_str *sensitive    = xs_dict_get(p_vars, "sensitive");
633         const xs_str *summary      = xs_dict_get(p_vars, "summary");
634         const xs_str *edit_id      = xs_dict_get(p_vars, "edit_id");
635-        const xs_str *alt_text     = xs_dict_get(p_vars, "alt_text");
636         int priv             = !xs_is_null(xs_dict_get(p_vars, "mentioned_only"));
637         int store_as_draft   = !xs_is_null(xs_dict_get(p_vars, "is_draft"));
638         xs *attach_list      = xs_list_new();
639 
640-        /* default alt text */
641-        if (xs_is_null(alt_text))
642-            alt_text = "";
643+        /* iterate the attachments */
644+        int max_attachments = xs_number_get(xs_dict_get_def(srv_config, "max_attachments", "4"));
645 
646-        /* is attach_url set? */
647-        if (!xs_is_null(attach_url) && *attach_url != '\0') {
648-            xs *l = xs_list_new();
649+        for (int att_n = 0; att_n < max_attachments; att_n++) {
650+            xs *url_lbl = xs_fmt("attach_url_%d", att_n);
651+            xs *att_lbl = xs_fmt("attach_%d", att_n);
652+            xs *alt_lbl = xs_fmt("alt_text_%d", att_n);
653 
654-            l = xs_list_append(l, attach_url);
655-            l = xs_list_append(l, alt_text);
656+            const char *attach_url     = xs_dict_get(p_vars, url_lbl);
657+            const xs_list *attach_file = xs_dict_get(p_vars, att_lbl);
658+            const char *alt_text       = xs_dict_get_def(p_vars, alt_lbl, "");
659 
660-            attach_list = xs_list_append(attach_list, l);
661-        }
662+            if (xs_is_string(attach_url) && *attach_url != '\0') {
663+                xs *l = xs_list_new();
664 
665-        /* is attach_file set? */
666-        if (!xs_is_null(attach_file) && xs_type(attach_file) == XSTYPE_LIST) {
667-            const char *fn = xs_list_get(attach_file, 0);
668+                l = xs_list_append(l, attach_url);
669+                l = xs_list_append(l, alt_text);
670 
671-            if (*fn != '\0') {
672-                char *ext = strrchr(fn, '.');
673-                xs *hash  = xs_md5_hex(fn, strlen(fn));
674-                xs *id    = xs_fmt("%s%s", hash, ext);
675-                xs *url   = xs_fmt("%s/s/%s", snac.actor, id);
676-                int fo    = xs_number_get(xs_list_get(attach_file, 1));
677-                int fs    = xs_number_get(xs_list_get(attach_file, 2));
678+                attach_list = xs_list_append(attach_list, l);
679+            }
680+            else
681+            if (xs_is_list(attach_file)) {
682+                const char *fn = xs_list_get(attach_file, 0);
683 
684-                /* store */
685-                static_put(&snac, id, payload + fo, fs);
686+                if (xs_is_string(fn) && *fn != '\0') {
687+                    char *ext = strrchr(fn, '.');
688+                    xs *hash  = xs_md5_hex(fn, strlen(fn));
689+                    xs *id    = xs_fmt("%s%s", hash, ext);
690+                    xs *url   = xs_fmt("%s/s/%s", snac.actor, id);
691+                    int fo    = xs_number_get(xs_list_get(attach_file, 1));
692+                    int fs    = xs_number_get(xs_list_get(attach_file, 2));
693 
694-                xs *l = xs_list_new();
695+                    /* store */
696+                    static_put(&snac, id, payload + fo, fs);
697 
698-                l = xs_list_append(l, url);
699-                l = xs_list_append(l, alt_text);
700+                    xs *l = xs_list_new();
701 
702-                attach_list = xs_list_append(attach_list, l);
703+                    l = xs_list_append(l, url);
704+                    l = xs_list_append(l, alt_text);
705+
706+                    attach_list = xs_list_append(attach_list, l);
707+                }
708             }
709         }
710 
+2, -1
 1@@ -219,7 +219,8 @@ int server_get_handler(xs_dict *req, const char *q_path,
 2         if (xs_type(q_vars) == XSTYPE_DICT && (t = xs_dict_get(q_vars, "t"))) {
 3             /** search by tag **/
 4             int skip = 0;
 5-            int show = xs_number_get(xs_dict_get(srv_config, "max_timeline_entries"));
 6+            int show = xs_number_get(xs_dict_get_def(srv_config, "def_timeline_entries",
 7+                                 xs_dict_get_def(srv_config, "max_timeline_entries", "50")));
 8             const char *v;
 9 
10             if ((v = xs_dict_get(q_vars, "skip")) != NULL)
M main.c
+37, -12
 1@@ -11,6 +11,7 @@
 2 #include "snac.h"
 3 
 4 #include <sys/stat.h>
 5+#include <sys/wait.h>
 6 
 7 int usage(void)
 8 {
 9@@ -49,6 +50,7 @@ int usage(void)
10     printf("unblock {basedir} {instance_url}     Unblocks a full instance\n");
11     printf("limit {basedir} {uid} {actor}        Limits an actor (drops their announces)\n");
12     printf("unlimit {basedir} {uid} {actor}      Unlimits an actor\n");
13+    printf("unmute {basedir} {uid} {actor}       Unmutes a previously muted actor\n");
14     printf("verify_links {basedir} {uid}         Verifies a user's links (in the metadata)\n");
15     printf("search {basedir} {uid} {regex}       Searches posts by content\n");
16     printf("export_csv {basedir} {uid}           Exports data as CSV files\n");
17@@ -446,6 +448,18 @@ int main(int argc, char *argv[])
18         return 0;
19     }
20 
21+    if (strcmp(cmd, "unmute") == 0) { /** **/
22+        if (is_muted(&snac, url)) {
23+            unmute(&snac, url);
24+
25+            printf("%s unmuted\n", url);
26+        }
27+        else
28+            printf("%s actor is not muted\n", url);
29+
30+        return 0;
31+    }
32+
33     if (strcmp(cmd, "search") == 0) { /** **/
34         int to;
35 
36@@ -663,19 +677,25 @@ int main(int argc, char *argv[])
37 
38         if (strcmp(url, "-e") == 0) {
39             /* get the content from an editor */
40+#define EDITOR "$EDITOR "
41+            char cmd[] = EDITOR "/tmp/snac-XXXXXX";
42             FILE *f;
43-
44-            unlink("/tmp/snac-edit.txt");
45-            system("$EDITOR /tmp/snac-edit.txt");
46-
47-            if ((f = fopen("/tmp/snac-edit.txt", "r")) != NULL) {
48-                content = xs_readall(f);
49-                fclose(f);
50-
51-                unlink("/tmp/snac-edit.txt");
52-            }
53-            else {
54-                printf("Nothing to send\n");
55+            int fd = mkstemp(cmd + strlen(EDITOR));
56+
57+            if (fd >= 0) {
58+                int status = system(cmd);
59+
60+                if (WIFEXITED(status) && WEXITSTATUS(status) == 0 && (f = fdopen(fd, "r")) != NULL) {
61+                    content = xs_readall(f);
62+                    fclose(f);
63+                    unlink(cmd + strlen(EDITOR));
64+                } else {
65+                    printf("Nothing to send\n");
66+                    close(fd);
67+                    return 1;
68+                }
69+            } else {
70+                fprintf(stderr, "Temp file creation failed\n");
71                 return 1;
72             }
73         }
74@@ -687,6 +707,11 @@ int main(int argc, char *argv[])
75         else
76             content = xs_dup(url);
77 
78+        if (!content || !*content) {
79+            printf("Nothing to send\n");
80+            return 1;
81+        }
82+
83         int scope = 0;
84         if (strcmp(cmd, "note_mention") == 0)
85             scope = 1;
+7, -2
 1@@ -1676,7 +1676,7 @@ int mastoapi_get_handler(const xs_dict *req, const char *q_path,
 2                 else
 3                 if (strcmp(opt, "statuses") == 0) { /** **/
 4                     /* the public list of posts of a user */
 5-                    xs *timeline = timeline_simple_list(&snac2, "public", 0, 256);
 6+                    xs *timeline = timeline_simple_list(&snac2, "public", 0, 256, NULL);
 7                     xs_list *p   = timeline;
 8                     const xs_str *v;
 9 
10@@ -2171,7 +2171,12 @@ int mastoapi_get_handler(const xs_dict *req, const char *q_path,
11 
12         {
13             xs *d11 = xs_json_loads("{\"characters_reserved_per_url\":32,"
14-                "\"max_characters\":100000,\"max_media_attachments\":8}");
15+                "\"max_characters\":100000,\"max_media_attachments\":4}");
16+
17+            const xs_number *max_attachments = xs_dict_get(srv_config, "max_attachments");
18+            if (xs_type(max_attachments) == XSTYPE_NUMBER)
19+                d11 = xs_dict_set(d11, "max_media_attachments", max_attachments);
20+
21             cfg = xs_dict_append(cfg, "statuses", d11);
22 
23             xs *d12 = xs_json_loads("{\"max_featured_tags\":0}");
M snac.c
+1, -1
1@@ -69,7 +69,7 @@ xs_str *tid(int offset)
2 
3     gettimeofday(&tv, NULL);
4 
5-    return xs_fmt("%10d.%06d", tv.tv_sec + offset, tv.tv_usec);
6+    return xs_fmt("%010ld.%06ld", (long)tv.tv_sec + (long)offset, (long)tv.tv_usec);
7 }
8 
9 
M snac.h
+7, -3
 1@@ -1,7 +1,7 @@
 2 /* snac - A simple, minimalistic ActivityPub instance */
 3 /* copyright (c) 2022 - 2025 grunfink et al. / MIT license */
 4 
 5-#define VERSION "2.69"
 6+#define VERSION "2.72"
 7 
 8 #define USER_AGENT "snac/" VERSION
 9 
10@@ -22,6 +22,8 @@
11 
12 #define MD5_HEX_SIZE 33
13 
14+#define MD5_ALREADY_SEEN_MARK "00000000000000000000000000000000"
15+
16 extern double disk_layout;
17 extern xs_str *srv_basedir;
18 extern xs_dict *srv_config;
19@@ -157,12 +159,14 @@ int timeline_here(snac *snac, const char *md5);
20 int timeline_get_by_md5(snac *snac, const char *md5, xs_dict **msg);
21 int timeline_del(snac *snac, const char *id);
22 xs_str *user_index_fn(snac *user, const char *idx_name);
23-xs_list *timeline_simple_list(snac *user, const char *idx_name, int skip, int show);
24-xs_list *timeline_list(snac *snac, const char *idx_name, int skip, int show);
25+xs_list *timeline_simple_list(snac *user, const char *idx_name, int skip, int show, int *more);
26+xs_list *timeline_list(snac *snac, const char *idx_name, int skip, int show, int *more);
27 int timeline_add(snac *snac, const char *id, const xs_dict *o_msg);
28 int timeline_admire(snac *snac, const char *id, const char *admirer, int like);
29 
30 xs_list *timeline_top_level(snac *snac, const xs_list *list);
31+void timeline_add_mark(snac *user);
32+
33 xs_list *local_list(snac *snac, int max);
34 xs_str *instance_index_fn(void);
35 xs_list *timeline_instance_list(int skip, int show);
+3, -0
 1@@ -28,6 +28,7 @@ static const char *default_srv_config = "{"
 2     "\"queue_timeout\":        6,"
 3     "\"queue_timeout_2\":      8,"
 4     "\"cssurls\":              [\"\"],"
 5+    "\"def_timeline_entries\": 50,"
 6     "\"max_timeline_entries\": 50,"
 7     "\"timeline_purge_days\":  120,"
 8     "\"local_purge_days\":     0,"
 9@@ -36,6 +37,7 @@ static const char *default_srv_config = "{"
10     "\"admin_account\":        \"\","
11     "\"title\":                \"\","
12     "\"short_description\":    \"\","
13+    "\"short_description_raw\": false,"
14     "\"protocol\":             \"https\","
15     "\"fastcgi\":              false"
16     "}";
17@@ -72,6 +74,7 @@ static const char *default_css =
18     ".snac-list-of-lists { padding-left: 0; }\n"
19     ".snac-list-of-lists li { display: inline; border: 1px solid #a0a0a0; border-radius: 25px;\n"
20     "  margin-right: 0.5em; padding-left: 0.5em; padding-right: 0.5em; }\n"
21+    ".snac-no-more-unseen-posts { border-top: 1px solid #a0a0a0; border-bottom: 1px solid #a0a0a0; padding: 0.5em 0; margin: 1em 0; }\n"
22     "@media (prefers-color-scheme: dark) { \n"
23     "  body, input, textarea { background-color: #000; color: #fff; }\n"
24     "  a { color: #7799dd }\n"
M xs.h
+28, -8
 1@@ -12,6 +12,7 @@
 2 #include <stdarg.h>
 3 #include <signal.h>
 4 #include <errno.h>
 5+#include <stdint.h>
 6 
 7 typedef enum {
 8     XSTYPE_STRING = 0x02,       /* C string (\0 delimited) (NOT STORED) */
 9@@ -142,6 +143,7 @@ void xs_data_get(void *data, const xs_data *value);
10 void *xs_memmem(const char *haystack, int h_size, const char *needle, int n_size);
11 
12 unsigned int xs_hash_func(const char *data, int size);
13+uint64_t xs_hash64_func(const char *data, int size);
14 
15 #ifdef XS_ASSERT
16 #include <assert.h>
17@@ -632,7 +634,7 @@ xs_str *xs_crop_i(xs_str *str, int start, int end)
18         end = sz + end;
19 
20     /* crop from the top */
21-    if (end > 0 && end < sz)
22+    if (end >= 0 && end < sz)
23         str[end] = '\0';
24 
25     /* crop from the bottom */
26@@ -989,16 +991,20 @@ xs_str *xs_join(const xs_list *list, const char *sep)
27 xs_list *xs_split_n(const char *str, const char *sep, int times)
28 /* splits a string into a list upto n times */
29 {
30+    xs_list *list = xs_list_new();
31+
32+    if (!xs_is_string(str) || !xs_is_string(sep))
33+        return list;
34+
35     int sz = strlen(sep);
36     char *ss;
37-    xs_list *list;
38-
39-    list = xs_list_new();
40 
41     while (times > 0 && (ss = strstr(str, sep)) != NULL) {
42         /* create a new string with this slice and add it to the list */
43         xs *s = xs_str_new_sz(str, ss - str);
44-        list = xs_list_append(list, s);
45+
46+        if (xs_is_string(s))
47+            list = xs_list_append(list, s);
48 
49         /* skip past the separator */
50         str = ss + sz;
51@@ -1007,7 +1013,8 @@ xs_list *xs_split_n(const char *str, const char *sep, int times)
52     }
53 
54     /* add the rest of the string */
55-    list = xs_list_append(list, str);
56+    if (xs_is_string(str))
57+        list = xs_list_append(list, str);
58 
59     return list;
60 }
61@@ -1487,9 +1494,8 @@ unsigned int xs_hash_func(const char *data, int size)
62 /* a general purpose hashing function */
63 {
64     unsigned int hash = 0x666;
65-    int n;
66 
67-    for (n = 0; n < size; n++) {
68+    for (int n = 0; n < size; n++) {
69         hash ^= (unsigned char)data[n];
70         hash *= 111111111;
71     }
72@@ -1498,6 +1504,20 @@ unsigned int xs_hash_func(const char *data, int size)
73 }
74 
75 
76+uint64_t xs_hash64_func(const char *data, int size)
77+/* a general purpose hashing function (64 bit) */
78+{
79+    uint64_t hash = 0x100;
80+
81+    for (int n = 0; n < size; n++) {
82+        hash ^= (unsigned char)data[n];
83+        hash *= 1111111111111111111;
84+    }
85+
86+    return hash;
87+}
88+
89+
90 #endif /* XS_IMPLEMENTATION */
91 
92 #endif /* _XS_H */
+3, -0
 1@@ -173,6 +173,9 @@ xs_dict *xs_fcgi_request(FILE *f, xs_str **payload, int *p_size, int *fcgi_id)
 2                     xs *v = xs_str_new_sz((char *)&buf[offset], vsz);
 3                     offset += vsz;
 4 
 5+                    if (!xs_is_string(k) || !xs_is_string(v))
 6+                        continue;
 7+
 8                     cgi_vars = xs_dict_append(cgi_vars, k, v);
 9 
10                     if (strcmp(k, "REQUEST_METHOD") == 0)
+2, -2
 1@@ -114,7 +114,7 @@ xs_html *xs_html_text(const char *content)
 2     xs_html *a = XS_HTML_NEW();
 3 
 4     a->type    = XS_HTML_TEXT;
 5-    a->content = xs_html_encode(content);
 6+    a->content = xs_is_string(content) ? xs_html_encode(content) : xs_str_new(NULL);
 7 
 8     return a;
 9 }
10@@ -126,7 +126,7 @@ xs_html *xs_html_raw(const char *content)
11     xs_html *a = XS_HTML_NEW();
12 
13     a->type    = XS_HTML_TEXT;
14-    a->content = xs_dup(content);
15+    a->content = xs_is_string(content) ? xs_dup(content) : xs_str_new(NULL);
16 
17     return a;
18 }
+34, -20
 1@@ -15,41 +15,48 @@ xs_dict *xs_httpd_request(FILE *f, xs_str **payload, int *p_size)
 2 {
 3     xs *q_vars = NULL;
 4     xs *p_vars = NULL;
 5-    xs *l1, *l2;
 6+    xs *l1;
 7     const char *v;
 8+    char *saveptr;
 9 
10     xs_socket_timeout(fileno(f), 2.0, 0.0);
11 
12     /* read the first line and split it */
13     l1 = xs_strip_i(xs_readline(f));
14-    l2 = xs_split(l1, " ");
15+    char *raw_path;
16+    const char *mtd;
17+    const char *proto;
18+
19+    if (!(mtd = strtok_r(l1, " ", &saveptr)) ||
20+        !(raw_path = strtok_r(NULL, " ", &saveptr)) ||
21+        !(proto = strtok_r(NULL, " ", &saveptr)) ||
22+        strtok_r(NULL, " ", &saveptr))
23+        return NULL;
24 
25-    if (xs_list_len(l2) != 3) {
26-        /* error or timeout */
27+    if (!xs_is_string(mtd) || !xs_is_string(raw_path) || !xs_is_string(proto))
28         return NULL;
29-    }
30 
31     xs_dict *req = xs_dict_new();
32 
33-    req = xs_dict_append(req, "method", xs_list_get(l2, 0));
34-    req = xs_dict_append(req, "raw_path", xs_list_get(l2, 1));
35-    req = xs_dict_append(req, "proto",  xs_list_get(l2, 2));
36+    req = xs_dict_append(req, "method", mtd);
37+    req = xs_dict_append(req, "raw_path", raw_path);
38+    req = xs_dict_append(req, "proto",  proto);
39 
40     {
41-        /* split the path with its optional variables */
42-        const xs_val *udp = xs_list_get(l2, 1);
43-        xs *pnv = xs_split_n(udp, "?", 1);
44-
45-        /* store the path */
46-        req = xs_dict_append(req, "path", xs_list_get(pnv, 0));
47+        char *q = strchr(raw_path, '?');
48 
49         /* get the variables */
50-        q_vars = xs_url_vars(xs_list_get(pnv, 1));
51+        if (q) {
52+                *q++ = '\0';
53+                q_vars = xs_url_vars(q);
54+        }
55+        /* store the path */
56+        req = xs_dict_append(req, "path", raw_path);
57     }
58 
59     /* read the headers */
60     for (;;) {
61-        xs *l, *p = NULL;
62+        xs *l;
63 
64         l = xs_strip_i(xs_readline(f));
65 
66@@ -58,11 +65,18 @@ xs_dict *xs_httpd_request(FILE *f, xs_str **payload, int *p_size)
67             break;
68 
69         /* split header and content */
70-        p = xs_split_n(l, ": ", 1);
71+        char *cnt = strchr(l, ':');
72+        if (!cnt)
73+            continue;
74+
75+        *cnt++ = '\0';
76+        cnt += strspn(cnt, " \r\n\t\v\f");
77+        l = xs_rstrip_chars_i(l, " \r\n\t\v\f");
78+
79+        if (!xs_is_string(cnt))
80+            continue;
81 
82-        if (xs_list_len(p) == 2)
83-            req = xs_dict_append(req, xs_tolower_i(
84-                    (xs_str *)xs_list_get(p, 0)), xs_list_get(p, 1));
85+        req = xs_dict_append(req, xs_tolower_i(l), cnt);
86     }
87 
88     xs_socket_timeout(fileno(f), 5.0, 0.0);
+3, -4
 1@@ -14,7 +14,7 @@ xs_val *xs_readall(FILE *f);
 2 xs_str *xs_readline(FILE *f)
 3 /* reads a line from a file */
 4 {
 5-    xs_str *s = NULL;
 6+    xs_str *s = xs_str_new(NULL);
 7 
 8     errno = 0;
 9 
10@@ -22,12 +22,11 @@ xs_str *xs_readline(FILE *f)
11     if (!feof(f)) {
12         int c;
13 
14-        s = xs_str_new(NULL);
15-
16         while ((c = fgetc(f)) != EOF) {
17             unsigned char rc = c;
18 
19-            s = xs_append_m(s, (char *)&rc, 1);
20+            if (xs_is_string((char *)&rc))
21+                s = xs_append_m(s, (char *)&rc, 1);
22 
23             if (c == '\n')
24                 break;
+6, -0
 1@@ -280,6 +280,12 @@ static xs_val *_xs_json_load_lexer(FILE *f, js_type *t)
 2             else {
 3                 char cc = c;
 4                 v = xs_insert_m(v, offset, &cc, 1);
 5+
 6+                if (!xs_is_string(v)) {
 7+                    *t = JS_ERROR;
 8+                    break;
 9+                }
10+
11                 offset++;
12             }
13         }
+6, -1
 1@@ -24,6 +24,7 @@ int xs_match(const char *str, const char *spec)
 2 retry:
 3 
 4     for (;;) {
 5+        const char *q = spec;
 6         char c = *str++;
 7         char p = *spec++;
 8 
 9@@ -63,8 +64,12 @@ retry:
10                     spec = b_spec;
11                     str  = ++b_str;
12                 }
13-                else
14+                else {
15+                    if (*q == '|')
16+                        spec = q;
17+
18                     break;
19+                }
20             }
21         }
22     }
+1, -1
1@@ -83,7 +83,7 @@ xs_val *xs_base64_dec(const xs_str *data, int *size)
2     s = xs_realloc(s, _xs_blk_size(*size + 1));
3     s[*size] = '\0';
4 
5-    BIO_free_all(mem);
6+    BIO_free_all(b64);
7 
8     return s;
9 }
+2, -0
1@@ -85,6 +85,8 @@ int xs_socket_server(const char *addr, const char *serv)
2       listen(rs, SOMAXCONN);
3     }
4 
5+    freeaddrinfo(res);
6+
7 #else /* WITHOUT_GETADDRINFO */
8    struct sockaddr_in host;
9 
+80, -37
  1@@ -11,18 +11,57 @@ xs_dict *xs_multipart_form_data(const char *payload, int p_size, const char *hea
  2 
  3 #ifdef XS_IMPLEMENTATION
  4 
  5+char *xs_url_dec_in(char *str, int qs)
  6+{
  7+    char *w = str;
  8+    char *r;
  9+
 10+    for (r = str; *r != '\0'; r++) {
 11+        switch (*r) {
 12+        case '%': {
 13+            unsigned hex;
 14+            if (!r[1] || !r[2])
 15+                return NULL;
 16+            if (sscanf(r + 1, "%2x", &hex) != 1)
 17+                return NULL;
 18+            *w++ = hex;
 19+            r += 2;
 20+            break;
 21+        }
 22+
 23+        case '+':
 24+            if (qs) {
 25+                *w++ = ' ';
 26+                break;
 27+            }
 28+            /* fall-through */
 29+        default:
 30+            *w++ = *r;
 31+        }
 32+    }
 33+
 34+    *w++ = '\0';
 35+    return str;
 36+}
 37+
 38 xs_str *xs_url_dec(const char *str)
 39 /* decodes an URL */
 40 {
 41     xs_str *s = xs_str_new(NULL);
 42 
 43     while (*str) {
 44+        if (!xs_is_string(str))
 45+            break;
 46+
 47         if (*str == '%') {
 48             unsigned int i;
 49 
 50             if (sscanf(str + 1, "%02x", &i) == 1) {
 51                 unsigned char uc = i;
 52 
 53+                if (!xs_is_string((char *)&uc))
 54+                    break;
 55+
 56                 s = xs_append_m(s, (char *)&uc, 1);
 57                 str += 2;
 58             }
 59@@ -69,43 +108,45 @@ xs_dict *xs_url_vars(const char *str)
 60 
 61     vars = xs_dict_new();
 62 
 63-    if (str != NULL) {
 64-        /* split by arguments */
 65-        xs *args = xs_split(str, "&");
 66-
 67-        const xs_val *v;
 68-
 69-        xs_list_foreach(args, v) {
 70-            xs *dv = xs_url_dec(v);
 71-            xs *kv = xs_split_n(dv, "=", 1);
 72-
 73-            if (xs_list_len(kv) == 2) {
 74-                const char *key = xs_list_get(kv, 0);
 75-                const char *pv  = xs_dict_get(vars, key);
 76-
 77-                if (!xs_is_null(pv)) {
 78-                    /* there is a previous value: convert to a list and append */
 79-                    xs *vlist = NULL;
 80-                    if (xs_type(pv) == XSTYPE_LIST)
 81-                        vlist = xs_dup(pv);
 82-                    else {
 83-                        vlist = xs_list_new();
 84-                        vlist = xs_list_append(vlist, pv);
 85-                    }
 86-
 87-                    vlist = xs_list_append(vlist, xs_list_get(kv, 1));
 88-                    vars  = xs_dict_set(vars, key, vlist);
 89-                }
 90+    if (xs_is_string(str)) {
 91+        xs *dup = xs_dup(str);
 92+        char *k;
 93+        char *saveptr;
 94+        for (k = strtok_r(dup, "&", &saveptr);
 95+             k;
 96+             k = strtok_r(NULL, "&", &saveptr)) {
 97+            char *v = strchr(k, '=');
 98+            if (!v)
 99+                continue;
100+            *v++ = '\0';
101+            k = xs_url_dec_in(k, 1);
102+            v = xs_url_dec_in(v, 1);
103+            if (!xs_is_string(k) || !xs_is_string(v))
104+                continue;
105+
106+            const char *pv  = xs_dict_get(vars, k);
107+            if (!xs_is_null(pv)) {
108+                /* there is a previous value: convert to a list and append */
109+                xs *vlist = NULL;
110+                if (xs_type(pv) == XSTYPE_LIST)
111+                    vlist = xs_dup(pv);
112                 else {
113-                    /* ends with []? force to always be a list */
114-                    if (xs_endswith(key, "[]")) {
115-                        xs *vlist = xs_list_new();
116-                        vlist = xs_list_append(vlist, xs_list_get(kv, 1));
117-                        vars = xs_dict_append(vars, key, vlist);
118-                    }
119-                    else
120-                        vars = xs_dict_append(vars, key, xs_list_get(kv, 1));
121+                    vlist = xs_list_new();
122+                    vlist = xs_list_append(vlist, pv);
123+                }
124+
125+                vlist = xs_list_append(vlist, v);
126+                vars  = xs_dict_set(vars, k, vlist);
127+            }
128+            else {
129+                /* ends with []? force to always be a list */
130+                if (xs_endswith(k, "[]")) {
131+                    xs *vlist = xs_list_new();
132+                    vlist = xs_list_append(vlist, v);
133+                    vars = xs_dict_append(vars, k, vlist);
134                 }
135+                else
136+                    vars = xs_dict_append(vars, k, v);
137             }
138         }
139     }
140@@ -233,7 +274,8 @@ xs_dict *xs_multipart_form_data(const char *payload, int p_size, const char *hea
141             l1 = xs_list_append(l1, vpo);
142             l1 = xs_list_append(l1, vps);
143 
144-            p_vars = xs_dict_append(p_vars, vn, l1);
145+            if (xs_is_string(vn))
146+                p_vars = xs_dict_append(p_vars, vn, l1);
147         }
148         else {
149             /* regular variable; just copy */
150@@ -241,7 +283,8 @@ xs_dict *xs_multipart_form_data(const char *payload, int p_size, const char *hea
151             memcpy(vc, payload + po, ps);
152             vc[ps] = '\0';
153 
154-            p_vars = xs_dict_append(p_vars, vn, vc);
155+            if (xs_is_string(vn) && xs_is_string(vc))
156+                p_vars = xs_dict_append(p_vars, vn, vc);
157         }
158 
159         /* move on */
+1, -1
1@@ -1 +1 @@
2-/* b865e89769aedfdbc61251e94451e9d37579f52e 2025-01-12T16:17:47+01:00 */
3+/* 2f43b93e9d2b63360c802e09f4c68adfef74c673 2025-01-28T07:40:50+01:00 */