summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--LICENSE2
-rw-r--r--Makefile8
-rw-r--r--Makefile.NetBSD8
-rw-r--r--RELEASE_NOTES.md32
-rw-r--r--activitypub.c13
-rw-r--r--data.c15
-rw-r--r--doc/snac.115
-rw-r--r--doc/snac.814
-rw-r--r--doc/style.css1
-rw-r--r--format.c22
-rw-r--r--html.c333
-rw-r--r--http.c2
-rw-r--r--httpd.c2
-rw-r--r--main.c40
-rw-r--r--mastoapi.c89
-rw-r--r--po/pt_BR.po12
-rw-r--r--rss.c12
-rw-r--r--snac.c153
-rw-r--r--snac.h10
-rw-r--r--upgrade.c2
-rw-r--r--utils.c75
-rw-r--r--webfinger.c2
-rw-r--r--xs.h2
-rw-r--r--xs_curl.h2
-rw-r--r--xs_fcgi.h2
-rw-r--r--xs_glob.h2
-rw-r--r--xs_hex.h2
-rw-r--r--xs_html.h2
-rw-r--r--xs_http.h2
-rw-r--r--xs_httpd.h2
-rw-r--r--xs_io.h2
-rw-r--r--xs_json.h4
-rw-r--r--xs_list_tools.h169
-rw-r--r--xs_match.h2
-rw-r--r--xs_mime.h2
-rw-r--r--xs_openssl.h2
-rw-r--r--xs_po.h2
-rw-r--r--xs_random.h2
-rw-r--r--xs_regex.h2
-rw-r--r--xs_set.h2
-rw-r--r--xs_socket.h2
-rw-r--r--xs_time.h2
-rw-r--r--xs_unicode.h2
-rw-r--r--xs_unix_socket.h2
-rw-r--r--xs_url.h2
-rw-r--r--xs_version.h2
-rw-r--r--xs_webmention.h2
47 files changed, 970 insertions, 111 deletions
diff --git a/LICENSE b/LICENSE
index 702b487..3fa58d1 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,6 +1,6 @@
1MIT License 1MIT License
2 2
3Copyright (c) 2022 - 2025 grunfink et al. (Fediverse: @grunfink@comam.es) 3Copyright (c) 2022 - 2026 grunfink et al. (Fediverse: @grunfink@comam.es)
4 4
5Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 5Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
6 6
diff --git a/Makefile b/Makefile
index ffeac1a..5614388 100644
--- a/Makefile
+++ b/Makefile
@@ -46,7 +46,7 @@ update-po:
46 xgettext --omit-header -j -o $$a --language=C --keyword=L --from-code=utf-8 *.c ; \ 46 xgettext --omit-header -j -o $$a --language=C --keyword=L --from-code=utf-8 *.c ; \
47 done 47 done
48 48
49activitypub.o: activitypub.c xs.h xs_json.h xs_curl.h xs_mime.h \ 49activitypub.o: activitypub.c xs.h xs_json.h xs_curl.h xs_url.h xs_mime.h \
50 xs_openssl.h xs_regex.h xs_time.h xs_set.h xs_match.h xs_unicode.h \ 50 xs_openssl.h xs_regex.h xs_time.h xs_set.h xs_match.h xs_unicode.h \
51 xs_webmention.h xs_http.h xs_http_codes.h snac.h 51 xs_webmention.h xs_http.h xs_http_codes.h snac.h
52data.o: data.c xs.h xs_hex.h xs_io.h xs_json.h xs_openssl.h xs_glob.h \ 52data.o: data.c xs.h xs_hex.h xs_io.h xs_json.h xs_openssl.h xs_glob.h \
@@ -68,16 +68,16 @@ mastoapi.o: mastoapi.c xs.h xs_hex.h xs_openssl.h xs_json.h xs_io.h \
68 xs_time.h xs_glob.h xs_set.h xs_random.h xs_url.h xs_mime.h xs_match.h \ 68 xs_time.h xs_glob.h xs_set.h xs_random.h xs_url.h xs_mime.h xs_match.h \
69 xs_unicode.h xs_http.h xs_http_codes.h snac.h 69 xs_unicode.h xs_http.h xs_http_codes.h snac.h
70rss.o: rss.c xs.h xs_html.h xs_regex.h xs_time.h xs_match.h xs_curl.h \ 70rss.o: rss.c xs.h xs_html.h xs_regex.h xs_time.h xs_match.h xs_curl.h \
71 xs_openssl.h xs_json.h xs_http.h xs_http_codes.h snac.h 71 xs_openssl.h xs_json.h xs_http.h xs_http_codes.h xs_unicode.h snac.h
72sandbox.o: sandbox.c xs.h snac.h 72sandbox.o: sandbox.c xs.h snac.h
73snac.o: snac.c xs.h xs_hex.h xs_io.h xs_unicode_tbl.h xs_unicode.h \ 73snac.o: snac.c xs.h xs_hex.h xs_io.h xs_unicode_tbl.h xs_unicode.h \
74 xs_json.h xs_curl.h xs_openssl.h xs_socket.h xs_unix_socket.h xs_url.h \ 74 xs_json.h xs_curl.h xs_openssl.h xs_socket.h xs_unix_socket.h xs_url.h \
75 xs_http.h xs_http_codes.h xs_httpd.h xs_mime.h xs_regex.h xs_set.h \ 75 xs_http.h xs_http_codes.h xs_httpd.h xs_mime.h xs_regex.h xs_set.h \
76 xs_time.h xs_glob.h xs_random.h xs_match.h xs_fcgi.h xs_html.h xs_po.h \ 76 xs_time.h xs_glob.h xs_random.h xs_match.h xs_fcgi.h xs_html.h xs_po.h \
77 xs_webmention.h snac.h 77 xs_webmention.h xs_list_tools.h snac.h
78upgrade.o: upgrade.c xs.h xs_io.h xs_json.h xs_glob.h snac.h 78upgrade.o: upgrade.c xs.h xs_io.h xs_json.h xs_glob.h snac.h
79utils.o: utils.c xs.h xs_io.h xs_json.h xs_time.h xs_openssl.h \ 79utils.o: utils.c xs.h xs_io.h xs_json.h xs_time.h xs_openssl.h \
80 xs_random.h xs_glob.h xs_curl.h xs_regex.h xs_http.h xs_http_codes.h \ 80 xs_random.h xs_glob.h xs_curl.h xs_regex.h xs_http.h xs_http_codes.h \
81 snac.h 81 xs_list_tools.h xs_set.h snac.h
82webfinger.o: webfinger.c xs.h xs_json.h xs_curl.h xs_mime.h xs_http.h \ 82webfinger.o: webfinger.c xs.h xs_json.h xs_curl.h xs_mime.h xs_http.h \
83 xs_http_codes.h snac.h 83 xs_http_codes.h snac.h
diff --git a/Makefile.NetBSD b/Makefile.NetBSD
index b5005ee..e752910 100644
--- a/Makefile.NetBSD
+++ b/Makefile.NetBSD
@@ -35,7 +35,7 @@ uninstall:
35 rm $(PREFIX_MAN)/man5/snac.5 35 rm $(PREFIX_MAN)/man5/snac.5
36 rm $(PREFIX_MAN)/man8/snac.8 36 rm $(PREFIX_MAN)/man8/snac.8
37 37
38activitypub.o: activitypub.c xs.h xs_json.h xs_curl.h xs_mime.h \ 38activitypub.o: activitypub.c xs.h xs_json.h xs_curl.h xs_url.h xs_mime.h \
39 xs_openssl.h xs_regex.h xs_time.h xs_set.h xs_match.h xs_unicode.h \ 39 xs_openssl.h xs_regex.h xs_time.h xs_set.h xs_match.h xs_unicode.h \
40 xs_webmention.h xs_http.h xs_http_codes.h snac.h 40 xs_webmention.h xs_http.h xs_http_codes.h snac.h
41data.o: data.c xs.h xs_hex.h xs_io.h xs_json.h xs_openssl.h xs_glob.h \ 41data.o: data.c xs.h xs_hex.h xs_io.h xs_json.h xs_openssl.h xs_glob.h \
@@ -57,16 +57,16 @@ mastoapi.o: mastoapi.c xs.h xs_hex.h xs_openssl.h xs_json.h xs_io.h \
57 xs_time.h xs_glob.h xs_set.h xs_random.h xs_url.h xs_mime.h xs_match.h \ 57 xs_time.h xs_glob.h xs_set.h xs_random.h xs_url.h xs_mime.h xs_match.h \
58 xs_unicode.h xs_http.h xs_http_codes.h snac.h 58 xs_unicode.h xs_http.h xs_http_codes.h snac.h
59rss.o: rss.c xs.h xs_html.h xs_regex.h xs_time.h xs_match.h xs_curl.h \ 59rss.o: rss.c xs.h xs_html.h xs_regex.h xs_time.h xs_match.h xs_curl.h \
60 xs_openssl.h xs_json.h xs_http.h xs_http_codes.h snac.h 60 xs_openssl.h xs_json.h xs_http.h xs_http_codes.h xs_unicode.h snac.h
61sandbox.o: sandbox.c xs.h snac.h 61sandbox.o: sandbox.c xs.h snac.h
62snac.o: snac.c xs.h xs_hex.h xs_io.h xs_unicode_tbl.h xs_unicode.h \ 62snac.o: snac.c xs.h xs_hex.h xs_io.h xs_unicode_tbl.h xs_unicode.h \
63 xs_json.h xs_curl.h xs_openssl.h xs_socket.h xs_unix_socket.h xs_url.h \ 63 xs_json.h xs_curl.h xs_openssl.h xs_socket.h xs_unix_socket.h xs_url.h \
64 xs_http.h xs_http_codes.h xs_httpd.h xs_mime.h xs_regex.h xs_set.h \ 64 xs_http.h xs_http_codes.h xs_httpd.h xs_mime.h xs_regex.h xs_set.h \
65 xs_time.h xs_glob.h xs_random.h xs_match.h xs_fcgi.h xs_html.h xs_po.h \ 65 xs_time.h xs_glob.h xs_random.h xs_match.h xs_fcgi.h xs_html.h xs_po.h \
66 xs_webmention.h snac.h 66 xs_webmention.h xs_list_tools.h snac.h
67upgrade.o: upgrade.c xs.h xs_io.h xs_json.h xs_glob.h snac.h 67upgrade.o: upgrade.c xs.h xs_io.h xs_json.h xs_glob.h snac.h
68utils.o: utils.c xs.h xs_io.h xs_json.h xs_time.h xs_openssl.h \ 68utils.o: utils.c xs.h xs_io.h xs_json.h xs_time.h xs_openssl.h \
69 xs_random.h xs_glob.h xs_curl.h xs_regex.h xs_http.h xs_http_codes.h \ 69 xs_random.h xs_glob.h xs_curl.h xs_regex.h xs_http.h xs_http_codes.h \
70 snac.h 70 xs_list_tools.h xs_set.h snac.h
71webfinger.o: webfinger.c xs.h xs_json.h xs_curl.h xs_mime.h xs_http.h \ 71webfinger.o: webfinger.c xs.h xs_json.h xs_curl.h xs_mime.h xs_http.h \
72 xs_http_codes.h snac.h 72 xs_http_codes.h snac.h
diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md
index 9d41588..e3f7b89 100644
--- a/RELEASE_NOTES.md
+++ b/RELEASE_NOTES.md
@@ -2,9 +2,39 @@
2 2
3## UNRELEASED 3## UNRELEASED
4 4
5Fixed crash in pronouns processing (contributed by byte).
6
7Added counters in the people page (contributed by byte).
8
9New command-line option `refresh`, to refresh all follower and following `Actor` objects, marking them as broken if they are.
10
11## 2.88
12
13If `disable_emojireact` is set to `true` in `server.json`, EmojiReacts (incoming and outgoing) are totally disabled.
14
15New command-line option `top_ten`, that returns the top ten most popular posts by a user (ordered by the sum of likes and boosts) (contributed by aov).
16
17Added a new set of per-user muted words; if a post contains any of them, it's hidden behind a dropdown (contributed by byte).
18
19If an account has a metadata named `pronouns`, it's shown by the name (contributed by violette).
20
21Mastodon API: children of a post are returned recursively, not just the first level (contributed by violette).
22
23Implemented optional metadata stripping for images and videos using external tools (contributed by Stefano Marinelli).
24
25## 2.87
26
27Hide EmojiReacts from muted actors and blocked instances.
28
29## 2.86
30
31Truncate RSS titles at UTF-8 character boundaries (contributed by lxo).
32
33Link contacts to single-user people pages. Also, user's posts are shown (contributed by lxo).
34
5Added emoji reactions (contributed by violette). 35Added emoji reactions (contributed by violette).
6 36
7Mastodon API: Fix for some client notifications (contributed by violette). 37Mastodon API: Fix for some client notifications (contributed by violette), fix for a status visibility error (contributed by fruye).
8 38
9If the query variable `terse` of a public post page is set to anything, no header is shown. 39If the query variable `terse` of a public post page is set to anything, no header is shown.
10 40
diff --git a/activitypub.c b/activitypub.c
index 59df31a..c34e510 100644
--- a/activitypub.c
+++ b/activitypub.c
@@ -1,5 +1,5 @@
1/* snac - A simple, minimalistic ActivityPub instance */ 1/* snac - A simple, minimalistic ActivityPub instance */
2/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */ 2/* copyright (c) 2022 - 2026 grunfink et al. / MIT license */
3 3
4#include "xs.h" 4#include "xs.h"
5#include "xs_json.h" 5#include "xs_json.h"
@@ -1611,7 +1611,7 @@ xs_dict *msg_emoji_init(snac *snac, const char *mid, const char *eid_o)
1611 xs *dict = xs_dict_new(); 1611 xs *dict = xs_dict_new();
1612 xs *icon = xs_dict_new(); 1612 xs *icon = xs_dict_new();
1613 xs *accounts = xs_list_new(); 1613 xs *accounts = xs_list_new();
1614 xs *emjs = emojis(); 1614 xs *emjs = emojis_rm_categories();
1615 1615
1616 /* may be a default emoji */ 1616 /* may be a default emoji */
1617 xs *eidd = xs_dup(eid); 1617 xs *eidd = xs_dup(eid);
@@ -2578,6 +2578,11 @@ int process_input_message(snac *snac, const xs_dict *msg, const xs_dict *req)
2578 return -1; 2578 return -1;
2579 } 2579 }
2580 2580
2581 if (strcmp(type, "EmojiReact") == 0 && xs_is_true(xs_dict_get(srv_config, "disable_emojireact"))) {
2582 srv_log(xs_fmt("Dropping EmojiReact from %s due to admin configuration", actor));
2583 return -1;
2584 }
2585
2581 const char *object, *utype; 2586 const char *object, *utype;
2582 2587
2583 object = xs_dict_get(msg, "object"); 2588 object = xs_dict_get(msg, "object");
@@ -2953,7 +2958,7 @@ int process_input_message(snac *snac, const xs_dict *msg, const xs_dict *req)
2953 if (xs_is_null(object)) 2958 if (xs_is_null(object))
2954 snac_log(snac, xs_fmt("malformed message: no 'id' field")); 2959 snac_log(snac, xs_fmt("malformed message: no 'id' field"));
2955 else 2960 else
2956 if (timeline_admire(snac, object, actor, 1, xs_dup(msg)) == HTTP_STATUS_CREATED) 2961 if (timeline_admire(snac, object, actor, 1, msg) == HTTP_STATUS_CREATED)
2957 snac_log(snac, xs_fmt("new '%s' %s %s", type, actor, object)); 2962 snac_log(snac, xs_fmt("new '%s' %s %s", type, actor, object));
2958 else 2963 else
2959 snac_log(snac, xs_fmt("repeated '%s' from %s to %s", type, actor, object)); 2964 snac_log(snac, xs_fmt("repeated '%s' from %s to %s", type, actor, object));
@@ -3390,7 +3395,7 @@ void process_user_queue_item(snac *user, xs_dict *q_item)
3390 actor_add(actor, actor_o); 3395 actor_add(actor, actor_o);
3391 } 3396 }
3392 else { 3397 else {
3393 if (status == HTTP_STATUS_GONE) { 3398 if (status == HTTP_STATUS_GONE || status == HTTP_STATUS_NOT_FOUND) {
3394 actor_failure(actor, 1); 3399 actor_failure(actor, 1);
3395 snac_log(user, xs_fmt("actor_refresh marking actor %s as broken %d", actor, status)); 3400 snac_log(user, xs_fmt("actor_refresh marking actor %s as broken %d", actor, status));
3396 } 3401 }
diff --git a/data.c b/data.c
index 023bae6..f32dc81 100644
--- a/data.c
+++ b/data.c
@@ -1,5 +1,5 @@
1/* snac - A simple, minimalistic ActivityPub instance */ 1/* snac - A simple, minimalistic ActivityPub instance */
2/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */ 2/* copyright (c) 2022 - 2026 grunfink et al. / MIT license */
3 3
4#include "xs.h" 4#include "xs.h"
5#include "xs_hex.h" 5#include "xs_hex.h"
@@ -89,8 +89,15 @@ int srv_open(const char *basedir, int auto_upgrade)
89 else { 89 else {
90 if (xs_number_get(xs_dict_get(srv_config, "layout")) < disk_layout) 90 if (xs_number_get(xs_dict_get(srv_config, "layout")) < disk_layout)
91 error = xs_fmt("ERROR: disk layout changed - execute 'snac upgrade' first"); 91 error = xs_fmt("ERROR: disk layout changed - execute 'snac upgrade' first");
92 else 92 else {
93 ret = 1; 93 if (!check_strip_tool()) {
94 const char *mp = xs_dict_get(srv_config, "mogrify_path");
95 if (mp == NULL) mp = "mogrify";
96 error = xs_fmt("ERROR: strip_exif enabled but '%s' not found or not working (set 'mogrify_path' in server.json)", mp);
97 }
98 else
99 ret = 1;
100 }
94 } 101 }
95 } 102 }
96 103
@@ -2710,6 +2717,8 @@ void static_put(snac *snac, const char *id, const char *data, int size)
2710 if (fn && (f = fopen(fn, "wb")) != NULL) { 2717 if (fn && (f = fopen(fn, "wb")) != NULL) {
2711 fwrite(data, size, 1, f); 2718 fwrite(data, size, 1, f);
2712 fclose(f); 2719 fclose(f);
2720
2721 strip_media(fn);
2713 } 2722 }
2714} 2723}
2715 2724
diff --git a/doc/snac.1 b/doc/snac.1
index cabd623..dbf63f4 100644
--- a/doc/snac.1
+++ b/doc/snac.1
@@ -110,6 +110,9 @@ URLs to RSS feeds of ActivityPub objects are also allowed
110.It Blocked hashtags... 110.It Blocked hashtags...
111Enter here the list of hashtags you want to block, one 111Enter here the list of hashtags you want to block, one
112per line, with or without the # symbol. 112per line, with or without the # symbol.
113.It Muted words...
114Enter here a list of words to be silenced. If a post includes
115any of this words, it's hidden behind a dropdown.
113.El 116.El
114.Pp 117.Pp
115The user setup dialog allows some user information to be 118The user setup dialog allows some user information to be
@@ -218,13 +221,20 @@ also private and cannot be liked nor boosted.
218For each entry in the timeline, a set of reasonable actions 221For each entry in the timeline, a set of reasonable actions
219in the form of buttons will be shown. These can be: 222in the form of buttons will be shown. These can be:
220.Bl -tag -offset indent 223.Bl -tag -offset indent
221.It Reply 224.It Reply...
222Unveils a text area to write your intelligent and acute comment 225Unveils a text area to write your intelligent and acute comment
223to an uninformed fellow. This note is sent to the original 226to an uninformed fellow. This note is sent to the original
224author as well as to your followers. The note can include 227author as well as to your followers. The note can include
225mentions in the @user@format; these people will also become 228mentions in the @user@format; these people will also become
226recipients of the message. If you reply to a boost or like, 229recipients of the message. If you reply to a boost or like,
227you are really replying to the note, not to the admirer of it. 230you are really replying to the note, not to the admirer of it.
231.It Emoji react...
232Unveils a text area that allows a user to react with an emoji
233by typing its identifier, that should match one of the emojis
234defined in the
235.Pa emojis.json
236file. By now, only those emoji identifiers surrounded by colons
237can be used.
228.It Like 238.It Like
229Click this if you admire this post. The poster and your 239Click this if you admire this post. The poster and your
230followers will be informed. 240followers will be informed.
@@ -405,6 +415,9 @@ Removes an existing list.
405Adds an account (by its @name@host handle or actor URL) to a list. 415Adds an account (by its @name@host handle or actor URL) to a list.
406.It Cm list_del Ar basedir Ar uid Ar name Ar actor_url 416.It Cm list_del Ar basedir Ar uid Ar name Ar actor_url
407Deletes an actor (by its actor URL) from a list. 417Deletes an actor (by its actor URL) from a list.
418.It Cm top_ten Ar basedir Ar uid Op N
419Returns the ids of the top ten (or top N) most popular posts (considering the
420sum of likes and boosts).
408.El 421.El
409.Ss Migrating an account to/from Mastodon 422.Ss Migrating an account to/from Mastodon
410See 423See
diff --git a/doc/snac.8 b/doc/snac.8
index b8a75fa..d961ed2 100644
--- a/doc/snac.8
+++ b/doc/snac.8
@@ -296,6 +296,20 @@ outgoing messages (default: 15). Anyway, whenever any incoming activity from a
296failed instance is detected, this counter is reset for it. 296failed instance is detected, this counter is reset for it.
297.It Ic vkey 297.It Ic vkey
298Public vapid key. Used for notification on some client. 298Public vapid key. Used for notification on some client.
299.It Ic disable_emojireact
300If set to true, all EmojiReact support (for input and output) is disabled.
301.It Ic strip_exif
302If set to true, EXIF and other metadata will be stripped from uploaded images (jpg, png, webp, heic, avif, tiff, gif, bmp) and videos (mp4, m4v, mov, webm, mkv, avi). This requires the
303.Nm mogrify
304(from ImageMagick) and
305.Nm ffmpeg
306tools to be installed. If
307.Nm snac
308cannot find or execute these tools at startup, it will refuse to run.
309.It Ic mogrify_path
310Overrides the default "mogrify" command name or path. Use this if the tool is not in the system PATH or has a different name.
311.It Ic ffmpeg_path
312Overrides the default "ffmpeg" command name or path. Use this if the tool is not in the system PATH or has a different name.
299.El 313.El
300.Pp 314.Pp
301You must restart the server to make effective these changes. 315You must restart the server to make effective these changes.
diff --git a/doc/style.css b/doc/style.css
index 2981926..9c8764a 100644
--- a/doc/style.css
+++ b/doc/style.css
@@ -40,3 +40,4 @@ blockquote { font-style: italic; }
40 a { color: #7799dd } 40 a { color: #7799dd }
41 a:visited { color: #aa99dd } 41 a:visited { color: #aa99dd }
42} 42}
43select { max-width: 40%; }
diff --git a/format.c b/format.c
index 4f93b7b..95c1b12 100644
--- a/format.c
+++ b/format.c
@@ -1,5 +1,5 @@
1/* snac - A simple, minimalistic ActivityPub instance */ 1/* snac - A simple, minimalistic ActivityPub instance */
2/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */ 2/* copyright (c) 2022 - 2026 grunfink et al. / MIT license */
3 3
4#include "xs.h" 4#include "xs.h"
5#include "xs_regex.h" 5#include "xs_regex.h"
@@ -79,6 +79,24 @@ xs_dict *emojis(void)
79 return d; 79 return d;
80} 80}
81 81
82
83xs_dict *emojis_rm_categories() {
84 xs *emjs = emojis();
85 char *res = xs_dict_new();
86 const char *k, *v;
87 xs_dict_foreach(emjs, k, v) {
88 if (xs_type(v) == XSTYPE_DICT) {
89 const char *v2;
90 xs_dict_foreach(v, k, v2)
91 res = xs_dict_append(res, k, v2);
92 }
93 else
94 res = xs_dict_append(res, k, v);
95 }
96 return res;
97}
98
99
82/* Non-whitespace without trailing comma, period or closing paren */ 100/* Non-whitespace without trailing comma, period or closing paren */
83#define NOSPACE "([^[:space:],.)]+|[,.)]+[^[:space:],.)])+" 101#define NOSPACE "([^[:space:],.)]+|[,.)]+[^[:space:],.)])+"
84 102
@@ -405,7 +423,7 @@ xs_str *not_really_markdown(const char *content, xs_list **attach, xs_list **tag
405 423
406 { 424 {
407 /* traditional emoticons */ 425 /* traditional emoticons */
408 xs *d = emojis(); 426 xs *d = emojis_rm_categories();
409 int c = 0; 427 int c = 0;
410 const char *k, *v; 428 const char *k, *v;
411 429
diff --git a/html.c b/html.c
index 20ad839..3f5435c 100644
--- a/html.c
+++ b/html.c
@@ -1,5 +1,5 @@
1/* snac - A simple, minimalistic ActivityPub instance */ 1/* snac - A simple, minimalistic ActivityPub instance */
2/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */ 2/* copyright (c) 2022 - 2026 grunfink et al. / MIT license */
3 3
4#include "xs.h" 4#include "xs.h"
5#include "xs_io.h" 5#include "xs_io.h"
@@ -16,6 +16,7 @@
16#include "xs_url.h" 16#include "xs_url.h"
17#include "xs_random.h" 17#include "xs_random.h"
18#include "xs_http.h" 18#include "xs_http.h"
19#include "xs_list_tools.h"
19 20
20#include "snac.h" 21#include "snac.h"
21 22
@@ -161,6 +162,34 @@ xs_str *actor_name(xs_dict *actor, const char *proxy)
161} 162}
162 163
163 164
165xs_str *actor_pronouns(xs_dict *actor)
166/* gets the actor name */
167{
168 const xs_list *attachment;
169 const xs_dict *d;
170 const char *v;
171 const char *pronouns = "";
172 xs_str *ret;
173
174 if (xs_is_list((attachment = xs_dict_get(actor, "attachment")))) {
175 xs_list_foreach(attachment, d) {
176 xs *prop = xs_utf8_to_lower(xs_dict_get(d, "name"));
177 /* make sure that we are reading the correct metadata */
178 if (strlen(prop) == 8 && strcmp(prop, "pronouns") == 0) {
179 /* safeguard from NULL values */
180 v = xs_dict_get(d, "value");
181 pronouns = v ? v : pronouns;
182 break;
183 }
184 }
185 }
186
187 /* strip all HTML tags */
188 ret = xs_regex_replace(pronouns, "</?[^>]+>", "");
189 return ret;
190}
191
192
164xs_str *format_text_with_emoji(snac *user, const char *text, int ems, const char *proxy) 193xs_str *format_text_with_emoji(snac *user, const char *text, int ems, const char *proxy)
165/* needed when we have local text with no tags attached */ 194/* needed when we have local text with no tags attached */
166{ 195{
@@ -194,6 +223,7 @@ xs_html *html_actor_icon(snac *user, xs_dict *actor, const char *date,
194 int fwer = 0; 223 int fwer = 0;
195 224
196 xs *name = actor_name(actor, proxy); 225 xs *name = actor_name(actor, proxy);
226 xs *pronouns = actor_pronouns(actor);
197 227
198 /* get the avatar */ 228 /* get the avatar */
199 if ((v = xs_dict_get(actor, "icon")) != NULL) { 229 if ((v = xs_dict_get(actor, "icon")) != NULL) {
@@ -221,23 +251,35 @@ xs_html *html_actor_icon(snac *user, xs_dict *actor, const char *date,
221 anchored link to the people page instead of the actor url */ 251 anchored link to the people page instead of the actor url */
222 if (fwer || fwing) { 252 if (fwer || fwing) {
223 xs *md5 = xs_md5_hex(actor_id, strlen(actor_id)); 253 xs *md5 = xs_md5_hex(actor_id, strlen(actor_id));
224 href = xs_fmt("%s/people#%s", user->actor, md5); 254 href = xs_fmt("%s/people/%s", user->actor, md5);
225 } 255 }
226 } 256 }
227 257
228 if (href == NULL) 258 if (href == NULL)
229 href = xs_dup(actor_id); 259 href = xs_dup(actor_id);
230 260
261 xs_html *name_link = xs_html_tag("a",
262 xs_html_attr("href", href),
263 xs_html_attr("class", "p-author h-card snac-author"),
264 xs_html_raw(name)); /* name is already html-escaped */
265
266 if (*pronouns) {
267 xs_html_add(name_link,
268 xs_html_text(" ["),
269 xs_html_tag("span",
270 xs_html_attr("class", "snac-pronouns"),
271 xs_html_attr("title", "user's pronouns"),
272 xs_html_raw(pronouns)),
273 xs_html_text("]"));
274 }
275
231 xs_html_add(actor_icon, 276 xs_html_add(actor_icon,
232 xs_html_sctag("img", 277 xs_html_sctag("img",
233 xs_html_attr("loading", "lazy"), 278 xs_html_attr("loading", "lazy"),
234 xs_html_attr("class", "snac-avatar"), 279 xs_html_attr("class", "snac-avatar"),
235 xs_html_attr("src", avatar), 280 xs_html_attr("src", avatar),
236 xs_html_attr("alt", "[?]")), 281 xs_html_attr("alt", "[?]")),
237 xs_html_tag("a", 282 name_link);
238 xs_html_attr("href", href),
239 xs_html_attr("class", "p-author h-card snac-author"),
240 xs_html_raw(name))); /* name is already html-escaped */
241 283
242 if (!xs_is_null(url)) { 284 if (!xs_is_null(url)) {
243 xs *md5 = xs_md5_hex(url, strlen(url)); 285 xs *md5 = xs_md5_hex(url, strlen(url));
@@ -250,6 +292,7 @@ xs_html *html_actor_icon(snac *user, xs_dict *actor, const char *date,
250 xs_html_text("»"))); 292 xs_html_text("»")));
251 } 293 }
252 294
295
253 if (strcmp(xs_dict_get(actor, "type"), "Service") == 0) { 296 if (strcmp(xs_dict_get(actor, "type"), "Service") == 0) {
254 xs_html_add(actor_icon, 297 xs_html_add(actor_icon,
255 xs_html_text(" "), 298 xs_html_text(" "),
@@ -1864,6 +1907,38 @@ xs_html *html_top_controls(snac *user)
1864 xs_html_attr("class", "button"), 1907 xs_html_attr("class", "button"),
1865 xs_html_attr("value", L("Update hashtags"))))))); 1908 xs_html_attr("value", L("Update hashtags")))))));
1866 1909
1910 xs *muted_words_action = xs_fmt("%s/admin/muted-words", user->actor);
1911 xs *muted_words = xs_join(xs_dict_get_def(user->config,
1912 "muted_words", xs_stock(XSTYPE_LIST)), "\n");
1913
1914 xs_html_add(top_controls,
1915 xs_html_tag("details",
1916 xs_html_tag("summary",
1917 xs_html_text(L("Muted words..."))),
1918 xs_html_tag("p",
1919 xs_html_text(L("One word per line, partial matches count"))),
1920 xs_html_tag("div",
1921 xs_html_attr("class", "snac-muted-words"),
1922 xs_html_tag("form",
1923 xs_html_attr("autocomplete", "off"),
1924 xs_html_attr("method", "post"),
1925 xs_html_attr("action", muted_words_action),
1926 xs_html_attr("enctype", "multipart/form-data"),
1927
1928 xs_html_tag("textarea",
1929 xs_html_attr("name", "muted_words"),
1930 xs_html_attr("cols", "40"),
1931 xs_html_attr("rows", "4"),
1932 xs_html_attr("placeholder", "nascar\nsuperbowl\nFIFA"),
1933 xs_html_text(muted_words)),
1934
1935 xs_html_tag("br", NULL),
1936
1937 xs_html_sctag("input",
1938 xs_html_attr("type", "submit"),
1939 xs_html_attr("class", "button"),
1940 xs_html_attr("value", L("Update muted words")))))));
1941
1867 return top_controls; 1942 return top_controls;
1868} 1943}
1869 1944
@@ -2103,7 +2178,7 @@ xs_html *html_entry_controls(snac *user, const char *actor,
2103 xs_html_tag("p", NULL)); 2178 xs_html_tag("p", NULL));
2104 } 2179 }
2105 2180
2106 { /** emoji react **/ 2181 if (!xs_is_true(xs_dict_get(srv_config, "disable_emojireact"))) { /** emoji react **/
2107 /* the post textarea */ 2182 /* the post textarea */
2108 xs *div_id = xs_fmt("%s_reply", md5); 2183 xs *div_id = xs_fmt("%s_reply", md5);
2109 xs *form_id = xs_fmt("%s_reply_form", md5); 2184 xs *form_id = xs_fmt("%s_reply_form", md5);
@@ -2144,6 +2219,30 @@ xs_html *html_entry_controls(snac *user, const char *actor,
2144} 2219}
2145 2220
2146 2221
2222static const xs_str* words_in_content(const xs_list *words, const xs_val *content)
2223/* returns a word that matches any of the words in content */
2224{
2225 if (!xs_is_list(words) || !xs_is_string(content)) {
2226 return NULL;
2227 }
2228 xs *c = xs_split(content, " ");
2229 xs *sc = xs_list_sort(c, NULL);
2230
2231 const xs_str *wv;
2232 const xs_str *cv;
2233 xs_list_foreach(words, wv) {
2234 xs_list_foreach(sc, cv) {
2235 xs_tolower_i((xs_str*)cv);
2236 if(xs_str_in(cv, wv) != -1){
2237 return wv;
2238 }
2239 }
2240 }
2241
2242 return NULL;
2243}
2244
2245
2147xs_html *html_entry(snac *user, xs_dict *msg, int read_only, 2246xs_html *html_entry(snac *user, xs_dict *msg, int read_only,
2148 int level, const char *md5, int hide_children) 2247 int level, const char *md5, int hide_children)
2149{ 2248{
@@ -2355,7 +2454,7 @@ xs_html *html_entry(snac *user, xs_dict *msg, int read_only,
2355 } 2454 }
2356 2455
2357 if (!read_only && (fwers || fwing)) 2456 if (!read_only && (fwers || fwing))
2358 href = xs_fmt("%s/people#%s", user->actor, p); 2457 href = xs_fmt("%s/people/%s", user->actor, p);
2359 else 2458 else
2360 href = xs_dup(id); 2459 href = xs_dup(id);
2361 2460
@@ -2438,6 +2537,17 @@ xs_html *html_entry(snac *user, xs_dict *msg, int read_only,
2438 xs_html_text(v), 2537 xs_html_text(v),
2439 xs_html_text(L(" [SENSITIVE CONTENT]")))); 2538 xs_html_text(L(" [SENSITIVE CONTENT]"))));
2440 } 2539 }
2540 else
2541 if (user &&
2542 /* muted_words is all lowercase and sorted for performance */
2543 (v = words_in_content(xs_dict_get(user->config, "muted_words"),
2544 xs_dict_get(msg, "content"))) != NULL) {
2545 snac_debug(user, 1, xs_fmt("word %s muted by user preferences: %s", v, id));
2546 snac_content = xs_html_tag("details",
2547 xs_html_tag("summary",
2548 xs_html_text(L("Muted: ")),
2549 xs_html_text(v)));
2550 }
2441 else { 2551 else {
2442 snac_content = xs_html_tag("div", NULL); 2552 snac_content = xs_html_tag("div", NULL);
2443 } 2553 }
@@ -2447,7 +2557,7 @@ xs_html *html_entry(snac *user, xs_dict *msg, int read_only,
2447 2557
2448 /* add all emoji reacts */ 2558 /* add all emoji reacts */
2449 int is_emoji = 0; 2559 int is_emoji = 0;
2450 { 2560 if (!xs_is_true(xs_dict_get(srv_config, "disable_emojireact"))) {
2451 int c = 0; 2561 int c = 0;
2452 const xs_dict *k; 2562 const xs_dict *k;
2453 xs *ls = xs_list_new(); 2563 xs *ls = xs_list_new();
@@ -2460,6 +2570,10 @@ xs_html *html_entry(snac *user, xs_dict *msg, int read_only,
2460 const char *content = xs_dict_get(m, "content"); 2570 const char *content = xs_dict_get(m, "content");
2461 const char *actor = xs_dict_get(m, "actor"); 2571 const char *actor = xs_dict_get(m, "actor");
2462 const xs_list *contentl = xs_dict_get(sfrl, content); 2572 const xs_list *contentl = xs_dict_get(sfrl, content);
2573
2574 if ((user && is_muted(user, actor)) || is_instance_blocked(actor))
2575 continue;
2576
2463 xs *actors = xs_list_new(); 2577 xs *actors = xs_list_new();
2464 actors = xs_list_append(actors, actor); 2578 actors = xs_list_append(actors, actor);
2465 char me = actor && user && strcmp(actor, user->actor) == 0; 2579 char me = actor && user && strcmp(actor, user->actor) == 0;
@@ -3593,13 +3707,19 @@ xs_str *html_timeline(snac *user, const xs_list *list, int read_only,
3593} 3707}
3594 3708
3595 3709
3596xs_html *html_people_list(snac *user, xs_list *list, const char *header, const char *t, const char *proxy) 3710xs_html *html_people_list(snac *user, xs_list *list, const char *header, const char *t, const char *proxy, int do_count)
3597{ 3711{
3598 xs_html *snac_posts; 3712 xs_html *snac_posts;
3713 xs *header_cnt;
3714 if (do_count)
3715 header_cnt = xs_fmt("%s - %d\n", header, xs_list_len(list));
3716 else
3717 header_cnt = xs_fmt("%s\n", header);
3718
3599 xs_html *people = xs_html_tag("div", 3719 xs_html *people = xs_html_tag("div",
3600 xs_html_tag("h2", 3720 xs_html_tag("h2",
3601 xs_html_attr("class", "snac-header"), 3721 xs_html_attr("class", "snac-header"),
3602 xs_html_text(header)), 3722 xs_html_text(header_cnt)),
3603 snac_posts = xs_html_tag("details", 3723 snac_posts = xs_html_tag("details",
3604 xs_html_attr("open", NULL), 3724 xs_html_attr("open", NULL),
3605 xs_html_tag("summary", 3725 xs_html_tag("summary",
@@ -3840,12 +3960,12 @@ xs_str *html_people(snac *user)
3840 3960
3841 if (xs_list_len(pending) || xs_is_true(xs_dict_get(user->config, "approve_followers"))) { 3961 if (xs_list_len(pending) || xs_is_true(xs_dict_get(user->config, "approve_followers"))) {
3842 xs_html_add(lists, 3962 xs_html_add(lists,
3843 html_people_list(user, pending, L("Pending follow confirmations"), "p", proxy)); 3963 html_people_list(user, pending, L("Pending follow confirmations"), "p", proxy, 1));
3844 } 3964 }
3845 3965
3846 xs_html_add(lists, 3966 xs_html_add(lists,
3847 html_people_list(user, wing, L("People you follow"), "i", proxy), 3967 html_people_list(user, wing, L("People you follow"), "i", proxy, 1),
3848 html_people_list(user, wers, L("People that follow you"), "e", proxy)); 3968 html_people_list(user, wers, L("People that follow you"), "e", proxy, 1));
3849 3969
3850 xs_html *html = xs_html_tag("html", 3970 xs_html *html = xs_html_tag("html",
3851 html_user_head(user, NULL, NULL), 3971 html_user_head(user, NULL, NULL),
@@ -3856,6 +3976,108 @@ xs_str *html_people(snac *user)
3856 return xs_html_render_s(html, "<!DOCTYPE html>\n"); 3976 return xs_html_render_s(html, "<!DOCTYPE html>\n");
3857} 3977}
3858 3978
3979/* Filter list to display only posts by actor. We'll probably show
3980 fewer than show posts. Should we try harder to find some? */
3981xs_str *html_people_one(snac *user, const char *actor, const xs_list *list,
3982 int skip, int show, int show_more, const char *page)
3983{
3984 const char *proxy = NULL;
3985 xs_list *p = (xs_list *)list;
3986 const char *v;
3987
3988 if (xs_is_true(xs_dict_get(srv_config, "proxy_media")))
3989 proxy = user->actor;
3990
3991 xs_html *body = html_user_body(user, 0);
3992
3993 xs_html *lists = xs_html_tag("div",
3994 xs_html_attr("class", "snac-posts"));
3995
3996 xs *foll = xs_list_append(xs_list_new(), actor);
3997
3998 xs_html_add(lists,
3999 html_people_list(user, foll, L("Contact's posts"), "p", proxy, 0));
4000
4001 xs_html_add(body, lists);
4002
4003 while (xs_list_iter(&p, &v)) {
4004 xs *msg = NULL;
4005 int status;
4006
4007 status = timeline_get_by_md5(user, v, &msg);
4008
4009 if (!valid_status(status))
4010 continue;
4011
4012 const char *id = xs_dict_get(msg, "id");
4013 const char *by = get_atto(msg);
4014 xs *actor_md5 = NULL;
4015 xs_list *boosts = NULL;
4016 xs_list *likes = NULL;
4017 xs_list *reacts = NULL;
4018 /* Besides actor's posts, also show actor's boosts, and also
4019 posts by user with likes or reacts by actor. I.e., any
4020 actor's actions that user could have seen in the timeline
4021 or in notifications. */
4022 if (!(by && strcmp(actor, by) == 0) &&
4023 xs_list_in((boosts = object_announces(id)),
4024 (actor_md5 = xs_md5_hex(actor, strlen(actor)))) == -1 &&
4025 (!(by && strcmp(user->actor, by) == 0) ||
4026 (xs_list_in((likes = object_likes(id)), actor_md5) == -1 &&
4027 xs_list_in((reacts = object_get_emoji_reacts(id)), actor_md5) == -1)))
4028 continue;
4029
4030 xs_html *entry = html_entry(user, msg, 0, 0, v, 1);
4031
4032 if (entry != NULL)
4033 xs_html_add(lists,
4034 entry);
4035 }
4036
4037 if (show_more) {
4038 xs *m = NULL;
4039 xs *m10 = NULL;
4040 xs *ss = xs_fmt("skip=%d&show=%d", skip + show, show);
4041
4042 xs *url = xs_dup(user == NULL ? srv_baseurl : user->actor);
4043
4044 if (page != NULL)
4045 url = xs_str_cat(url, page);
4046
4047 if (xs_str_in(url, "?") != -1)
4048 m = xs_fmt("%s&%s", url, ss);
4049 else
4050 m = xs_fmt("%s?%s", url, ss);
4051
4052 m10 = xs_fmt("%s0", m);
4053
4054 xs_html *more_links = xs_html_tag("p",
4055 xs_html_tag("a",
4056 xs_html_attr("href", url),
4057 xs_html_attr("name", "snac-more"),
4058 xs_html_text(L("Back to top"))),
4059 xs_html_text(" - "),
4060 xs_html_tag("a",
4061 xs_html_attr("href", m),
4062 xs_html_attr("name", "snac-more"),
4063 xs_html_text(L("More..."))),
4064 xs_html_text(" - "),
4065 xs_html_tag("a",
4066 xs_html_attr("href", m10),
4067 xs_html_attr("name", "snac-more"),
4068 xs_html_text(L("More (x 10)..."))));
4069
4070 xs_html_add(body,
4071 more_links);
4072 }
4073
4074 xs_html *html = xs_html_tag("html",
4075 html_user_head(user, NULL, NULL),
4076 xs_html_add(body,
4077 html_footer(user)));
4078
4079 return xs_html_render_s(html, "<!DOCTYPE html>\n");
4080}
3859 4081
3860xs_str *html_notifications(snac *user, int skip, int show) 4082xs_str *html_notifications(snac *user, int skip, int show)
3861{ 4083{
@@ -3924,6 +4146,9 @@ xs_str *html_notifications(snac *user, int skip, int show)
3924 if (xs_is_string(id2) && xs_set_add(&rep, id2) != 1) 4146 if (xs_is_string(id2) && xs_set_add(&rep, id2) != 1)
3925 continue; 4147 continue;
3926 4148
4149 if (strcmp(type, "EmojiReact") == 0 && xs_is_true(xs_dict_get(srv_config, "disable_emojireact")))
4150 continue;
4151
3927 object_get(id, &obj); 4152 object_get(id, &obj);
3928 4153
3929 const char *msg_id = NULL; 4154 const char *msg_id = NULL;
@@ -4271,8 +4496,12 @@ int html_get_handler(const xs_dict *req, const char *q_path,
4271 cache = 0; 4496 cache = 0;
4272 4497
4273 int skip = 0; 4498 int skip = 0;
4499 const char *max_show_default = "50";
4500 int max_show = xs_number_get(xs_dict_get_def(srv_config, "max_timeline_entries",
4501 max_show_default));
4274 int def_show = xs_number_get(xs_dict_get_def(srv_config, "def_timeline_entries", 4502 int def_show = xs_number_get(xs_dict_get_def(srv_config, "def_timeline_entries",
4275 xs_dict_get_def(srv_config, "max_timeline_entries", "50"))); 4503 xs_dict_get_def(srv_config, "max_timeline_entries",
4504 max_show_default)));
4276 int show = def_show; 4505 int show = def_show;
4277 4506
4278 if ((v = xs_dict_get(q_vars, "skip")) != NULL) 4507 if ((v = xs_dict_get(q_vars, "skip")) != NULL)
@@ -4298,6 +4527,8 @@ int html_get_handler(const xs_dict *req, const char *q_path,
4298 /* a show of 0 has no sense */ 4527 /* a show of 0 has no sense */
4299 if (show == 0) 4528 if (show == 0)
4300 show = def_show; 4529 show = def_show;
4530 if (show > max_show)
4531 show = max_show;
4301 4532
4302 if (p_path == NULL) { /** public timeline **/ 4533 if (p_path == NULL) { /** public timeline **/
4303 xs *h = xs_str_localtime(0, "%Y-%m.html"); 4534 xs *h = xs_str_localtime(0, "%Y-%m.html");
@@ -4343,12 +4574,14 @@ int html_get_handler(const xs_dict *req, const char *q_path,
4343 status = HTTP_STATUS_UNAUTHORIZED; 4574 status = HTTP_STATUS_UNAUTHORIZED;
4344 } 4575 }
4345 else { 4576 else {
4346 xs *q = NULL; 4577 const char *q = NULL;
4347 const char *q1 = xs_dict_get(q_vars, "q"); 4578 xs *cq = xs_dup(xs_dict_get(q_vars, "q"));
4348 xs *url_acct = NULL; 4579 xs *url_acct = NULL;
4349 4580
4350 if (xs_is_string(q1)) 4581 if (xs_is_string(cq)) {
4351 q = xs_strip_i(xs_dup(q1)); 4582 cq = xs_strip_i(cq);
4583 q = cq;
4584 }
4352 4585
4353 /* searching for an URL? */ 4586 /* searching for an URL? */
4354 if (q && xs_match(q, "https://*|http://*")) { 4587 if (q && xs_match(q, "https://*|http://*")) {
@@ -4413,11 +4646,11 @@ int html_get_handler(const xs_dict *req, const char *q_path,
4413 actor_add(actor, actor_obj); 4646 actor_add(actor, actor_obj);
4414 4647
4415 /* create a people list with only one element */ 4648 /* create a people list with only one element */
4416 l = xs_list_append(xs_list_new(), actor); 4649 l = xs_list_append(l, actor);
4417 4650
4418 xs *title = xs_fmt(L("Search results for account %s"), q); 4651 xs *title = xs_fmt(L("Search results for account %s"), q);
4419 4652
4420 page = html_people_list(&snac, l, title, "wf", NULL); 4653 page = html_people_list(&snac, l, title, "wf", NULL, 1);
4421 } 4654 }
4422 } 4655 }
4423 4656
@@ -4553,6 +4786,37 @@ int html_get_handler(const xs_dict *req, const char *q_path,
4553 } 4786 }
4554 } 4787 }
4555 else 4788 else
4789 if (xs_startswith(p_path, "people/")) { /** a single actor **/
4790 if (!login(&snac, req)) {
4791 *body = xs_dup(uid);
4792 status = HTTP_STATUS_UNAUTHORIZED;
4793 }
4794 else {
4795 xs *actor_dict = NULL;
4796 const char *actor_id = NULL;
4797 xs *actor = NULL;
4798 xs_list *page_lst = xs_split_n(p_path, "?", 2);
4799 xs *page = xs_str_cat(xs_str_new("/"), xs_list_get(page_lst, 0));
4800 xs_list *l = xs_split_n(page, "/", 3);
4801 const char *actor_md5 = xs_list_get(l, 2);
4802
4803 if (valid_status(object_get_by_md5(actor_md5, &actor_dict)) &&
4804 (actor_id = xs_dict_get(actor_dict, "id")) != NULL &&
4805 valid_status(actor_get(actor_id, &actor))) {
4806 int more = 0;
4807 xs *list = timeline_simple_list(&snac, "private", skip, show, &more);
4808
4809 *body = html_people_one(&snac, actor_id, list, skip, show, more, page);
4810 *b_size = strlen(*body);
4811 status = HTTP_STATUS_OK;
4812 }
4813 else {
4814 *body = xs_dup(uid);
4815 status = HTTP_STATUS_NOT_FOUND;
4816 }
4817 }
4818 }
4819 else
4556 if (strcmp(p_path, "notifications") == 0) { /** the list of notifications **/ 4820 if (strcmp(p_path, "notifications") == 0) { /** the list of notifications **/
4557 if (!login(&snac, req)) { 4821 if (!login(&snac, req)) {
4558 *body = xs_dup(uid); 4822 *body = xs_dup(uid);
@@ -5665,6 +5929,33 @@ int html_post_handler(const xs_dict *req, const char *q_path,
5665 5929
5666 status = HTTP_STATUS_SEE_OTHER; 5930 status = HTTP_STATUS_SEE_OTHER;
5667 } 5931 }
5932 else
5933 if (p_path && strcmp(p_path, "admin/muted-words") == 0) {
5934 const char *words = xs_dict_get(p_vars, "muted_words");
5935
5936 if (xs_is_string(words)) {
5937 xs *new_words = xs_list_new();
5938 xs *l = xs_split(words, "\n");
5939 const char *v;
5940
5941 xs_list_foreach(l, v) {
5942 xs *s1 = xs_strip_i(xs_dup(v));
5943 s1 = xs_replace_i(s1, " ", "");
5944
5945 if (*s1 == '\0')
5946 continue;
5947
5948 xs *s2 = xs_utf8_to_lower(s1);
5949
5950 new_words = xs_list_insert_sorted(new_words, s2);
5951 }
5952
5953 snac.config = xs_dict_set(snac.config, "muted_words", new_words);
5954 user_persist(&snac, 0);
5955 }
5956
5957 status = HTTP_STATUS_SEE_OTHER;
5958 }
5668 5959
5669 if (status == HTTP_STATUS_SEE_OTHER) { 5960 if (status == HTTP_STATUS_SEE_OTHER) {
5670 const char *hard_redir = xs_dict_get(p_vars, "hard-redir"); 5961 const char *hard_redir = xs_dict_get(p_vars, "hard-redir");
diff --git a/http.c b/http.c
index f0b1314..4f0b2fa 100644
--- a/http.c
+++ b/http.c
@@ -1,5 +1,5 @@
1/* snac - A simple, minimalistic ActivityPub instance */ 1/* snac - A simple, minimalistic ActivityPub instance */
2/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */ 2/* copyright (c) 2022 - 2026 grunfink et al. / MIT license */
3 3
4#include "xs.h" 4#include "xs.h"
5#include "xs_io.h" 5#include "xs_io.h"
diff --git a/httpd.c b/httpd.c
index f93f197..f46cfca 100644
--- a/httpd.c
+++ b/httpd.c
@@ -1,5 +1,5 @@
1/* snac - A simple, minimalistic ActivityPub instance */ 1/* snac - A simple, minimalistic ActivityPub instance */
2/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */ 2/* copyright (c) 2022 - 2026 grunfink et al. / MIT license */
3 3
4#include "xs.h" 4#include "xs.h"
5#include "xs_io.h" 5#include "xs_io.h"
diff --git a/main.c b/main.c
index 4b0463e..e7d5b0d 100644
--- a/main.c
+++ b/main.c
@@ -1,5 +1,5 @@
1/* snac - A simple, minimalistic ActivityPub instance */ 1/* snac - A simple, minimalistic ActivityPub instance */
2/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */ 2/* copyright (c) 2022 - 2026 grunfink et al. / MIT license */
3 3
4#include "xs.h" 4#include "xs.h"
5#include "xs_io.h" 5#include "xs_io.h"
@@ -18,7 +18,7 @@
18int usage(const char *cmd) 18int usage(const char *cmd)
19{ 19{
20 printf("snac " VERSION " - A simple, minimalistic ActivityPub instance\n"); 20 printf("snac " VERSION " - A simple, minimalistic ActivityPub instance\n");
21 printf("Copyright (c) 2022 - 2025 grunfink et al. / MIT license\n"); 21 printf("Copyright (c) 2022 - 2026 grunfink et al. / MIT license\n");
22 printf("\n"); 22 printf("\n");
23 23
24 if (cmd == NULL) { 24 if (cmd == NULL) {
@@ -76,7 +76,9 @@ int usage(const char *cmd)
76 "list_create {basedir} {uid} {name} Creates a new list\n" 76 "list_create {basedir} {uid} {name} Creates a new list\n"
77 "list_remove {basedir} {uid} {name} Removes an existing list\n" 77 "list_remove {basedir} {uid} {name} Removes an existing list\n"
78 "list_add {basedir} {uid} {name} {acct} Adds an account (@user@host or actor url) to a list\n" 78 "list_add {basedir} {uid} {name} {acct} Adds an account (@user@host or actor url) to a list\n"
79 "list_del {basedir} {uid} {name} {actor} Deletes an actor URL from a list\n"; 79 "list_del {basedir} {uid} {name} {actor} Deletes an actor URL from a list\n"
80 "top_ten {basedir} {uid} [{N}] Prints the most popular posts\n"
81 "refresh {basedir} {uid} Refreshes all actors\n";
80 82
81 if (cmd == NULL) 83 if (cmd == NULL)
82 printf("%s", cmds); 84 printf("%s", cmds);
@@ -350,6 +352,38 @@ int main(int argc, char *argv[])
350 return 0; 352 return 0;
351 } 353 }
352 354
355 if (strcmp(cmd, "top_ten") == 0) { /** **/
356 int count = 10;
357 const char *n = GET_ARGV();
358 if (xs_is_string(n))
359 count = atoi(n);
360
361 xs *l = user_top_ten(&snac, count);
362 const xs_list *i;
363
364 xs_list_foreach(l, i) {
365 printf("%s %ld★ %ld↺\n", xs_list_get(i, 0),
366 xs_number_get_l(xs_list_get(i, 1)),
367 xs_number_get_l(xs_list_get(i, 2)));
368 }
369
370 return 0;
371 }
372
373 if (strcmp(cmd, "refresh") == 0) { /** **/
374 xs *fwers = follower_list(&snac);
375 xs *fwing = following_list(&snac);
376 const char *id;
377
378 xs_list_foreach(fwers, id)
379 enqueue_actor_refresh(&snac, id, 0);
380
381 xs_list_foreach(fwing, id)
382 enqueue_actor_refresh(&snac, id, 0);
383
384 return 0;
385 }
386
353 if ((url = GET_ARGV()) == NULL) 387 if ((url = GET_ARGV()) == NULL)
354 return usage(cmd); 388 return usage(cmd);
355 389
diff --git a/mastoapi.c b/mastoapi.c
index 6a12ced..dd80abc 100644
--- a/mastoapi.c
+++ b/mastoapi.c
@@ -1,5 +1,5 @@
1/* snac - A simple, minimalistic ActivityPub instance */ 1/* snac - A simple, minimalistic ActivityPub instance */
2/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */ 2/* copyright (c) 2022 - 2026 grunfink et al. / MIT license */
3 3
4#ifndef NO_MASTODON_API 4#ifndef NO_MASTODON_API
5 5
@@ -1171,6 +1171,10 @@ xs_dict *mastoapi_status(snac *snac, const xs_dict *msg)
1171 const char *content = xs_dict_get(msg, "content"); 1171 const char *content = xs_dict_get(msg, "content");
1172 const char *actor = xs_dict_get(msg, "actor"); 1172 const char *actor = xs_dict_get(msg, "actor");
1173 const xs_list *contentl = xs_dict_get(sfrl, content); 1173 const xs_list *contentl = xs_dict_get(sfrl, content);
1174
1175 if ((snac && is_muted(snac, actor)) || is_instance_blocked(actor))
1176 continue;
1177
1174 /* NOTE: idk when there are no actor, but i encountered that bug. 1178 /* NOTE: idk when there are no actor, but i encountered that bug.
1175 * Probably because of one of my previous attempts. 1179 * Probably because of one of my previous attempts.
1176 * Keeping this just in case, can remove later */ 1180 * Keeping this just in case, can remove later */
@@ -1805,6 +1809,37 @@ xs_list *mastoapi_account_lists(snac *user, const char *uid)
1805} 1809}
1806 1810
1807 1811
1812xs_list *build_childrens(const xs_dict *msg, snac *snac1) {
1813 xs_list *ret = xs_list_new();
1814 xs *children = object_children(xs_dict_get(msg, "id"));
1815 char *p = children;
1816 const xs_str *v;
1817
1818 while (xs_list_iter(&p, &v)) {
1819 xs *m2 = NULL;
1820
1821 if (valid_status(timeline_get_by_md5(snac1, v, &m2))) {
1822 if (xs_is_null(xs_dict_get(m2, "name"))) {
1823 xs *st = mastoapi_status(snac1, m2);
1824
1825 if (st) {
1826 /* childrens children */
1827 xs *childs = build_childrens(m2, snac1);
1828 ret = xs_list_append(ret, st);
1829 if (xs_list_len(childs)) {
1830 char *p2 = childs;
1831 while (xs_list_iter(&p2, &v))
1832 ret = xs_list_append(ret, v);
1833
1834 }
1835 }
1836 }
1837 }
1838 }
1839 return ret;
1840}
1841
1842
1808int mastoapi_get_handler(const xs_dict *req, const char *q_path, 1843int mastoapi_get_handler(const xs_dict *req, const char *q_path,
1809 char **body, int *b_size, char **ctype, xs_str **link) 1844 char **body, int *b_size, char **ctype, xs_str **link)
1810{ 1845{
@@ -2612,19 +2647,33 @@ int mastoapi_get_handler(const xs_dict *req, const char *q_path,
2612 if (strcmp(cmd, "/v1/custom_emojis") == 0) { /** **/ 2647 if (strcmp(cmd, "/v1/custom_emojis") == 0) { /** **/
2613 xs *emo = emojis(); 2648 xs *emo = emojis();
2614 xs *list = xs_list_new(); 2649 xs *list = xs_list_new();
2615 int c = 0;
2616 const xs_str *k; 2650 const xs_str *k;
2617 const xs_val *v; 2651 const xs_val *v;
2618 while(xs_dict_next(emo, &k, &v, &c)) { 2652 xs_dict_foreach(emo, k, v) {
2619 xs *current = xs_dict_new(); 2653 xs *current = xs_dict_new();
2620 if (xs_startswith(v, "https://") && xs_startswith((xs_mime_by_ext(v)), "image/")) { 2654 if ((xs_startswith(v, "https://") && xs_startswith((xs_mime_by_ext(v)), "image/")) || xs_type(v) == XSTYPE_DICT) {
2621 /* remove first and last colon */ 2655 /* remove first and last colon */
2622 xs *shortcode = xs_replace(k, ":", ""); 2656 if (xs_type(v) == XSTYPE_DICT) {
2623 current = xs_dict_append(current, "shortcode", shortcode); 2657 const char *v2;
2624 current = xs_dict_append(current, "url", v); 2658 const char *cat = k;
2625 current = xs_dict_append(current, "static_url", v); 2659 xs_dict_foreach(v, k, v2) {
2626 current = xs_dict_append(current, "visible_in_picker", xs_stock(XSTYPE_TRUE)); 2660 xs *shortcode = xs_replace(k, ":", "");
2627 list = xs_list_append(list, current); 2661 current = xs_dict_append(current, "shortcode", shortcode);
2662 current = xs_dict_append(current, "url", v2);
2663 current = xs_dict_append(current, "static_url", v2);
2664 current = xs_dict_append(current, "visible_in_picker", xs_stock(XSTYPE_TRUE));
2665 current = xs_dict_append(current, "category", cat);
2666 list = xs_list_append(list, current);
2667 }
2668 }
2669 else {
2670 xs *shortcode = xs_replace(k, ":", "");
2671 current = xs_dict_append(current, "shortcode", shortcode);
2672 current = xs_dict_append(current, "url", v);
2673 current = xs_dict_append(current, "static_url", v);
2674 current = xs_dict_append(current, "visible_in_picker", xs_stock(XSTYPE_TRUE));
2675 list = xs_list_append(list, current);
2676 }
2628 } 2677 }
2629 } 2678 }
2630 *body = xs_json_dumps(list, 0); 2679 *body = xs_json_dumps(list, 0);
@@ -2817,8 +2866,6 @@ int mastoapi_get_handler(const xs_dict *req, const char *q_path,
2817 /* return ancestors and children */ 2866 /* return ancestors and children */
2818 xs *anc = xs_list_new(); 2867 xs *anc = xs_list_new();
2819 xs *des = xs_list_new(); 2868 xs *des = xs_list_new();
2820 xs_list *p;
2821 const xs_str *v;
2822 char pid[MD5_HEX_SIZE]; 2869 char pid[MD5_HEX_SIZE];
2823 2870
2824 /* build the [grand]parent list, moving up */ 2871 /* build the [grand]parent list, moving up */
@@ -2838,21 +2885,9 @@ int mastoapi_get_handler(const xs_dict *req, const char *q_path,
2838 } 2885 }
2839 2886
2840 /* build the children list */ 2887 /* build the children list */
2841 xs *children = object_children(xs_dict_get(msg, "id")); 2888 xs *childs = build_childrens(msg, &snac1);
2842 p = children; 2889 if (xs_list_len(childs) > 0)
2843 2890 des = xs_list_cat(des, childs);
2844 while (xs_list_iter(&p, &v)) {
2845 xs *m2 = NULL;
2846
2847 if (valid_status(timeline_get_by_md5(&snac1, v, &m2))) {
2848 if (xs_is_null(xs_dict_get(m2, "name"))) {
2849 xs *st = mastoapi_status(&snac1, m2);
2850
2851 if (st)
2852 des = xs_list_append(des, st);
2853 }
2854 }
2855 }
2856 2891
2857 out = xs_dict_new(); 2892 out = xs_dict_new();
2858 out = xs_dict_append(out, "ancestors", anc); 2893 out = xs_dict_append(out, "ancestors", anc);
diff --git a/po/pt_BR.po b/po/pt_BR.po
index e930490..71b0132 100644
--- a/po/pt_BR.po
+++ b/po/pt_BR.po
@@ -4,13 +4,13 @@
4msgid "" 4msgid ""
5msgstr "" 5msgstr ""
6"Project-Id-Version: snac\n" 6"Project-Id-Version: snac\n"
7"PO-Revision-Date: 2025-10-11 16:48-0300\n" 7"PO-Revision-Date: 2025-12-22 07:55-0300\n"
8"Last-Translator: Daltux <@daltux@snac.daltux.net>\n" 8"Last-Translator: Daltux <@daltux@snac.daltux.net>\n"
9"Language: pt_BR\n" 9"Language: pt_BR\n"
10"MIME-Version: 1.0\n" 10"MIME-Version: 1.0\n"
11"Content-Type: text/plain; charset=UTF-8\n" 11"Content-Type: text/plain; charset=UTF-8\n"
12"Content-Transfer-Encoding: 8bit\n" 12"Content-Transfer-Encoding: 8bit\n"
13"X-Generator: Poedit 3.7\n" 13"X-Generator: Geany / PoHelper 2.0\n"
14 14
15#: html.c:534 15#: html.c:534
16msgid "Sensitive content: " 16msgid "Sensitive content: "
@@ -800,17 +800,17 @@ msgid "Direct Message"
800msgstr "Mensagem direta" 800msgstr "Mensagem direta"
801 801
802#: html.c:488 html.c:2534 html.c:2559 html.c:5177 802#: html.c:488 html.c:2534 html.c:2559 html.c:5177
803msgid "EmojiUnreact" 803msgid "Desfazer EmojiReação"
804msgstr "" 804msgstr ""
805 805
806#: html.c:488 html.c:1440 html.c:2534 html.c:2559 html.c:5188 806#: html.c:488 html.c:1440 html.c:2534 html.c:2559 html.c:5188
807msgid "EmojiReact" 807msgid "EmojiReact"
808msgstr "" 808msgstr "EmojiReação"
809 809
810#: html.c:2115 810#: html.c:2115
811msgid "Emoji react..." 811msgid "Emoji react..."
812msgstr "" 812msgstr "EmojiReagir..."
813 813
814#: html.c:2609 814#: html.c:2609
815msgid "Emoji reactions: " 815msgid "Emoji reactions: "
816msgstr "" 816msgstr "EmojiReações: "
diff --git a/rss.c b/rss.c
index 6124e7a..dc26071 100644
--- a/rss.c
+++ b/rss.c
@@ -1,5 +1,5 @@
1/* snac - A simple, minimalistic ActivityPub instance */ 1/* snac - A simple, minimalistic ActivityPub instance */
2/* copyright (c) 2025 grunfink et al. / MIT license */ 2/* copyright (c) 2025 - 2026 grunfink et al. / MIT license */
3 3
4#include "xs.h" 4#include "xs.h"
5#include "xs_html.h" 5#include "xs_html.h"
@@ -10,6 +10,7 @@
10#include "xs_openssl.h" 10#include "xs_openssl.h"
11#include "xs_json.h" 11#include "xs_json.h"
12#include "xs_http.h" 12#include "xs_http.h"
13#include "xs_unicode.h"
13 14
14#include "snac.h" 15#include "snac.h"
15 16
@@ -74,7 +75,14 @@ xs_str *rss_from_timeline(snac *user, const xs_list *timeline,
74 title = xs_regex_replace_i(title, "&[^;]+;", " "); 75 title = xs_regex_replace_i(title, "&[^;]+;", " ");
75 int i; 76 int i;
76 77
77 for (i = 0; title[i] && title[i] != '\n' && i < 50; i++); 78 for (i = 0; title[i] && title[i] != '\n' && i < 50; ) {
79 const char *p = &title[i];
80 unsigned int cp = xs_utf8_dec(&p);
81 int n = p - title;
82 if (cp == 0xfffd || n > 50)
83 break;
84 i = n;
85 }
78 86
79 if (title[i] != '\0') { 87 if (title[i] != '\0') {
80 title[i] = '\0'; 88 title[i] = '\0';
diff --git a/snac.c b/snac.c
index 31f524f..87b0d63 100644
--- a/snac.c
+++ b/snac.c
@@ -1,5 +1,5 @@
1/* snac - A simple, minimalistic ActivityPub instance */ 1/* snac - A simple, minimalistic ActivityPub instance */
2/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */ 2/* copyright (c) 2022 - 2026 grunfink et al. / MIT license */
3 3
4#define XS_IMPLEMENTATION 4#define XS_IMPLEMENTATION
5 5
@@ -27,11 +27,15 @@
27#include "xs_html.h" 27#include "xs_html.h"
28#include "xs_po.h" 28#include "xs_po.h"
29#include "xs_webmention.h" 29#include "xs_webmention.h"
30#include "xs_list_tools.h"
30 31
31#include "snac.h" 32#include "snac.h"
32 33
33#include <sys/time.h> 34#include <sys/time.h>
34#include <sys/stat.h> 35#include <sys/stat.h>
36#include <sys/wait.h>
37#include <limits.h>
38#include <stdlib.h>
35 39
36xs_str *srv_basedir = NULL; 40xs_str *srv_basedir = NULL;
37xs_dict *srv_config = NULL; 41xs_dict *srv_config = NULL;
@@ -170,3 +174,150 @@ int check_password(const char *uid, const char *passwd, const char *hash)
170 174
171 return ret; 175 return ret;
172} 176}
177
178
179int strip_media(const char *fn)
180/* strips EXIF data from a file */
181{
182 int ret = 0;
183
184 const xs_val *v = xs_dict_get(srv_config, "strip_exif");
185
186 if (xs_type(v) == XSTYPE_TRUE) {
187 /* Heuristic: find 'user/' in the path to make it relative */
188 /* This works for ~/user/..., /var/snac/user/..., etc. */
189 const char *r_fn = strstr(fn, "user/");
190
191 if (r_fn == NULL) {
192 /* Fallback: try to strip ~/ if present */
193 if (strncmp(fn, "~/", 2) == 0)
194 r_fn = fn + 2;
195 else
196 r_fn = fn;
197 }
198
199 xs *l_fn = xs_tolower_i(xs_dup(r_fn));
200
201 /* check image extensions */
202 if (xs_endswith(l_fn, ".jpg") || xs_endswith(l_fn, ".jpeg") ||
203 xs_endswith(l_fn, ".png") || xs_endswith(l_fn, ".webp") ||
204 xs_endswith(l_fn, ".heic") || xs_endswith(l_fn, ".heif") ||
205 xs_endswith(l_fn, ".avif") || xs_endswith(l_fn, ".tiff") ||
206 xs_endswith(l_fn, ".gif") || xs_endswith(l_fn, ".bmp")) {
207
208 const char *mp = xs_dict_get(srv_config, "mogrify_path");
209 if (mp == NULL)
210 mp = "mogrify";
211
212 xs *cmd = xs_fmt("cd \"%s\" && %s -auto-orient -strip \"%s\" 2>/dev/null", srv_basedir, mp, r_fn);
213
214 ret = system(cmd);
215
216 if (ret != 0) {
217 int code = 0;
218 if (WIFEXITED(ret))
219 code = WEXITSTATUS(ret);
220
221 if (code == 127)
222 srv_log(xs_fmt("strip_media: error stripping %s. '%s' not found (exit 127). Set 'mogrify_path' in server.json.", r_fn, mp));
223 else
224 srv_log(xs_fmt("strip_media: error stripping %s %d", r_fn, ret));
225 }
226 else
227 srv_debug(1, xs_fmt("strip_media: stripped %s", r_fn));
228 }
229 else
230 /* check video extensions */
231 if (xs_endswith(l_fn, ".mp4") || xs_endswith(l_fn, ".m4v") ||
232 xs_endswith(l_fn, ".mov") || xs_endswith(l_fn, ".webm") ||
233 xs_endswith(l_fn, ".mkv") || xs_endswith(l_fn, ".avi")) {
234
235 const char *fp = xs_dict_get(srv_config, "ffmpeg_path");
236 if (fp == NULL)
237 fp = "ffmpeg";
238
239 /* ffmpeg cannot modify in-place, so we need a temp file */
240 /* we must preserve valid extension for ffmpeg to guess the format */
241 const char *ext = strrchr(r_fn, '.');
242 if (ext == NULL) ext = "";
243 xs *tmp_fn = xs_fmt("%s.tmp%s", r_fn, ext);
244
245 /* -map_metadata -1 strips all global metadata */
246 /* -c copy copies input streams without re-encoding */
247 /* we don't silence stderr so we can debug issues */
248 /* we explicitly cd to srv_basedir to ensure relative paths work */
249 xs *cmd = xs_fmt("cd \"%s\" && %s -y -i \"%s\" -map_metadata -1 -c copy \"%s\"", srv_basedir, fp, r_fn, tmp_fn);
250
251 ret = system(cmd);
252
253 if (ret != 0) {
254 int code = 0;
255 if (WIFEXITED(ret))
256 code = WEXITSTATUS(ret);
257
258 if (code == 127)
259 srv_log(xs_fmt("strip_media: error stripping %s. '%s' not found (exit 127). Set 'ffmpeg_path' in server.json.", r_fn, fp));
260 else {
261 srv_log(xs_fmt("strip_media: error stripping %s %d", r_fn, ret));
262 srv_log(xs_fmt("strip_media: command was: %s", cmd));
263 }
264
265 /* try to cleanup, just in case */
266 /* unlink needs full path too if we are not in basedir */
267 xs *full_tmp_fn = xs_fmt("%s/%s", srv_basedir, tmp_fn);
268 unlink(full_tmp_fn);
269 }
270 else {
271 /* rename tmp file to original */
272 /* use full path for source because it was created relative to basedir */
273 xs *full_tmp_fn = xs_fmt("%s/%s", srv_basedir, tmp_fn);
274
275 if (rename(full_tmp_fn, fn) == 0)
276 srv_debug(1, xs_fmt("strip_media: stripped %s", fn));
277 else
278 srv_log(xs_fmt("strip_media: error renaming %s to %s", full_tmp_fn, fn));
279 }
280 }
281 }
282
283 return ret;
284}
285
286
287int check_strip_tool(void)
288{
289 const xs_val *v = xs_dict_get(srv_config, "strip_exif");
290 int ret = 1;
291
292 if (xs_type(v) == XSTYPE_TRUE) {
293 /* check mogrify */
294 {
295 const char *mp = xs_dict_get(srv_config, "mogrify_path");
296 if (mp == NULL)
297 mp = "mogrify";
298
299 xs *cmd = xs_fmt("%s -version 2>/dev/null >/dev/null", mp);
300
301 if (system(cmd) != 0) {
302 srv_log(xs_fmt("check_strip_tool: '%s' not working", mp));
303 ret = 0;
304 }
305 }
306
307 /* check ffmpeg */
308 if (ret) {
309 const char *fp = xs_dict_get(srv_config, "ffmpeg_path");
310 if (fp == NULL)
311 fp = "ffmpeg";
312
313 xs *cmd = xs_fmt("%s -version 2>/dev/null >/dev/null", fp);
314
315 if (system(cmd) != 0) {
316 srv_log(xs_fmt("check_strip_tool: '%s' not working", fp));
317 ret = 0;
318 }
319 }
320 }
321
322 return ret;
323}
diff --git a/snac.h b/snac.h
index bcff7f8..b6e1da1 100644
--- a/snac.h
+++ b/snac.h
@@ -1,7 +1,7 @@
1/* snac - A simple, minimalistic ActivityPub instance */ 1/* snac - A simple, minimalistic ActivityPub instance */
2/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */ 2/* copyright (c) 2022 - 2026 grunfink et al. / MIT license */
3 3
4#define VERSION "2.86-dev" 4#define VERSION "2.88"
5 5
6#define USER_AGENT "snac/" VERSION 6#define USER_AGENT "snac/" VERSION
7 7
@@ -105,6 +105,9 @@ int validate_uid(const char *uid);
105xs_str *hash_password(const char *uid, const char *passwd, const char *nonce); 105xs_str *hash_password(const char *uid, const char *passwd, const char *nonce);
106int check_password(const char *uid, const char *passwd, const char *hash); 106int check_password(const char *uid, const char *passwd, const char *hash);
107 107
108int strip_media(const char *fn);
109int check_strip_tool(void);
110
108void srv_archive(const char *direction, const char *url, xs_dict *req, 111void srv_archive(const char *direction, const char *url, xs_dict *req,
109 const char *payload, int p_size, 112 const char *payload, int p_size,
110 int status, xs_dict *headers, 113 int status, xs_dict *headers,
@@ -412,6 +415,7 @@ int activitypub_post_handler(const xs_dict *req, const char *q_path,
412 char **body, int *b_size, char **ctype); 415 char **body, int *b_size, char **ctype);
413 416
414xs_dict *emojis(void); 417xs_dict *emojis(void);
418xs_dict *emojis_rm_categories(void);
415xs_str *format_text_with_emoji(snac *user, const char *text, int ems, const char *proxy); 419xs_str *format_text_with_emoji(snac *user, const char *text, int ems, const char *proxy);
416xs_str *not_really_markdown(const char *content, xs_list **attach, xs_list **tag); 420xs_str *not_really_markdown(const char *content, xs_list **attach, xs_list **tag);
417xs_str *sanitize(const char *content); 421xs_str *sanitize(const char *content);
@@ -494,3 +498,5 @@ void rss_to_timeline(snac *user, const char *url);
494void rss_poll_hashtags(void); 498void rss_poll_hashtags(void);
495 499
496void data_fsck(void); 500void data_fsck(void);
501
502xs_list *user_top_ten(snac *user, int count);
diff --git a/upgrade.c b/upgrade.c
index 9e0ae6e..07e9c51 100644
--- a/upgrade.c
+++ b/upgrade.c
@@ -1,5 +1,5 @@
1/* snac - A simple, minimalistic ActivityPub instance */ 1/* snac - A simple, minimalistic ActivityPub instance */
2/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */ 2/* copyright (c) 2022 - 2026 grunfink et al. / MIT license */
3 3
4#include "xs.h" 4#include "xs.h"
5#include "xs_io.h" 5#include "xs_io.h"
diff --git a/utils.c b/utils.c
index 44e4006..9f4e1b7 100644
--- a/utils.c
+++ b/utils.c
@@ -1,5 +1,5 @@
1/* snac - A simple, minimalistic ActivityPub instance */ 1/* snac - A simple, minimalistic ActivityPub instance */
2/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */ 2/* copyright (c) 2022 - 2026 grunfink et al. / MIT license */
3 3
4#include "xs.h" 4#include "xs.h"
5#include "xs_io.h" 5#include "xs_io.h"
@@ -11,6 +11,8 @@
11#include "xs_curl.h" 11#include "xs_curl.h"
12#include "xs_regex.h" 12#include "xs_regex.h"
13#include "xs_http.h" 13#include "xs_http.h"
14#include "xs_list_tools.h"
15#include "xs_set.h"
14 16
15#include "snac.h" 17#include "snac.h"
16 18
@@ -86,6 +88,7 @@ static const char * const default_css =
86 " a { color: #7799dd }\n" 88 " a { color: #7799dd }\n"
87 " a:visited { color: #aa99dd }\n" 89 " a:visited { color: #aa99dd }\n"
88 "}\n" 90 "}\n"
91 "select { max-width: 40%; }\n"
89; 92;
90 93
91const char *snac_blurb = 94const char *snac_blurb =
@@ -1002,6 +1005,76 @@ void import_csv(snac *user)
1002 snac_log(user, xs_fmt("Cannot open file %s", fn)); 1005 snac_log(user, xs_fmt("Cannot open file %s", fn));
1003} 1006}
1004 1007
1008
1009static int top_ten_sort(const void *v1, const void *v2)
1010{
1011 const xs_list *l1 = *(const xs_list **)v1;
1012 const xs_list *l2 = *(const xs_list **)v2;
1013
1014 const char *c1 = xs_list_get(l1, 3);
1015 const char *c2 = xs_list_get(l2, 3);
1016
1017 return xs_cmp(c2, c1);
1018}
1019
1020
1021xs_list *user_top_ten(snac *user, int count)
1022/* returns the top ten more popular posts by a user */
1023{
1024 xs *idx = xs_fmt("%s/private.idx", user->basedir);
1025 xs *list = index_list(idx, XS_ALL);
1026 xs *u_list = xs_list_new();
1027 xs_set u;
1028
1029 xs_set_init(&u);
1030
1031 const char *md5;
1032
1033 xs_list_foreach(list, md5) {
1034 xs *obj = NULL;
1035
1036 if (!valid_status(object_get_by_md5(md5, &obj)))
1037 continue;
1038
1039 const char *id = xs_dict_get_def(obj, "id", "-");
1040
1041 if (!is_msg_mine(user, id))
1042 continue;
1043
1044 if (xs_set_add(&u, id) != 1)
1045 continue;
1046
1047 /* get metrics */
1048 int ls = object_likes_len(id);
1049 int as = object_announces_len(id);
1050
1051 /* build the entry and convert to list */
1052 xs *s = xs_fmt("%s\t%d\t%d\t%010d", id, ls, as, ls + as);
1053 xs *l = xs_split(s, "\t");
1054
1055 u_list = xs_list_append(u_list, l);
1056 }
1057
1058 /* sort by the sum of likes and boosts */
1059 xs *s_list = xs_list_sort(u_list, top_ten_sort);
1060
1061 xs_list *r = xs_list_new();
1062 const xs_list *i;
1063
1064 xs_list_foreach(s_list, i) {
1065 r = xs_list_append(r, i);
1066
1067 if (--count <= 0)
1068 break;
1069 }
1070
1071 xs_set_free(&u);
1072
1073 return r;
1074}
1075
1076
1077
1005static const struct { 1078static const struct {
1006 const char *proto; 1079 const char *proto;
1007 unsigned short default_port; 1080 unsigned short default_port;
diff --git a/webfinger.c b/webfinger.c
index 1ce5e76..264cb85 100644
--- a/webfinger.c
+++ b/webfinger.c
@@ -1,5 +1,5 @@
1/* snac - A simple, minimalistic ActivityPub instance */ 1/* snac - A simple, minimalistic ActivityPub instance */
2/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */ 2/* copyright (c) 2022 - 2026 grunfink et al. / MIT license */
3 3
4#include "xs.h" 4#include "xs.h"
5#include "xs_json.h" 5#include "xs_json.h"
diff --git a/xs.h b/xs.h
index 5f2e6aa..11f0722 100644
--- a/xs.h
+++ b/xs.h
@@ -1,4 +1,4 @@
1/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */ 1/* copyright (c) 2022 - 2026 grunfink et al. / MIT license */
2 2
3#ifndef _XS_H 3#ifndef _XS_H
4 4
diff --git a/xs_curl.h b/xs_curl.h
index feb23e0..4b510ac 100644
--- a/xs_curl.h
+++ b/xs_curl.h
@@ -1,4 +1,4 @@
1/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */ 1/* copyright (c) 2022 - 2026 grunfink et al. / MIT license */
2 2
3#ifndef _XS_CURL_H 3#ifndef _XS_CURL_H
4 4
diff --git a/xs_fcgi.h b/xs_fcgi.h
index b3cb892..0af6fcc 100644
--- a/xs_fcgi.h
+++ b/xs_fcgi.h
@@ -1,4 +1,4 @@
1/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */ 1/* copyright (c) 2022 - 2026 grunfink et al. / MIT license */
2 2
3/* 3/*
4 This is an intentionally-dead-simple FastCGI implementation; 4 This is an intentionally-dead-simple FastCGI implementation;
diff --git a/xs_glob.h b/xs_glob.h
index 6d87ca1..c38ec9a 100644
--- a/xs_glob.h
+++ b/xs_glob.h
@@ -1,4 +1,4 @@
1/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */ 1/* copyright (c) 2022 - 2026 grunfink et al. / MIT license */
2 2
3#ifndef _XS_GLOB_H 3#ifndef _XS_GLOB_H
4 4
diff --git a/xs_hex.h b/xs_hex.h
index 2507d5d..91c528e 100644
--- a/xs_hex.h
+++ b/xs_hex.h
@@ -1,4 +1,4 @@
1/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */ 1/* copyright (c) 2022 - 2026 grunfink et al. / MIT license */
2 2
3#ifndef _XS_HEX_H 3#ifndef _XS_HEX_H
4 4
diff --git a/xs_html.h b/xs_html.h
index 29886c6..ab77499 100644
--- a/xs_html.h
+++ b/xs_html.h
@@ -1,4 +1,4 @@
1/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */ 1/* copyright (c) 2022 - 2026 grunfink et al. / MIT license */
2 2
3#ifndef _XS_HTML_H 3#ifndef _XS_HTML_H
4 4
diff --git a/xs_http.h b/xs_http.h
index f2fd296..b9b5f80 100644
--- a/xs_http.h
+++ b/xs_http.h
@@ -1,4 +1,4 @@
1/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */ 1/* copyright (c) 2022 - 2026 grunfink et al. / MIT license */
2 2
3#ifndef _XS_HTTP_H 3#ifndef _XS_HTTP_H
4 4
diff --git a/xs_httpd.h b/xs_httpd.h
index 9be51e6..4a4689c 100644
--- a/xs_httpd.h
+++ b/xs_httpd.h
@@ -1,4 +1,4 @@
1/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */ 1/* copyright (c) 2022 - 2026 grunfink et al. / MIT license */
2 2
3#ifndef _XS_HTTPD_H 3#ifndef _XS_HTTPD_H
4 4
diff --git a/xs_io.h b/xs_io.h
index 9c5018e..9a03e5b 100644
--- a/xs_io.h
+++ b/xs_io.h
@@ -1,4 +1,4 @@
1/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */ 1/* copyright (c) 2022 - 2026 grunfink et al. / MIT license */
2 2
3#ifndef _XS_IO_H 3#ifndef _XS_IO_H
4 4
diff --git a/xs_json.h b/xs_json.h
index 07800fa..a9577ee 100644
--- a/xs_json.h
+++ b/xs_json.h
@@ -1,4 +1,4 @@
1/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */ 1/* copyright (c) 2022 - 2026 grunfink et al. / MIT license */
2 2
3#ifndef _XS_JSON_H 3#ifndef _XS_JSON_H
4 4
@@ -28,6 +28,8 @@ int xs_json_load_object(FILE *f, int maxdepth, xs_dict **d);
28 28
29/** IMPLEMENTATION **/ 29/** IMPLEMENTATION **/
30 30
31#include "xs_unicode.h"
32
31/** JSON dumps **/ 33/** JSON dumps **/
32 34
33static void _xs_json_dump_str(const char *data, FILE *f) 35static void _xs_json_dump_str(const char *data, FILE *f)
diff --git a/xs_list_tools.h b/xs_list_tools.h
new file mode 100644
index 0000000..33d4b87
--- /dev/null
+++ b/xs_list_tools.h
@@ -0,0 +1,169 @@
1/* copyright (c) 2022 - 2026 grunfink et al. / MIT license */
2
3#ifndef _XS_LIST_TOOLS_H
4
5#define _XS_LIST_TOOLS_H
6
7 xs_list *xs_list_insert_sorted(xs_list *list, const xs_val *nv);
8 xs_list *xs_list_reverse(const xs_list *l);
9 xs_val **xs_list_to_array(const xs_list *l, int *len);
10 int xs_list_sort_cmp(const void *p1, const void *p2);
11 int xs_list_sort_inv_cmp(const void *p1, const void *p2);
12 int xs_list_sort_dict_cmp(const char *field, const void *p1, const void *p2);
13 xs_list *xs_list_sort(const xs_list *l, int (*cmp)(const void *, const void *));
14 xs_list *xs_list_shuffle(const xs_list *l);
15
16#ifdef XS_IMPLEMENTATION
17
18#include "xs_random.h"
19
20xs_list *xs_list_insert_sorted(xs_list *list, const xs_val *nv)
21/* inserts a string in the list in its ordered position */
22{
23 XS_ASSERT_TYPE(list, XSTYPE_LIST);
24
25 int offset = xs_size(list);
26
27 const xs_val *v;
28 xs_list_foreach(list, v) {
29 /* if this element is greater or equal, insert here */
30 if (xs_cmp(v, nv) >= 0) {
31 offset = v - list;
32 break;
33 }
34 }
35
36 return _xs_list_write_litem(list, offset - 1, nv, xs_size(nv));
37}
38
39
40xs_list *xs_list_reverse(const xs_list *l)
41/* creates a new list as a reverse version of l */
42{
43 xs_list *n = xs_dup(l);
44 const xs_val *v;
45
46 /* move to one byte before the EOM */
47 char *p = n + xs_size(n) - 1;
48
49 xs_list_foreach(l, v) {
50 /* size of v, plus the LITEM */
51 int z = xs_size(v) + 1;
52
53 p -= z;
54
55 /* copy v, including its LITEM */
56 memcpy(p, v - 1, z);
57 }
58
59 return n;
60}
61
62
63xs_val **xs_list_to_array(const xs_list *l, int *len)
64/* converts a list to an array of values */
65/* must be freed after use */
66{
67 *len = xs_list_len(l);
68 xs_val **a = xs_realloc(NULL, *len * sizeof(xs_val *));
69 const xs_val *v;
70 int n = 0;
71
72 xs_list_foreach(l, v)
73 a[n++] = (xs_val *)v;
74
75 return a;
76}
77
78
79int xs_list_sort_cmp(const void *p1, const void *p2)
80/* default list sorting function */
81{
82 const xs_val *v1 = *(xs_val **)p1;
83 const xs_val *v2 = *(xs_val **)p2;
84
85 return xs_cmp(v1, v2);
86}
87
88
89int xs_list_sort_inv_cmp(const void *p1, const void *p2)
90/* default list inverse sorting function */
91{
92 const xs_val *v1 = *(xs_val **)p1;
93 const xs_val *v2 = *(xs_val **)p2;
94
95 return xs_cmp(v2, v1);
96}
97
98
99int xs_list_sort_dict_cmp(const char *field, const void *p1, const void *p2)
100/* compare sorting function for a field an array of dicts */
101{
102 const xs_dict *d1 = *(xs_val **)p1;
103 const xs_dict *d2 = *(xs_val **)p2;
104
105 if (xs_type(d1) != XSTYPE_DICT || xs_type(d2) != XSTYPE_DICT)
106 return 0;
107
108 return xs_cmp(xs_dict_get_def(d1, field, ""),
109 xs_dict_get_def(d2, field, ""));
110}
111
112
113xs_list *xs_list_sort(const xs_list *l, int (*cmp)(const void *, const void *))
114/* returns a sorted copy of l. cmp can be null for standard sorting */
115{
116 int sz;
117 xs_val **a = xs_list_to_array(l, &sz);
118 xs_list *nl = xs_dup(l);
119 char *p = nl + 1 + _XS_TYPE_SIZE;
120
121 /* sort the array */
122 qsort(a, sz, sizeof(xs_val *), cmp ? cmp : xs_list_sort_cmp);
123
124 /* transfer the sorted list over the copy */
125 for (int n = 0; n < sz; n++) {
126 /* get the litem */
127 const char *e = a[n] - 1;
128 int z = xs_size(e);
129
130 memcpy(p, e, z);
131 p += z;
132 }
133
134 xs_free(a);
135
136 return nl;
137}
138
139
140xs_list *xs_list_shuffle(const xs_list *l)
141/* returns a shuffled list */
142{
143 int sz;
144 xs_val **a = xs_list_to_array(l, &sz);
145 xs_list *nl = xs_list_new();
146 unsigned int seed = 0;
147
148 xs_rnd_buf(&seed, sizeof(seed));
149
150 /* shuffle */
151 for (int n = sz - 1; n > 0; n--) {
152 int m = xs_rnd_int32_d(&seed) % n;
153 void *p = a[n];
154 a[n] = a[m];
155 a[m] = p;
156 }
157
158 for (int n = 0; n < sz; n++)
159 nl = xs_list_append(nl, a[n]);
160
161 xs_free(a);
162
163 return nl;
164}
165
166
167#endif /* XS_IMPLEMENTATION */
168
169#endif /* XS_LIST_TOOLS_H */
diff --git a/xs_match.h b/xs_match.h
index 76c1bf3..21830e8 100644
--- a/xs_match.h
+++ b/xs_match.h
@@ -1,4 +1,4 @@
1/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */ 1/* copyright (c) 2022 - 2026 grunfink et al. / MIT license */
2 2
3#ifndef _XS_MATCH_H 3#ifndef _XS_MATCH_H
4 4
diff --git a/xs_mime.h b/xs_mime.h
index 0146385..6709ec6 100644
--- a/xs_mime.h
+++ b/xs_mime.h
@@ -1,4 +1,4 @@
1/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */ 1/* copyright (c) 2022 - 2026 grunfink et al. / MIT license */
2 2
3#ifndef _XS_MIME_H 3#ifndef _XS_MIME_H
4 4
diff --git a/xs_openssl.h b/xs_openssl.h
index f215bcc..64b59dd 100644
--- a/xs_openssl.h
+++ b/xs_openssl.h
@@ -1,4 +1,4 @@
1/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */ 1/* copyright (c) 2022 - 2026 grunfink et al. / MIT license */
2 2
3#ifndef _XS_OPENSSL_H 3#ifndef _XS_OPENSSL_H
4 4
diff --git a/xs_po.h b/xs_po.h
index c6feec9..c1ad2c9 100644
--- a/xs_po.h
+++ b/xs_po.h
@@ -1,4 +1,4 @@
1/* copyright (c) 2025 grunfink et al. / MIT license */ 1/* copyright (c) 2025 - 2026 grunfink et al. / MIT license */
2 2
3#ifndef _XS_PO_H 3#ifndef _XS_PO_H
4 4
diff --git a/xs_random.h b/xs_random.h
index f936099..357f9cb 100644
--- a/xs_random.h
+++ b/xs_random.h
@@ -1,4 +1,4 @@
1/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */ 1/* copyright (c) 2022 - 2026 grunfink et al. / MIT license */
2 2
3#ifndef _XS_RANDOM_H 3#ifndef _XS_RANDOM_H
4 4
diff --git a/xs_regex.h b/xs_regex.h
index 1e157d2..5ccefda 100644
--- a/xs_regex.h
+++ b/xs_regex.h
@@ -1,4 +1,4 @@
1/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */ 1/* copyright (c) 2022 - 2026 grunfink et al. / MIT license */
2 2
3#ifndef _XS_REGEX_H 3#ifndef _XS_REGEX_H
4 4
diff --git a/xs_set.h b/xs_set.h
index 8946e42..ae67cc7 100644
--- a/xs_set.h
+++ b/xs_set.h
@@ -1,4 +1,4 @@
1/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */ 1/* copyright (c) 2022 - 2026 grunfink et al. / MIT license */
2 2
3#ifndef _XS_SET_H 3#ifndef _XS_SET_H
4 4
diff --git a/xs_socket.h b/xs_socket.h
index 7bf5298..70bfe98 100644
--- a/xs_socket.h
+++ b/xs_socket.h
@@ -1,4 +1,4 @@
1/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */ 1/* copyright (c) 2022 - 2026 grunfink et al. / MIT license */
2 2
3#ifndef _XS_SOCKET_H 3#ifndef _XS_SOCKET_H
4 4
diff --git a/xs_time.h b/xs_time.h
index 507d238..46b35a1 100644
--- a/xs_time.h
+++ b/xs_time.h
@@ -1,4 +1,4 @@
1/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */ 1/* copyright (c) 2022 - 2026 grunfink et al. / MIT license */
2 2
3#ifndef _XS_TIME_H 3#ifndef _XS_TIME_H
4 4
diff --git a/xs_unicode.h b/xs_unicode.h
index 0b4de1c..7686dcd 100644
--- a/xs_unicode.h
+++ b/xs_unicode.h
@@ -1,4 +1,4 @@
1/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */ 1/* copyright (c) 2022 - 2026 grunfink et al. / MIT license */
2 2
3#ifndef _XS_UNICODE_H 3#ifndef _XS_UNICODE_H
4 4
diff --git a/xs_unix_socket.h b/xs_unix_socket.h
index 462a5b3..1ef7d39 100644
--- a/xs_unix_socket.h
+++ b/xs_unix_socket.h
@@ -1,4 +1,4 @@
1/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */ 1/* copyright (c) 2022 - 2026 grunfink et al. / MIT license */
2 2
3#ifndef _XS_UNIX_SOCKET_H 3#ifndef _XS_UNIX_SOCKET_H
4 4
diff --git a/xs_url.h b/xs_url.h
index 222771f..56d6d9d 100644
--- a/xs_url.h
+++ b/xs_url.h
@@ -1,4 +1,4 @@
1/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */ 1/* copyright (c) 2022 - 2026 grunfink et al. / MIT license */
2 2
3#ifndef _XS_URL_H 3#ifndef _XS_URL_H
4 4
diff --git a/xs_version.h b/xs_version.h
index 34a7a45..92a865e 100644
--- a/xs_version.h
+++ b/xs_version.h
@@ -1 +1 @@
/* 0a8b987d7bb945fe7844411727d03ac73f417455 2025-10-14T05:21:05+02:00 */ /* 270f9376eabd4f8e0ed3ae22a1f8eb6e06ea8b8b 2026-01-10T20:39:12+01:00 */
diff --git a/xs_webmention.h b/xs_webmention.h
index f9578b4..035b3a6 100644
--- a/xs_webmention.h
+++ b/xs_webmention.h
@@ -1,4 +1,4 @@
1/* copyright (c) 2025 grunfink et al. / MIT license */ 1/* copyright (c) 2025 - 2026 grunfink et al. / MIT license */
2 2
3#ifndef _XS_WEBMENTION_H 3#ifndef _XS_WEBMENTION_H
4 4