commit 7eb2556

shtrophic  ·  2025-02-17 19:54:36 +0000 UTC
parents 7611a6b, d40834e
Merge remote-tracking branch 'upstream/master' into curl-smtp
16 files changed,  +1062, -127
M data.c
M html.c
M snac.c
M snac.h
+14, -7
 1@@ -38,24 +38,31 @@ uninstall:
 2 	rm $(PREFIX_MAN)/man5/snac.5
 3 	rm $(PREFIX_MAN)/man8/snac.8
 4 
 5+update-po:
 6+	mkdir -p po
 7+	[ -f "po/en.po" ] || xgettext -o po/en.po --language=C --keyword=L --from-code=utf-8 *.c
 8+	for a in po/*.po ; do \
 9+		xgettext --omit-header -j -o $$a --language=C --keyword=L --from-code=utf-8 *.c ; \
10+	done
11+
12 activitypub.o: activitypub.c xs.h xs_json.h xs_curl.h xs_mime.h \
13  xs_openssl.h xs_regex.h xs_time.h xs_set.h xs_match.h xs_unicode.h \
14  snac.h http_codes.h
15 data.o: data.c xs.h xs_hex.h xs_io.h xs_json.h xs_openssl.h xs_glob.h \
16- xs_set.h xs_time.h xs_regex.h xs_match.h xs_unicode.h xs_random.h snac.h \
17- http_codes.h
18+ xs_set.h xs_time.h xs_regex.h xs_match.h xs_unicode.h xs_random.h \
19+ xs_po.h snac.h http_codes.h
20 format.o: format.c xs.h xs_regex.h xs_mime.h xs_html.h xs_json.h \
21  xs_time.h xs_match.h snac.h http_codes.h
22 html.o: html.c xs.h xs_io.h xs_json.h xs_regex.h xs_set.h xs_openssl.h \
23- xs_time.h xs_mime.h xs_match.h xs_html.h xs_curl.h xs_unicode.h snac.h \
24- http_codes.h
25+ xs_time.h xs_mime.h xs_match.h xs_html.h xs_curl.h xs_unicode.h xs_url.h \
26+ snac.h http_codes.h
27 http.o: http.c xs.h xs_io.h xs_openssl.h xs_curl.h xs_time.h xs_json.h \
28  snac.h http_codes.h
29 httpd.o: httpd.c xs.h xs_io.h xs_json.h xs_socket.h xs_unix_socket.h \
30  xs_httpd.h xs_mime.h xs_time.h xs_openssl.h xs_fcgi.h xs_html.h snac.h \
31  http_codes.h
32-main.o: main.c xs.h xs_io.h xs_json.h xs_time.h xs_openssl.h snac.h \
33- http_codes.h
34+main.o: main.c xs.h xs_io.h xs_json.h xs_time.h xs_openssl.h xs_match.h \
35+ snac.h http_codes.h
36 mastoapi.o: mastoapi.c xs.h xs_hex.h xs_openssl.h xs_json.h xs_io.h \
37  xs_time.h xs_glob.h xs_set.h xs_random.h xs_url.h xs_mime.h xs_match.h \
38  snac.h http_codes.h
39@@ -63,7 +70,7 @@ sandbox.o: sandbox.c xs.h snac.h http_codes.h
40 snac.o: snac.c xs.h xs_hex.h xs_io.h xs_unicode_tbl.h xs_unicode.h \
41  xs_json.h xs_curl.h xs_openssl.h xs_socket.h xs_unix_socket.h xs_url.h \
42  xs_httpd.h xs_mime.h xs_regex.h xs_set.h xs_time.h xs_glob.h xs_random.h \
43- xs_match.h xs_fcgi.h xs_html.h snac.h http_codes.h
44+ xs_match.h xs_fcgi.h xs_html.h xs_po.h snac.h http_codes.h
45 upgrade.o: upgrade.c xs.h xs_io.h xs_json.h xs_glob.h snac.h http_codes.h
46 utils.o: utils.c xs.h xs_io.h xs_json.h xs_time.h xs_openssl.h \
47  xs_random.h xs_glob.h xs_curl.h xs_regex.h snac.h http_codes.h
+7, -7
 1@@ -39,20 +39,20 @@ activitypub.o: activitypub.c xs.h xs_json.h xs_curl.h xs_mime.h \
 2  xs_openssl.h xs_regex.h xs_time.h xs_set.h xs_match.h xs_unicode.h \
 3  snac.h http_codes.h
 4 data.o: data.c xs.h xs_hex.h xs_io.h xs_json.h xs_openssl.h xs_glob.h \
 5- xs_set.h xs_time.h xs_regex.h xs_match.h xs_unicode.h xs_random.h snac.h \
 6- http_codes.h
 7+ xs_set.h xs_time.h xs_regex.h xs_match.h xs_unicode.h xs_random.h \
 8+ xs_po.h snac.h http_codes.h
 9 format.o: format.c xs.h xs_regex.h xs_mime.h xs_html.h xs_json.h \
10  xs_time.h xs_match.h snac.h http_codes.h
11 html.o: html.c xs.h xs_io.h xs_json.h xs_regex.h xs_set.h xs_openssl.h \
12- xs_time.h xs_mime.h xs_match.h xs_html.h xs_curl.h xs_unicode.h snac.h \
13- http_codes.h
14+ xs_time.h xs_mime.h xs_match.h xs_html.h xs_curl.h xs_unicode.h xs_url.h \
15+ snac.h http_codes.h
16 http.o: http.c xs.h xs_io.h xs_openssl.h xs_curl.h xs_time.h xs_json.h \
17  snac.h http_codes.h
18 httpd.o: httpd.c xs.h xs_io.h xs_json.h xs_socket.h xs_unix_socket.h \
19  xs_httpd.h xs_mime.h xs_time.h xs_openssl.h xs_fcgi.h xs_html.h snac.h \
20  http_codes.h
21-main.o: main.c xs.h xs_io.h xs_json.h xs_time.h xs_openssl.h snac.h \
22- http_codes.h
23+main.o: main.c xs.h xs_io.h xs_json.h xs_time.h xs_openssl.h xs_match.h \
24+ snac.h http_codes.h
25 mastoapi.o: mastoapi.c xs.h xs_hex.h xs_openssl.h xs_json.h xs_io.h \
26  xs_time.h xs_glob.h xs_set.h xs_random.h xs_url.h xs_mime.h xs_match.h \
27  snac.h http_codes.h
28@@ -60,7 +60,7 @@ sandbox.o: sandbox.c xs.h snac.h http_codes.h
29 snac.o: snac.c xs.h xs_hex.h xs_io.h xs_unicode_tbl.h xs_unicode.h \
30  xs_json.h xs_curl.h xs_openssl.h xs_socket.h xs_unix_socket.h xs_url.h \
31  xs_httpd.h xs_mime.h xs_regex.h xs_set.h xs_time.h xs_glob.h xs_random.h \
32- xs_match.h xs_fcgi.h xs_html.h snac.h http_codes.h
33+ xs_match.h xs_fcgi.h xs_html.h xs_po.h snac.h http_codes.h
34 upgrade.o: upgrade.c xs.h xs_io.h xs_json.h xs_glob.h snac.h http_codes.h
35 utils.o: utils.c xs.h xs_io.h xs_json.h xs_time.h xs_openssl.h \
36  xs_random.h xs_glob.h xs_curl.h xs_regex.h snac.h http_codes.h
+11, -3
 1@@ -2715,6 +2715,12 @@ int process_user_queue(snac *snac)
 2 }
 3 
 4 
 5+xs_str *str_status(int status)
 6+{
 7+    return xs_fmt("%d %s", status, status < 0 ? xs_curl_strerr(status) : http_status_text(status));
 8+}
 9+
10+
11 void process_queue_item(xs_dict *q_item)
12 /* processes an item from the global queue */
13 {
14@@ -2771,7 +2777,9 @@ void process_queue_item(xs_dict *q_item)
15         else
16             payload = xs_str_new(NULL);
17 
18-        srv_log(xs_fmt("output message: sent to inbox %s %d%s", inbox, status, payload));
19+        xs *s_status = str_status(status);
20+
21+        srv_log(xs_fmt("output message: sent to inbox %s (%s)%s", inbox, s_status, payload));
22 
23         if (!valid_status(status)) {
24             retries++;
25@@ -2789,10 +2797,10 @@ void process_queue_item(xs_dict *q_item)
26                 || status == HTTP_STATUS_UNPROCESSABLE_CONTENT
27                 || status < 0)
28                 /* explicit error: discard */
29-                srv_log(xs_fmt("output message: error %s %d", inbox, status));
30+                srv_log(xs_fmt("output message: error %s (%s)", inbox, s_status));
31             else
32             if (retries > queue_retry_max)
33-                srv_log(xs_fmt("output message: giving up %s %d", inbox, status));
34+                srv_log(xs_fmt("output message: giving up %s (%s)", inbox, s_status));
35             else {
36                 /* requeue */
37                 enqueue_output_raw(keyid, seckey, msg, inbox, retries, status);
M data.c
+45, -0
 1@@ -13,6 +13,7 @@
 2 #include "xs_match.h"
 3 #include "xs_unicode.h"
 4 #include "xs_random.h"
 5+#include "xs_po.h"
 6 
 7 #include "snac.h"
 8 
 9@@ -98,6 +99,9 @@ int srv_open(const char *basedir, int auto_upgrade)
10     if (error != NULL)
11         srv_log(error);
12 
13+    if (!ret)
14+        return ret;
15+
16     /* create the queue/ subdir, just in case */
17     xs *qdir = xs_fmt("%s/queue", srv_basedir);
18     mkdirx(qdir);
19@@ -148,6 +152,29 @@ int srv_open(const char *basedir, int auto_upgrade)
20         mkdirx(expdir);
21     }
22 
23+    /* languages */
24+    srv_langs = xs_dict_new();
25+    srv_langs = xs_dict_set(srv_langs, "en", xs_stock(XSTYPE_NULL));
26+
27+    xs *l_dir = xs_fmt("%s/lang/", srv_basedir);
28+    mkdirx(l_dir);
29+
30+    l_dir = xs_str_cat(l_dir, "*.po");
31+    xs *pos = xs_glob(l_dir, 0, 0);
32+    const char *po;
33+
34+    xs_list_foreach(pos, po) {
35+        xs *d = xs_po_to_dict(po);
36+
37+        if (xs_is_dict(d)) {
38+            xs *l = xs_split(po, "/");
39+            xs *id = xs_dup(xs_list_get(l, -1));
40+            id = xs_replace_i(id, ".po", "");
41+
42+            srv_langs = xs_dict_set(srv_langs, id, d);
43+        }
44+    }
45+
46     return ret;
47 }
48 
49@@ -4064,3 +4091,21 @@ void badlogin_inc(const char *user, const char *addr)
50         pthread_mutex_unlock(&data_mutex);
51     }
52 }
53+
54+
55+/** language strings **/
56+
57+const char *lang_str(const char *str, const snac *user)
58+/* returns a translated string */
59+{
60+    const char *n_str = str;
61+
62+    if (user && xs_is_dict(user->lang) && xs_is_string(str)) {
63+        n_str = xs_dict_get(user->lang, str);
64+
65+        if (xs_is_null(n_str) || *n_str == '\0')
66+            n_str = str;
67+    }
68+
69+    return n_str;
70+}
+10, -7
 1@@ -23,8 +23,12 @@ Ultrix machine in your grandfather basement, probably MacOS) support hard
 2 links on their native filesystems. Don't do fancy things like moving the
 3 subdirectories to different filesystems. Also, if you move your
 4 .Nm
 5-installation to another server, do it with a tool that respect hard
 6-link counts. Remember:
 7+installation to another server, do it with a tool that keeps hard
 8+links, like
 9+.Xr tar 1
10+or
11+.Xr rsync 1
12+with the -H switch. Remember:
13 .Nm
14 is a very UNIXy program that loves hard links.
15 .Ss Building and Installation
16@@ -194,9 +198,7 @@ By setting this to true, no inbox collection is done. Inbox collection helps
17 being discovered from remote instances, but also increases network traffic.
18 .It Ic http_headers
19 If you need to add more HTTP response headers for whatever reason, you can
20-fill this object with the required header/value pairs. For example, for enhanced
21-XSS security, you can set the "Content-Security-Policy" header to "script-src ;"
22-to be totally sure that no JavaScript is executed.
23+fill this object with the required header/value pairs.
24 .It Ic show_instance_timeline
25 If this is set to true, the instance base URL will show a timeline with the latest
26 user posts instead of the default greeting static page. If other information
27@@ -327,8 +329,9 @@ These weapons of mass destruction can be written into the
28 file in the server base directory, one per line; if this file exists,
29 all posts' content will be matched (after being stripped of HTML tags)
30 against these regexes, one by one, and any match will make the post to
31-be rejected. If you don't know about regular expressions, don't use this
32-option (or learn about them in some tutorial, there are gazillions of
33+be rejected. Use lower case, the regex will be case insensitive by default. 
34+If you don't know about regular expressions, don't use this
35+option (or learn about them inw some tutorial, there are gazillions of
36 them out there), as you and your users may start missing posts. Also,
37 given that every regular expression implementation supports a different
38 set of features, consider reading the documentation about the one
+1, -1
1@@ -458,7 +458,7 @@ xs_str *sanitize(const char *content)
2 
3             if (valid_tags[i]) {
4                 /* accepted tag: rebuild it with only the accepted elements */
5-                xs *el = xs_regex_select(v, "(src|href|rel|class|target)=\"[^\"]*\"");
6+                xs *el = xs_regex_select(v, "(src|href|rel|class|target)=(\"[^\"]*\"|'[^']*')");
7                 xs *s3 = xs_join(el, " ");
8 
9                 s2 = xs_fmt("<%s%s%s%s>",
M html.c
+144, -80
  1@@ -17,7 +17,7 @@
  2 
  3 #include "snac.h"
  4 
  5-int login(snac *snac, const xs_dict *headers)
  6+int login(snac *user, const xs_dict *headers)
  7 /* tries a login */
  8 {
  9     int logged_in = 0;
 10@@ -31,23 +31,23 @@ int login(snac *snac, const xs_dict *headers)
 11         xs *l1 = xs_split_n(s2, ":", 1);
 12 
 13         if (xs_list_len(l1) == 2) {
 14-            const char *user = xs_list_get(l1, 0);
 15+            const char *uid  = xs_list_get(l1, 0);
 16             const char *pwd  = xs_list_get(l1, 1);
 17             const char *addr = xs_or(xs_dict_get(headers, "remote-addr"),
 18                                      xs_dict_get(headers, "x-forwarded-for"));
 19 
 20-            if (badlogin_check(user, addr)) {
 21-                logged_in = check_password(user, pwd,
 22-                    xs_dict_get(snac->config, "passwd"));
 23+            if (badlogin_check(uid, addr)) {
 24+                logged_in = check_password(uid, pwd,
 25+                    xs_dict_get(user->config, "passwd"));
 26 
 27                 if (!logged_in)
 28-                    badlogin_inc(user, addr);
 29+                    badlogin_inc(uid, addr);
 30             }
 31         }
 32     }
 33 
 34     if (logged_in)
 35-        lastlog_write(snac, "web");
 36+        lastlog_write(user, "web");
 37 
 38     return logged_in;
 39 }
 40@@ -69,7 +69,7 @@ xs_str *replace_shortnames(xs_str *s, const xs_list *tag, int ems, const char *p
 41 
 42         xs *style = xs_fmt("height: %dem; width: %dem; vertical-align: middle;", ems, ems);
 43 
 44-        const char *v;
 45+        const xs_dict *v;
 46         int c = 0;
 47 
 48         while (xs_list_next(tag_list, &v, &c)) {
 49@@ -77,19 +77,25 @@ xs_str *replace_shortnames(xs_str *s, const xs_list *tag, int ems, const char *p
 50 
 51             if (t && strcmp(t, "Emoji") == 0) {
 52                 const char *n = xs_dict_get(v, "name");
 53-                const char *i = xs_dict_get(v, "icon");
 54+                const xs_dict *i = xs_dict_get(v, "icon");
 55 
 56-                if (n && i) {
 57+                if (xs_is_string(n) && xs_is_dict(i)) {
 58                     const char *u = xs_dict_get(i, "url");
 59-                    xs *url = make_url(u, proxy, 0);
 60+                    const char *mt = xs_dict_get(i, "mediaType");
 61+
 62+                    if (xs_is_string(u) && xs_is_string(mt) && strcmp(mt, "image/svg+xml")) {
 63+                        xs *url = make_url(u, proxy, 0);
 64 
 65-                    xs_html *img = xs_html_sctag("img",
 66-                        xs_html_attr("loading", "lazy"),
 67-                        xs_html_attr("src", url),
 68-                        xs_html_attr("style", style));
 69+                        xs_html *img = xs_html_sctag("img",
 70+                            xs_html_attr("loading", "lazy"),
 71+                            xs_html_attr("src", url),
 72+                            xs_html_attr("style", style));
 73 
 74-                    xs *s1 = xs_html_render(img);
 75-                    s = xs_replace_i(s, n, s1);
 76+                        xs *s1 = xs_html_render(img);
 77+                        s = xs_replace_i(s, n, s1);
 78+                    }
 79+                    else
 80+                        s = xs_replace_i(s, n, "");
 81                 }
 82             }
 83         }
 84@@ -621,6 +627,9 @@ static xs_html *html_instance_body(void)
 85     const char *email    = xs_dict_get(srv_config, "admin_email");
 86     const char *acct     = xs_dict_get(srv_config, "admin_account");
 87 
 88+    /* for L() */
 89+    const snac *user = NULL;
 90+
 91     xs *blurb = xs_replace(snac_blurb, "%host%", host);
 92 
 93     xs_html *dl;
 94@@ -735,9 +744,11 @@ xs_html *html_user_head(snac *user, const char *desc, const char *url)
 95         xs *fwers = follower_list(user);
 96         xs *fwing = following_list(user);
 97 
 98-        xs *s1 = xs_fmt(L("%d following, %d followers · "),
 99+        xs *s1 = xs_fmt(L("%d following, %d followers"),
100             xs_list_len(fwing), xs_list_len(fwers));
101 
102+        s1 = xs_str_cat(s1, " · ");
103+
104         s_desc = xs_str_prepend_i(s_desc, s1);
105     }
106 
107@@ -1052,8 +1063,8 @@ static xs_html *html_user_body(snac *user, int read_only)
108         const char *longitude = xs_dict_get_def(user->config, "longitude", "");
109 
110         if (*latitude && *longitude) {
111-            xs *label = xs_fmt(L("%s,%s"), latitude, longitude);
112-            xs *url   = xs_fmt(L("https://openstreetmap.org/search?query=%s,%s"),
113+            xs *label = xs_fmt("%s,%s", latitude, longitude);
114+            xs *url   = xs_fmt("https://openstreetmap.org/search?query=%s,%s",
115                         latitude, longitude);
116 
117             xs_html_add(top_user,
118@@ -1069,7 +1080,7 @@ static xs_html *html_user_body(snac *user, int read_only)
119             xs *fwers = follower_list(user);
120             xs *fwing = following_list(user);
121 
122-            xs *s1 = xs_fmt(L("%d following %d followers"),
123+            xs *s1 = xs_fmt(L("%d following, %d followers"),
124                 xs_list_len(fwing), xs_list_len(fwers));
125 
126             xs_html_add(top_user,
127@@ -1085,16 +1096,16 @@ static xs_html *html_user_body(snac *user, int read_only)
128 }
129 
130 
131-xs_html *html_top_controls(snac *snac)
132+xs_html *html_top_controls(snac *user)
133 /* generates the top controls */
134 {
135-    xs *ops_action = xs_fmt("%s/admin/action", snac->actor);
136+    xs *ops_action = xs_fmt("%s/admin/action", user->actor);
137 
138     xs_html *top_controls = xs_html_tag("div",
139         xs_html_attr("class", "snac-top-controls"),
140 
141         /** new post **/
142-        html_note(snac, L("New Post..."),
143+        html_note(user, L("New Post..."),
144             "new_post_div", "new_post_form",
145             L("What's on your mind?"), "",
146             NULL, NULL,
147@@ -1164,53 +1175,53 @@ xs_html *html_top_controls(snac *snac)
148     const char *email = "[disabled by admin]";
149 
150     if (xs_type(xs_dict_get(srv_config, "disable_email_notifications")) != XSTYPE_TRUE) {
151-        email = xs_dict_get(snac->config_o, "email");
152+        email = xs_dict_get(user->config_o, "email");
153         if (xs_is_null(email)) {
154-            email = xs_dict_get(snac->config, "email");
155+            email = xs_dict_get(user->config, "email");
156 
157             if (xs_is_null(email))
158                 email = "";
159         }
160     }
161 
162-    const char *cw = xs_dict_get(snac->config, "cw");
163+    const char *cw = xs_dict_get(user->config, "cw");
164     if (xs_is_null(cw))
165         cw = "";
166 
167-    const char *telegram_bot = xs_dict_get(snac->config, "telegram_bot");
168+    const char *telegram_bot = xs_dict_get(user->config, "telegram_bot");
169     if (xs_is_null(telegram_bot))
170         telegram_bot = "";
171 
172-    const char *telegram_chat_id = xs_dict_get(snac->config, "telegram_chat_id");
173+    const char *telegram_chat_id = xs_dict_get(user->config, "telegram_chat_id");
174     if (xs_is_null(telegram_chat_id))
175         telegram_chat_id = "";
176 
177-    const char *ntfy_server = xs_dict_get(snac->config, "ntfy_server");
178+    const char *ntfy_server = xs_dict_get(user->config, "ntfy_server");
179     if (xs_is_null(ntfy_server))
180         ntfy_server = "";
181 
182-    const char *ntfy_token = xs_dict_get(snac->config, "ntfy_token");
183+    const char *ntfy_token = xs_dict_get(user->config, "ntfy_token");
184     if (xs_is_null(ntfy_token))
185         ntfy_token = "";
186 
187-    const char *purge_days = xs_dict_get(snac->config, "purge_days");
188+    const char *purge_days = xs_dict_get(user->config, "purge_days");
189     if (!xs_is_null(purge_days) && xs_type(purge_days) == XSTYPE_NUMBER)
190         purge_days = (char *)xs_number_str(purge_days);
191     else
192         purge_days = "0";
193 
194-    const xs_val *d_dm_f_u  = xs_dict_get(snac->config, "drop_dm_from_unknown");
195-    const xs_val *bot       = xs_dict_get(snac->config, "bot");
196-    const xs_val *a_private = xs_dict_get(snac->config, "private");
197-    const xs_val *auto_boost = xs_dict_get(snac->config, "auto_boost");
198-    const xs_val *coll_thrds = xs_dict_get(snac->config, "collapse_threads");
199-    const xs_val *pending    = xs_dict_get(snac->config, "approve_followers");
200-    const xs_val *show_foll  = xs_dict_get(snac->config, "show_contact_metrics");
201-    const char *latitude     = xs_dict_get_def(snac->config, "latitude", "");
202-    const char *longitude    = xs_dict_get_def(snac->config, "longitude", "");
203+    const xs_val *d_dm_f_u  = xs_dict_get(user->config, "drop_dm_from_unknown");
204+    const xs_val *bot       = xs_dict_get(user->config, "bot");
205+    const xs_val *a_private = xs_dict_get(user->config, "private");
206+    const xs_val *auto_boost = xs_dict_get(user->config, "auto_boost");
207+    const xs_val *coll_thrds = xs_dict_get(user->config, "collapse_threads");
208+    const xs_val *pending    = xs_dict_get(user->config, "approve_followers");
209+    const xs_val *show_foll  = xs_dict_get(user->config, "show_contact_metrics");
210+    const char *latitude     = xs_dict_get_def(user->config, "latitude", "");
211+    const char *longitude    = xs_dict_get_def(user->config, "longitude", "");
212 
213     xs *metadata = NULL;
214-    const xs_dict *md = xs_dict_get(snac->config, "metadata");
215+    const xs_dict *md = xs_dict_get(user->config, "metadata");
216 
217     if (xs_type(md) == XSTYPE_DICT) {
218         const xs_str *k;
219@@ -1232,7 +1243,29 @@ xs_html *html_top_controls(snac *snac)
220     else
221         metadata = xs_str_new(NULL);
222 
223-    xs *user_setup_action = xs_fmt("%s/admin/user-setup", snac->actor);
224+    /* ui language */
225+    xs_html *lang_select = xs_html_tag("select",
226+        xs_html_attr("name", "web_ui_lang"));
227+
228+    const char *u_lang = xs_dict_get_def(user->config, "lang", "en");
229+    const char *lang;
230+    const xs_dict *langs;
231+
232+    xs_dict_foreach(srv_langs, lang, langs) {
233+        if (strcmp(u_lang, lang) == 0)
234+            xs_html_add(lang_select,
235+                xs_html_tag("option",
236+                    xs_html_text(lang),
237+                    xs_html_attr("value", lang),
238+                    xs_html_attr("selected", "selected")));
239+        else
240+            xs_html_add(lang_select,
241+                xs_html_tag("option",
242+                    xs_html_text(lang),
243+                    xs_html_attr("value", lang)));
244+    }
245+
246+    xs *user_setup_action = xs_fmt("%s/admin/user-setup", user->actor);
247 
248     xs_html_add(top_controls,
249         xs_html_tag("details",
250@@ -1251,7 +1284,7 @@ xs_html *html_top_controls(snac *snac)
251                     xs_html_sctag("input",
252                         xs_html_attr("type", "text"),
253                         xs_html_attr("name", "name"),
254-                        xs_html_attr("value", xs_dict_get(snac->config, "name")),
255+                        xs_html_attr("value", xs_dict_get(user->config, "name")),
256                         xs_html_attr("placeholder", L("Your name")))),
257                 xs_html_tag("p",
258                     xs_html_text(L("Avatar: ")),
259@@ -1281,7 +1314,7 @@ xs_html *html_top_controls(snac *snac)
260                         xs_html_attr("cols", "40"),
261                         xs_html_attr("rows", "4"),
262                         xs_html_attr("placeholder", L("Write about yourself here...")),
263-                        xs_html_text(xs_dict_get(snac->config, "bio")))),
264+                        xs_html_text(xs_dict_get(user->config, "bio")))),
265                 xs_html_sctag("input",
266                     xs_html_attr("type", "checkbox"),
267                     xs_html_attr("name", "cw"),
268@@ -1422,6 +1455,11 @@ xs_html *html_top_controls(snac *snac)
269                             "/example.com/my-blog\nGPG Key=1FA54\n..."),
270                     xs_html_text(metadata))),
271 
272+                xs_html_tag("p",
273+                    xs_html_text(L("Web interface language:")),
274+                    xs_html_sctag("br", NULL),
275+                    lang_select),
276+
277                 xs_html_tag("p",
278                     xs_html_text(L("New password:")),
279                     xs_html_sctag("br", NULL),
280@@ -1444,8 +1482,8 @@ xs_html *html_top_controls(snac *snac)
281 
282                 xs_html_tag("p", NULL)))));
283 
284-    xs *followed_hashtags_action = xs_fmt("%s/admin/followed-hashtags", snac->actor);
285-    xs *followed_hashtags = xs_join(xs_dict_get_def(snac->config,
286+    xs *followed_hashtags_action = xs_fmt("%s/admin/followed-hashtags", user->actor);
287+    xs *followed_hashtags = xs_join(xs_dict_get_def(user->config,
288                         "followed_hashtags", xs_stock(XSTYPE_LIST)), "\n");
289 
290     xs_html_add(top_controls,
291@@ -1480,7 +1518,7 @@ xs_html *html_top_controls(snac *snac)
292 }
293 
294 
295-static xs_html *html_button(char *clss, char *label, char *hint)
296+static xs_html *html_button(const char *clss, const char *label, const char *hint)
297 {
298     xs *c = xs_fmt("snac-btn-%s", clss);
299 
300@@ -1496,7 +1534,7 @@ static xs_html *html_button(char *clss, char *label, char *hint)
301 }
302 
303 
304-xs_str *build_mentions(snac *snac, const xs_dict *msg)
305+xs_str *build_mentions(snac *user, const xs_dict *msg)
306 /* returns a string with the mentions in msg */
307 {
308     xs_str *s = xs_str_new(NULL);
309@@ -1510,7 +1548,7 @@ xs_str *build_mentions(snac *snac, const xs_dict *msg)
310         const char *name = xs_dict_get(v, "name");
311 
312         if (type && strcmp(type, "Mention") == 0 &&
313-            href && strcmp(href, snac->actor) != 0 && name) {
314+            href && strcmp(href, user->actor) != 0 && name) {
315             xs *s1 = NULL;
316 
317             if (name[0] != '@') {
318@@ -1551,7 +1589,7 @@ xs_str *build_mentions(snac *snac, const xs_dict *msg)
319 }
320 
321 
322-xs_html *html_entry_controls(snac *snac, const char *actor,
323+xs_html *html_entry_controls(snac *user, const char *actor,
324                             const xs_dict *msg, const char *md5)
325 {
326     const char *id    = xs_dict_get(msg, "id");
327@@ -1560,7 +1598,7 @@ xs_html *html_entry_controls(snac *snac, const char *actor,
328     xs *likes   = object_likes(id);
329     xs *boosts  = object_announces(id);
330 
331-    xs *action = xs_fmt("%s/admin/action", snac->actor);
332+    xs *action = xs_fmt("%s/admin/action", user->actor);
333     xs *redir  = xs_fmt("%s_entry", md5);
334 
335     xs_html *form;
336@@ -1587,8 +1625,8 @@ xs_html *html_entry_controls(snac *snac, const char *actor,
337                 xs_html_attr("name",     "redir"),
338                 xs_html_attr("value",    redir))));
339 
340-    if (!xs_startswith(id, snac->actor)) {
341-        if (xs_list_in(likes, snac->md5) == -1) {
342+    if (!xs_startswith(id, user->actor)) {
343+        if (xs_list_in(likes, user->md5) == -1) {
344             /* not already liked; add button */
345             xs_html_add(form,
346                 html_button("like", L("Like"), L("Say you like this post")));
347@@ -1600,7 +1638,7 @@ xs_html *html_entry_controls(snac *snac, const char *actor,
348         }
349     }
350     else {
351-        if (is_pinned(snac, id))
352+        if (is_pinned(user, id))
353             xs_html_add(form,
354                 html_button("unpin", L("Unpin"), L("Unpin this post from your timeline")));
355         else
356@@ -1609,7 +1647,7 @@ xs_html *html_entry_controls(snac *snac, const char *actor,
357     }
358 
359     if (is_msg_public(msg)) {
360-        if (xs_list_in(boosts, snac->md5) == -1) {
361+        if (xs_list_in(boosts, user->md5) == -1) {
362             /* not already boosted; add button */
363             xs_html_add(form,
364                 html_button("boost", L("Boost"), L("Announce this post to your followers")));
365@@ -1621,16 +1659,16 @@ xs_html *html_entry_controls(snac *snac, const char *actor,
366         }
367     }
368 
369-    if (is_bookmarked(snac, id))
370+    if (is_bookmarked(user, id))
371             xs_html_add(form,
372                 html_button("unbookmark", L("Unbookmark"), L("Delete this post from your bookmarks")));
373         else
374             xs_html_add(form,
375                 html_button("bookmark", L("Bookmark"), L("Add this post to your bookmarks")));
376 
377-    if (strcmp(actor, snac->actor) != 0) {
378+    if (strcmp(actor, user->actor) != 0) {
379         /* controls for other actors than this one */
380-        if (following_check(snac, actor)) {
381+        if (following_check(user, actor)) {
382             xs_html_add(form,
383                 html_button("unfollow", L("Unfollow"), L("Stop following this user's activity")));
384         }
385@@ -1640,7 +1678,7 @@ xs_html *html_entry_controls(snac *snac, const char *actor,
386         }
387 
388         if (!xs_is_null(group)) {
389-            if (following_check(snac, group)) {
390+            if (following_check(user, group)) {
391                 xs_html_add(form,
392                     html_button("unfollow", L("Unfollow Group"),
393                         L("Stop following this group or channel")));
394@@ -1666,7 +1704,7 @@ xs_html *html_entry_controls(snac *snac, const char *actor,
395 
396     const char *prev_src = xs_dict_get(msg, "sourceContent");
397 
398-    if (!xs_is_null(prev_src) && strcmp(actor, snac->actor) == 0) { /** edit **/
399+    if (!xs_is_null(prev_src) && strcmp(actor, user->actor) == 0) { /** edit **/
400         /* post can be edited */
401         xs *div_id  = xs_fmt("%s_edit", md5);
402         xs *form_id = xs_fmt("%s_edit_form", md5);
403@@ -1693,26 +1731,26 @@ xs_html *html_entry_controls(snac *snac, const char *actor,
404 
405         xs_html_add(controls, xs_html_tag("div",
406             xs_html_tag("p", NULL),
407-            html_note(snac, L("Edit..."),
408+            html_note(user, L("Edit..."),
409                 div_id, form_id,
410                 "", prev_src,
411                 id, NULL,
412                 xs_dict_get(msg, "sensitive"), xs_dict_get(msg, "summary"),
413                 xs_stock(is_msg_public(msg) ? XSTYPE_FALSE : XSTYPE_TRUE), redir,
414-                NULL, 0, att_files, att_alt_texts, is_draft(snac, id))),
415+                NULL, 0, att_files, att_alt_texts, is_draft(user, id))),
416             xs_html_tag("p", NULL));
417     }
418 
419     { /** reply **/
420         /* the post textarea */
421-        xs *ct      = build_mentions(snac, msg);
422+        xs *ct      = build_mentions(user, msg);
423         xs *div_id  = xs_fmt("%s_reply", md5);
424         xs *form_id = xs_fmt("%s_reply_form", md5);
425         xs *redir   = xs_fmt("%s_entry", md5);
426 
427         xs_html_add(controls, xs_html_tag("div",
428             xs_html_tag("p", NULL),
429-            html_note(snac, L("Reply..."),
430+            html_note(user, L("Reply..."),
431                 div_id, form_id,
432                 "", ct,
433                 NULL, NULL,
434@@ -1839,7 +1877,7 @@ xs_html *html_entry(snac *user, xs_dict *msg, int read_only,
435                 xs_html_raw(" &#128204; ")));
436     }
437 
438-    if (user && is_bookmarked(user, id)) {
439+    if (user && !read_only && is_bookmarked(user, id)) {
440         /* add a bookmark emoji */
441         xs_html_add(score,
442             xs_html_tag("span",
443@@ -2242,6 +2280,11 @@ xs_html *html_entry(snac *user, xs_dict *msg, int read_only,
444             if (content && xs_str_in(content, o_href) != -1)
445                 continue;
446 
447+            /* drop silently any attachment that may include JavaScript */
448+            if (strcmp(type, "image/svg+xml") == 0 ||
449+                strcmp(type, "text/html") == 0)
450+                continue;
451+
452             /* do this attachment include an icon? */
453             const xs_dict *icon = xs_dict_get(a, "icon");
454             if (xs_type(icon) == XSTYPE_DICT) {
455@@ -2571,7 +2614,7 @@ xs_html *html_entry(snac *user, xs_dict *msg, int read_only,
456 }
457 
458 
459-xs_html *html_footer(void)
460+xs_html *html_footer(const snac *user)
461 {
462     return xs_html_tag("div",
463         xs_html_attr("class", "snac-footer"),
464@@ -2879,13 +2922,13 @@ xs_str *html_timeline(snac *user, const xs_list *list, int read_only,
465     }
466 
467     xs_html_add(body,
468-        html_footer());
469+        html_footer(user));
470 
471     return xs_html_render_s(html, "<!DOCTYPE html>\n");
472 }
473 
474 
475-xs_html *html_people_list(snac *snac, xs_list *list, char *header, char *t, const char *proxy)
476+xs_html *html_people_list(snac *user, xs_list *list, const char *header, const char *t, const char *proxy)
477 {
478     xs_html *snac_posts;
479     xs_html *people = xs_html_tag("div",
480@@ -2910,7 +2953,7 @@ xs_html *html_people_list(snac *snac, xs_list *list, char *header, char *t, cons
481                     xs_html_attr("name", md5)),
482                 xs_html_tag("div",
483                     xs_html_attr("class", "snac-post-header"),
484-                    html_actor_icon(snac, actor, xs_dict_get(actor, "published"),
485+                    html_actor_icon(user, actor, xs_dict_get(actor, "published"),
486                                     NULL, NULL, 0, 1, proxy, NULL, NULL)));
487 
488             /* content (user bio) */
489@@ -2934,7 +2977,7 @@ xs_html *html_people_list(snac *snac, xs_list *list, char *header, char *t, cons
490             }
491 
492             /* buttons */
493-            xs *btn_form_action = xs_fmt("%s/admin/action", snac->actor);
494+            xs *btn_form_action = xs_fmt("%s/admin/action", user->actor);
495 
496             xs_html *snac_controls = xs_html_tag("div",
497                 xs_html_attr("class",   "snac-controls"));
498@@ -2954,12 +2997,12 @@ xs_html *html_people_list(snac *snac, xs_list *list, char *header, char *t, cons
499 
500             xs_html_add(snac_controls, form);
501 
502-            if (following_check(snac, actor_id)) {
503+            if (following_check(user, actor_id)) {
504                 xs_html_add(form,
505                     html_button("unfollow", L("Unfollow"),
506                                 L("Stop following this user's activity")));
507 
508-                if (is_limited(snac, actor_id))
509+                if (is_limited(user, actor_id))
510                     xs_html_add(form,
511                         html_button("unlimit", L("Unlimit"),
512                                 L("Allow announces (boosts) from this user")));
513@@ -2973,12 +3016,12 @@ xs_html *html_people_list(snac *snac, xs_list *list, char *header, char *t, cons
514                     html_button("follow", L("Follow"),
515                                 L("Start following this user's activity")));
516 
517-                if (follower_check(snac, actor_id))
518+                if (follower_check(user, actor_id))
519                     xs_html_add(form,
520                         html_button("delete", L("Delete"), L("Delete this user")));
521             }
522 
523-            if (pending_check(snac, actor_id)) {
524+            if (pending_check(user, actor_id)) {
525                 xs_html_add(form,
526                     html_button("approve", L("Approve"),
527                                 L("Approve this follow request")));
528@@ -2987,7 +3030,7 @@ xs_html *html_people_list(snac *snac, xs_list *list, char *header, char *t, cons
529                     html_button("discard", L("Discard"), L("Discard this follow request")));
530             }
531 
532-            if (is_muted(snac, actor_id))
533+            if (is_muted(user, actor_id))
534                 xs_html_add(form,
535                     html_button("unmute", L("Unmute"),
536                                 L("Stop blocking activities from this user")));
537@@ -3002,7 +3045,7 @@ xs_html *html_people_list(snac *snac, xs_list *list, char *header, char *t, cons
538 
539             xs_html_add(snac_controls,
540                 xs_html_tag("p", NULL),
541-                html_note(snac, L("Direct Message..."),
542+                html_note(user, L("Direct Message..."),
543                     dm_div_id, dm_form_id,
544                     "", "",
545                     NULL, actor_id,
546@@ -3048,7 +3091,7 @@ xs_str *html_people(snac *user)
547         html_user_head(user, NULL, NULL),
548         xs_html_add(html_user_body(user, 0),
549             lists,
550-            html_footer()));
551+            html_footer(user)));
552 
553     return xs_html_render_s(html, "<!DOCTYPE html>\n");
554 }
555@@ -3298,7 +3341,7 @@ xs_str *html_notifications(snac *user, int skip, int show)
556     xs_set_free(&rep);
557 
558     xs_html_add(body,
559-        html_footer());
560+        html_footer(user));
561 
562     /* set the check time to now */
563     xs *dummy = notify_check_time(user, 1);
564@@ -3310,12 +3353,24 @@ xs_str *html_notifications(snac *user, int skip, int show)
565 }
566 
567 
568+void set_user_lang(snac *user)
569+/* sets the language dict according to user configuration */
570+{
571+    user->lang = NULL;
572+    const char *lang = xs_dict_get(user->config, "lang");
573+
574+    if (xs_is_string(lang))
575+        user->lang = xs_dict_get(srv_langs, lang);
576+}
577+
578+
579 int html_get_handler(const xs_dict *req, const char *q_path,
580                      char **body, int *b_size, char **ctype,
581                      xs_str **etag, xs_str **last_modified)
582 {
583     const char *accept = xs_dict_get(req, "accept");
584     int status = HTTP_STATUS_NOT_FOUND;
585+    const snac *user = NULL;
586     snac snac;
587     xs *uid = NULL;
588     const char *p_path;
589@@ -3382,6 +3437,9 @@ int html_get_handler(const xs_dict *req, const char *q_path,
590         return HTTP_STATUS_NOT_FOUND;
591     }
592 
593+    user = &snac; /* for L() */
594+    set_user_lang(&snac);
595+
596     if (xs_is_true(xs_dict_get(srv_config, "proxy_media")))
597         proxy = 1;
598 
599@@ -3550,7 +3608,7 @@ int html_get_handler(const xs_dict *req, const char *q_path,
600                         html_user_head(&snac, NULL, NULL),
601                         xs_html_add(html_user_body(&snac, 0),
602                         page,
603-                        html_footer()));
604+                        html_footer(user)));
605 
606                     *body = xs_html_render_s(html, "<!DOCTYPE html>\n");
607                     *b_size = strlen(*body);
608@@ -4005,6 +4063,7 @@ int html_post_handler(const xs_dict *req, const char *q_path,
609     (void)ctype;
610 
611     int status = 0;
612+    const snac *user = NULL;
613     snac snac;
614     const char *uid;
615     const char *p_path;
616@@ -4028,6 +4087,9 @@ int html_post_handler(const xs_dict *req, const char *q_path,
617         return HTTP_STATUS_UNAUTHORIZED;
618     }
619 
620+    user = &snac; /* for L() */
621+    set_user_lang(&snac);
622+
623     p_vars = xs_dict_get(req, "p_vars");
624 
625     if (p_path && strcmp(p_path, "admin/note") == 0) { /** **/
626@@ -4470,6 +4532,8 @@ int html_post_handler(const xs_dict *req, const char *q_path,
627             snac.config = xs_dict_set(snac.config, "show_contact_metrics", xs_stock(XSTYPE_TRUE));
628         else
629             snac.config = xs_dict_set(snac.config, "show_contact_metrics", xs_stock(XSTYPE_FALSE));
630+        if ((v = xs_dict_get(p_vars, "web_ui_lang")) != NULL)
631+            snac.config = xs_dict_set(snac.config, "lang", v);
632 
633         snac.config = xs_dict_set(snac.config, "latitude", xs_dict_get_def(p_vars, "latitude", ""));
634         snac.config = xs_dict_set(snac.config, "longitude", xs_dict_get_def(p_vars, "longitude", ""));
+5, -0
 1@@ -211,6 +211,8 @@ int server_get_handler(xs_dict *req, const char *q_path,
 2 {
 3     int status = 0;
 4 
 5+    const snac *user = NULL;
 6+
 7     /* is it the server root? */
 8     if (*q_path == '\0' || strcmp(q_path, "/") == 0) {
 9         const xs_dict *q_vars = xs_dict_get(req, "q_vars");
10@@ -553,6 +555,9 @@ void httpd_connection(FILE *f)
11     headers = xs_dict_append(headers, "access-control-allow-origin", "*");
12     headers = xs_dict_append(headers, "access-control-allow-headers", "*");
13 
14+    /* disable any form of fucking JavaScript */
15+    headers = xs_dict_append(headers, "Content-Security-Policy", "script-src ;");
16+
17     if (p_state->use_fcgi)
18         xs_fcgi_response(f, status, headers, body, b_size, fcgi_id);
19     else
+687, -0
  1@@ -0,0 +1,687 @@
  2+# snac message translation file
  3+#
  4+#, fuzzy
  5+msgid ""
  6+msgstr ""
  7+"Project-Id-Version: snac\n"
  8+"Last-Translator: grunfink\n"
  9+"Language: en\n"
 10+"Content-Type: text/plain; charset=UTF-8\n"
 11+
 12+#: html.c:367
 13+msgid "Sensitive content: "
 14+msgstr ""
 15+
 16+#: html.c:375
 17+msgid "Sensitive content description"
 18+msgstr ""
 19+
 20+#: html.c:388
 21+msgid "Only for mentioned people: "
 22+msgstr ""
 23+
 24+#: html.c:411
 25+msgid "Reply to (URL): "
 26+msgstr ""
 27+
 28+#: html.c:420
 29+msgid "Don't send, but store as a draft"
 30+msgstr ""
 31+
 32+#: html.c:421
 33+msgid "Draft:"
 34+msgstr ""
 35+
 36+#: html.c:441
 37+msgid "Attachments..."
 38+msgstr ""
 39+
 40+#: html.c:464
 41+msgid "File:"
 42+msgstr ""
 43+
 44+#: html.c:468
 45+msgid "Clear this field to delete the attachment"
 46+msgstr ""
 47+
 48+#: html.c:477 html.c:502
 49+msgid "Attachment description"
 50+msgstr ""
 51+
 52+#: html.c:513
 53+msgid "Poll..."
 54+msgstr ""
 55+
 56+#: html.c:515
 57+msgid "Poll options (one per line, up to 8):"
 58+msgstr ""
 59+
 60+#: html.c:527
 61+msgid "One choice"
 62+msgstr ""
 63+
 64+#: html.c:530
 65+msgid "Multiple choices"
 66+msgstr ""
 67+
 68+#: html.c:536
 69+msgid "End in 5 minutes"
 70+msgstr ""
 71+
 72+#: html.c:540
 73+msgid "End in 1 hour"
 74+msgstr ""
 75+
 76+#: html.c:543
 77+msgid "End in 1 day"
 78+msgstr ""
 79+
 80+#: html.c:551
 81+msgid "Post"
 82+msgstr ""
 83+
 84+#: html.c:648 html.c:655
 85+msgid "Site description"
 86+msgstr ""
 87+
 88+#: html.c:666
 89+msgid "Admin email"
 90+msgstr ""
 91+
 92+#: html.c:679
 93+msgid "Admin account"
 94+msgstr ""
 95+
 96+#: html.c:747 html.c:1083
 97+#, c-format
 98+msgid "%d following, %d followers"
 99+msgstr ""
100+
101+#: html.c:837
102+msgid "RSS"
103+msgstr ""
104+
105+#: html.c:842 html.c:870
106+msgid "private"
107+msgstr ""
108+
109+#: html.c:866
110+msgid "public"
111+msgstr ""
112+
113+#: html.c:874
114+msgid "notifications"
115+msgstr ""
116+
117+#: html.c:879
118+msgid "people"
119+msgstr ""
120+
121+#: html.c:883
122+msgid "instance"
123+msgstr ""
124+
125+#: html.c:892
126+msgid ""
127+"Search posts by URL or content (regular expression), @user@host accounts, or "
128+"#tag"
129+msgstr ""
130+
131+#: html.c:893
132+msgid "Content search"
133+msgstr ""
134+
135+#: html.c:1015
136+msgid "verified link"
137+msgstr ""
138+
139+#: html.c:1072 html.c:2420 html.c:2433 html.c:2442
140+msgid "Location: "
141+msgstr ""
142+
143+#: html.c:1108
144+msgid "New Post..."
145+msgstr ""
146+
147+#: html.c:1110
148+msgid "What's on your mind?"
149+msgstr ""
150+
151+#: html.c:1119
152+msgid "Operations..."
153+msgstr ""
154+
155+#: html.c:1134 html.c:1677 html.c:3016 html.c:4333
156+msgid "Follow"
157+msgstr ""
158+
159+#: html.c:1136
160+msgid "(by URL or user@host)"
161+msgstr ""
162+
163+#: html.c:1151 html.c:1653 html.c:4285
164+msgid "Boost"
165+msgstr ""
166+
167+#: html.c:1153 html.c:1170
168+msgid "(by URL)"
169+msgstr ""
170+
171+#: html.c:1168 html.c:1632 html.c:4276
172+msgid "Like"
173+msgstr ""
174+
175+#: html.c:1273
176+msgid "User Settings..."
177+msgstr ""
178+
179+#: html.c:1282
180+msgid "Display name:"
181+msgstr ""
182+
183+#: html.c:1288
184+msgid "Your name"
185+msgstr ""
186+
187+#: html.c:1290
188+msgid "Avatar: "
189+msgstr ""
190+
191+#: html.c:1298
192+msgid "Delete current avatar"
193+msgstr ""
194+
195+#: html.c:1300
196+msgid "Header image (banner): "
197+msgstr ""
198+
199+#: html.c:1308
200+msgid "Delete current header image"
201+msgstr ""
202+
203+#: html.c:1310
204+msgid "Bio:"
205+msgstr ""
206+
207+#: html.c:1316
208+msgid "Write about yourself here..."
209+msgstr ""
210+
211+#: html.c:1325
212+msgid "Always show sensitive content"
213+msgstr ""
214+
215+#: html.c:1327
216+msgid "Email address for notifications:"
217+msgstr ""
218+
219+#: html.c:1335
220+msgid "Telegram notifications (bot key and chat id):"
221+msgstr ""
222+
223+#: html.c:1349
224+msgid "ntfy notifications (ntfy server and token):"
225+msgstr ""
226+
227+#: html.c:1363
228+msgid "Maximum days to keep posts (0: server settings):"
229+msgstr ""
230+
231+#: html.c:1377
232+msgid "Drop direct messages from people you don't follow"
233+msgstr ""
234+
235+#: html.c:1386
236+msgid "This account is a bot"
237+msgstr ""
238+
239+#: html.c:1395
240+msgid "Auto-boost all mentions to this account"
241+msgstr ""
242+
243+#: html.c:1404
244+msgid "This account is private (posts are not shown through the web)"
245+msgstr ""
246+
247+#: html.c:1414
248+msgid "Collapse top threads by default"
249+msgstr ""
250+
251+#: html.c:1423
252+msgid "Follow requests must be approved"
253+msgstr ""
254+
255+#: html.c:1432
256+msgid "Publish follower and following metrics"
257+msgstr ""
258+
259+#: html.c:1434
260+msgid "Current location:"
261+msgstr ""
262+
263+#: html.c:1448
264+msgid "Profile metadata (key=value pairs in each line):"
265+msgstr ""
266+
267+#: html.c:1459
268+msgid "Web interface language:"
269+msgstr ""
270+
271+#: html.c:1464
272+msgid "New password:"
273+msgstr ""
274+
275+#: html.c:1471
276+msgid "Repeat new password:"
277+msgstr ""
278+
279+#: html.c:1481
280+msgid "Update user info"
281+msgstr ""
282+
283+#: html.c:1492
284+msgid "Followed hashtags..."
285+msgstr ""
286+
287+#: html.c:1494
288+msgid "One hashtag per line"
289+msgstr ""
290+
291+#: html.c:1515
292+msgid "Update hashtags"
293+msgstr ""
294+
295+#: html.c:1632
296+msgid "Say you like this post"
297+msgstr ""
298+
299+#: html.c:1637 html.c:4294
300+msgid "Unlike"
301+msgstr ""
302+
303+#: html.c:1637
304+msgid "Nah don't like it that much"
305+msgstr ""
306+
307+#: html.c:1643 html.c:4426
308+msgid "Unpin"
309+msgstr ""
310+
311+#: html.c:1643
312+msgid "Unpin this post from your timeline"
313+msgstr ""
314+
315+#: html.c:1646 html.c:4421
316+msgid "Pin"
317+msgstr ""
318+
319+#: html.c:1646
320+msgid "Pin this post to the top of your timeline"
321+msgstr ""
322+
323+#: html.c:1653
324+msgid "Announce this post to your followers"
325+msgstr ""
326+
327+#: html.c:1658 html.c:4302
328+msgid "Unboost"
329+msgstr ""
330+
331+#: html.c:1658
332+msgid "I regret I boosted this"
333+msgstr ""
334+
335+#: html.c:1664 html.c:4436
336+msgid "Unbookmark"
337+msgstr ""
338+
339+#: html.c:1664
340+msgid "Delete this post from your bookmarks"
341+msgstr ""
342+
343+#: html.c:1667 html.c:4431
344+msgid "Bookmark"
345+msgstr ""
346+
347+#: html.c:1667
348+msgid "Add this post to your bookmarks"
349+msgstr ""
350+
351+#: html.c:1673 html.c:3002 html.c:3190 html.c:4346
352+msgid "Unfollow"
353+msgstr ""
354+
355+#: html.c:1673 html.c:3003
356+msgid "Stop following this user's activity"
357+msgstr ""
358+
359+#: html.c:1677 html.c:3017
360+msgid "Start following this user's activity"
361+msgstr ""
362+
363+#: html.c:1683 html.c:4376
364+msgid "Unfollow Group"
365+msgstr ""
366+
367+#: html.c:1684
368+msgid "Stop following this group or channel"
369+msgstr ""
370+
371+#: html.c:1688 html.c:4363
372+msgid "Follow Group"
373+msgstr ""
374+
375+#: html.c:1689
376+msgid "Start following this group or channel"
377+msgstr ""
378+
379+#: html.c:1694 html.c:3039 html.c:4310
380+msgid "MUTE"
381+msgstr ""
382+
383+#: html.c:1695
384+msgid "Block any activity from this user forever"
385+msgstr ""
386+
387+#: html.c:1700 html.c:3021 html.c:4393
388+msgid "Delete"
389+msgstr ""
390+
391+#: html.c:1700
392+msgid "Delete this post"
393+msgstr ""
394+
395+#: html.c:1703 html.c:4318
396+msgid "Hide"
397+msgstr ""
398+
399+#: html.c:1703
400+msgid "Hide this post and its children"
401+msgstr ""
402+
403+#: html.c:1734
404+msgid "Edit..."
405+msgstr ""
406+
407+#: html.c:1753
408+msgid "Reply..."
409+msgstr ""
410+
411+#: html.c:1804
412+msgid "Truncated (too deep)"
413+msgstr ""
414+
415+#: html.c:1813
416+msgid "follows you"
417+msgstr ""
418+
419+#: html.c:1876
420+msgid "Pinned"
421+msgstr ""
422+
423+#: html.c:1884
424+msgid "Bookmarked"
425+msgstr ""
426+
427+#: html.c:1892
428+msgid "Poll"
429+msgstr ""
430+
431+#: html.c:1899
432+msgid "Voted"
433+msgstr ""
434+
435+#: html.c:1908
436+msgid "Event"
437+msgstr ""
438+
439+#: html.c:1940 html.c:1969
440+msgid "boosted"
441+msgstr ""
442+
443+#: html.c:1985
444+msgid "in reply to"
445+msgstr ""
446+
447+#: html.c:2036
448+msgid " [SENSITIVE CONTENT]"
449+msgstr ""
450+
451+#: html.c:2213
452+msgid "Vote"
453+msgstr ""
454+
455+#: html.c:2223
456+msgid "Closed"
457+msgstr ""
458+
459+#: html.c:2248
460+msgid "Closes in"
461+msgstr ""
462+
463+#: html.c:2327
464+msgid "Video"
465+msgstr ""
466+
467+#: html.c:2342
468+msgid "Audio"
469+msgstr ""
470+
471+#: html.c:2364
472+msgid "Attachment"
473+msgstr ""
474+
475+#: html.c:2378
476+msgid "Alt..."
477+msgstr ""
478+
479+#: html.c:2391
480+msgid "Source channel or community"
481+msgstr ""
482+
483+#: html.c:2485
484+msgid "Time: "
485+msgstr ""
486+
487+#: html.c:2560
488+msgid "Older..."
489+msgstr ""
490+
491+#: html.c:2623
492+msgid "about this site"
493+msgstr ""
494+
495+#: html.c:2625
496+msgid "powered by "
497+msgstr ""
498+
499+#: html.c:2690
500+msgid "Dismiss"
501+msgstr ""
502+
503+#: html.c:2707
504+#, c-format
505+msgid "Timeline for list '%s'"
506+msgstr ""
507+
508+#: html.c:2726 html.c:3767
509+msgid "Pinned posts"
510+msgstr ""
511+
512+#: html.c:2738 html.c:3782
513+msgid "Bookmarked posts"
514+msgstr ""
515+
516+#: html.c:2750 html.c:3797
517+msgid "Post drafts"
518+msgstr ""
519+
520+#: html.c:2809
521+msgid "No more unseen posts"
522+msgstr ""
523+
524+#: html.c:2813 html.c:2913
525+msgid "Back to top"
526+msgstr ""
527+
528+#: html.c:2866
529+msgid "History"
530+msgstr ""
531+
532+#: html.c:2918 html.c:3338
533+msgid "More..."
534+msgstr ""
535+
536+#: html.c:3007 html.c:4329
537+msgid "Unlimit"
538+msgstr ""
539+
540+#: html.c:3008
541+msgid "Allow announces (boosts) from this user"
542+msgstr ""
543+
544+#: html.c:3011 html.c:4325
545+msgid "Limit"
546+msgstr ""
547+
548+#: html.c:3012
549+msgid "Block announces (boosts) from this user"
550+msgstr ""
551+
552+#: html.c:3021
553+msgid "Delete this user"
554+msgstr ""
555+
556+#: html.c:3026 html.c:4441
557+msgid "Approve"
558+msgstr ""
559+
560+#: html.c:3027
561+msgid "Approve this follow request"
562+msgstr ""
563+
564+#: html.c:3030 html.c:4465
565+msgid "Discard"
566+msgstr ""
567+
568+#: html.c:3030
569+msgid "Discard this follow request"
570+msgstr ""
571+
572+#: html.c:3035 html.c:4314
573+msgid "Unmute"
574+msgstr ""
575+
576+#: html.c:3036
577+msgid "Stop blocking activities from this user"
578+msgstr ""
579+
580+#: html.c:3040
581+msgid "Block any activity from this user"
582+msgstr ""
583+
584+#: html.c:3048
585+msgid "Direct Message..."
586+msgstr ""
587+
588+#: html.c:3083
589+msgid "Pending follow confirmations"
590+msgstr ""
591+
592+#: html.c:3087
593+msgid "People you follow"
594+msgstr ""
595+
596+#: html.c:3088
597+msgid "People that follow you"
598+msgstr ""
599+
600+#: html.c:3127
601+msgid "Clear all"
602+msgstr ""
603+
604+#: html.c:3184
605+msgid "Mention"
606+msgstr ""
607+
608+#: html.c:3187
609+msgid "Finished poll"
610+msgstr ""
611+
612+#: html.c:3202
613+msgid "Follow Request"
614+msgstr ""
615+
616+#: html.c:3285
617+msgid "Context"
618+msgstr ""
619+
620+#: html.c:3296
621+msgid "New"
622+msgstr ""
623+
624+#: html.c:3311
625+msgid "Already seen"
626+msgstr ""
627+
628+#: html.c:3326
629+msgid "None"
630+msgstr ""
631+
632+#: html.c:3592
633+#, c-format
634+msgid "Search results for account %s"
635+msgstr ""
636+
637+#: html.c:3599
638+#, c-format
639+msgid "Account %s not found"
640+msgstr ""
641+
642+#: html.c:3630
643+#, c-format
644+msgid "Search results for tag %s"
645+msgstr ""
646+
647+#: html.c:3630
648+#, c-format
649+msgid "Nothing found for tag %s"
650+msgstr ""
651+
652+#: html.c:3646
653+#, c-format
654+msgid "Search results for '%s' (may be more)"
655+msgstr ""
656+
657+#: html.c:3649
658+#, c-format
659+msgid "Search results for '%s'"
660+msgstr ""
661+
662+#: html.c:3652
663+#, c-format
664+msgid "No more matches for '%s'"
665+msgstr ""
666+
667+#: html.c:3654
668+#, c-format
669+msgid "Nothing found for '%s'"
670+msgstr ""
671+
672+#: html.c:3752
673+msgid "Showing instance timeline"
674+msgstr ""
675+
676+#: html.c:3820
677+#, c-format
678+msgid "Showing timeline for list '%s'"
679+msgstr ""
680+
681+#: httpd.c:250
682+#, c-format
683+msgid "Search results for tag #%s"
684+msgstr ""
685+
686+#: httpd.c:259
687+msgid "Recent posts by users in this instance"
688+msgstr ""
M snac.c
+3, -0
 1@@ -24,6 +24,7 @@
 2 #include "xs_match.h"
 3 #include "xs_fcgi.h"
 4 #include "xs_html.h"
 5+#include "xs_po.h"
 6 
 7 #include "snac.h"
 8 
 9@@ -34,6 +35,7 @@ xs_str *srv_basedir = NULL;
10 xs_dict *srv_config = NULL;
11 xs_str *srv_baseurl = NULL;
12 xs_str *srv_proxy_token_seed = NULL;
13+xs_dict *srv_langs = NULL;
14 
15 int dbglevel = 0;
16 
17@@ -179,6 +181,7 @@ const char *http_status_text(int status)
18 /* translate status codes to canonical status texts */
19 {
20     switch (status) {
21+        case 399: return "Timeout";
22 #define HTTP_STATUS(code, name, text) case HTTP_STATUS_ ## name: return #text;
23 #include "http_codes.h"
24 #undef HTTP_STATUS
M snac.h
+10, -2
 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.72"
 6+#define VERSION "2.73-dev"
 7 
 8 #define USER_AGENT "snac/" VERSION
 9 
10@@ -16,6 +16,10 @@
11 #define MAX_THREADS 256
12 #endif
13 
14+#ifndef MAX_JSON_DEPTH
15+#define MAX_JSON_DEPTH 8
16+#endif
17+
18 #ifndef MAX_CONVERSATION_LEVELS
19 #define MAX_CONVERSATION_LEVELS 48
20 #endif
21@@ -29,10 +33,11 @@ extern xs_str *srv_basedir;
22 extern xs_dict *srv_config;
23 extern xs_str *srv_baseurl;
24 extern xs_str *srv_proxy_token_seed;
25+extern xs_dict *srv_langs;
26 
27 extern int dbglevel;
28 
29-#define L(s) (s)
30+#define L(s) lang_str((s), user)
31 
32 #define POSTLIKE_OBJECT_TYPE "Note|Question|Page|Article|Video|Audio|Event"
33 
34@@ -55,6 +60,7 @@ typedef struct {
35     xs_dict *links;     /* validated links */
36     xs_str *actor;      /* actor url */
37     xs_str *md5;        /* actor url md5 */
38+    const xs_dict *lang;/* string translation dict */
39 } snac;
40 
41 typedef struct {
42@@ -441,3 +447,5 @@ xs_str *make_url(const char *href, const char *proxy, int by_token);
43 
44 int badlogin_check(const char *user, const char *addr);
45 void badlogin_inc(const char *user, const char *addr);
46+
47+const char *lang_str(const char *str, const snac *user);
+11, -0
 1@@ -13,6 +13,8 @@ int xs_smtp_request(const char *url, const char *user, const char *pass,
 2                    const char *from, const char *to, const xs_str *body,
 3                    int use_ssl);
 4 
 5+const char *xs_curl_strerr(int errnum);
 6+
 7 #ifdef XS_IMPLEMENTATION
 8 
 9 #include <curl/curl.h>
10@@ -240,6 +242,15 @@ int xs_smtp_request(const char *url, const char *user, const char *pass,
11     return (int)res;
12 }
13 
14+
15+const char *xs_curl_strerr(int errnum)
16+{
17+    CURLcode cc = errnum < 0 ? -errnum : errnum;
18+
19+    return curl_easy_strerror(cc);
20+}
21+
22+
23 #endif /* XS_IMPLEMENTATION */
24 
25 #endif /* _XS_CURL_H */
+23, -17
  1@@ -4,17 +4,23 @@
  2 
  3 #define _XS_JSON_H
  4 
  5+#ifndef MAX_JSON_DEPTH
  6+#define MAX_JSON_DEPTH 32
  7+#endif
  8+
  9 int xs_json_dump(const xs_val *data, int indent, FILE *f);
 10 xs_str *xs_json_dumps(const xs_val *data, int indent);
 11 
 12-xs_val *xs_json_load(FILE *f);
 13-xs_val *xs_json_loads(const xs_str *json);
 14+xs_val *xs_json_load_full(FILE *f, int maxdepth);
 15+xs_val *xs_json_loads_full(const xs_str *json, int maxdepth);
 16+#define xs_json_load(f) xs_json_load_full(f, MAX_JSON_DEPTH)
 17+#define xs_json_loads(s) xs_json_loads_full(s, MAX_JSON_DEPTH)
 18 
 19 xstype xs_json_load_type(FILE *f);
 20 int xs_json_load_array_iter(FILE *f, xs_val **value, xstype *pt, int *c);
 21 int xs_json_load_object_iter(FILE *f, xs_str **key, xs_val **value, xstype *pt, int *c);
 22-xs_list *xs_json_load_array(FILE *f);
 23-xs_dict *xs_json_load_object(FILE *f);
 24+xs_list *xs_json_load_array(FILE *f, int maxdepth);
 25+xs_dict *xs_json_load_object(FILE *f, int maxdepth);
 26 
 27 
 28 #ifdef XS_IMPLEMENTATION
 29@@ -371,7 +377,7 @@ int xs_json_load_array_iter(FILE *f, xs_val **value, xstype *pt, int *c)
 30 }
 31 
 32 
 33-xs_list *xs_json_load_array(FILE *f)
 34+xs_list *xs_json_load_array(FILE *f, int maxdepth)
 35 /* loads a full JSON array (after the initial OBRACK) */
 36 {
 37     xstype t;
 38@@ -387,12 +393,12 @@ xs_list *xs_json_load_array(FILE *f)
 39 
 40         if (r == 1) {
 41             /* partial load? */
 42-            if (v == NULL) {
 43+            if (v == NULL && maxdepth != 0) {
 44                 if (t == XSTYPE_LIST)
 45-                    v = xs_json_load_array(f);
 46+                    v = xs_json_load_array(f, maxdepth - 1);
 47                 else
 48                 if (t == XSTYPE_DICT)
 49-                    v = xs_json_load_object(f);
 50+                    v = xs_json_load_object(f, maxdepth - 1);
 51             }
 52 
 53             /* still null? fail */
 54@@ -459,7 +465,7 @@ int xs_json_load_object_iter(FILE *f, xs_str **key, xs_val **value, xstype *pt,
 55 }
 56 
 57 
 58-xs_dict *xs_json_load_object(FILE *f)
 59+xs_dict *xs_json_load_object(FILE *f, int maxdepth)
 60 /* loads a full JSON object (after the initial OCURLY) */
 61 {
 62     xstype t;
 63@@ -476,12 +482,12 @@ xs_dict *xs_json_load_object(FILE *f)
 64 
 65         if (r == 1) {
 66             /* partial load? */
 67-            if (v == NULL) {
 68+            if (v == NULL && maxdepth != 0) {
 69                 if (t == XSTYPE_LIST)
 70-                    v = xs_json_load_array(f);
 71+                    v = xs_json_load_array(f, maxdepth - 1);
 72                 else
 73                 if (t == XSTYPE_DICT)
 74-                    v = xs_json_load_object(f);
 75+                    v = xs_json_load_object(f, maxdepth - 1);
 76             }
 77 
 78             /* still null? fail */
 79@@ -500,14 +506,14 @@ xs_dict *xs_json_load_object(FILE *f)
 80 }
 81 
 82 
 83-xs_val *xs_json_loads(const xs_str *json)
 84+xs_val *xs_json_loads_full(const xs_str *json, int maxdepth)
 85 /* loads a string in JSON format and converts to a multiple data */
 86 {
 87     FILE *f;
 88     xs_val *v = NULL;
 89 
 90     if ((f = fmemopen((char *)json, strlen(json), "r")) != NULL) {
 91-        v = xs_json_load(f);
 92+        v = xs_json_load_full(f, maxdepth);
 93         fclose(f);
 94     }
 95 
 96@@ -533,17 +539,17 @@ xstype xs_json_load_type(FILE *f)
 97 }
 98 
 99 
100-xs_val *xs_json_load(FILE *f)
101+xs_val *xs_json_load_full(FILE *f, int maxdepth)
102 /* loads a JSON file */
103 {
104     xs_val *v = NULL;
105     xstype t = xs_json_load_type(f);
106 
107     if (t == XSTYPE_LIST)
108-        v = xs_json_load_array(f);
109+        v = xs_json_load_array(f, maxdepth);
110     else
111     if (t == XSTYPE_DICT)
112-        v = xs_json_load_object(f);
113+        v = xs_json_load_object(f, maxdepth);
114 
115     return v;
116 }
+86, -0
 1@@ -0,0 +1,86 @@
 2+/* copyright (c) 2025 grunfink et al. / MIT license */
 3+
 4+#ifndef _XS_PO_H
 5+
 6+#define _XS_PO_H
 7+
 8+xs_dict *xs_po_to_dict(const char *fn);
 9+
10+#ifdef XS_IMPLEMENTATION
11+
12+xs_dict *xs_po_to_dict(const char *fn)
13+/* converts a PO file to a dict */
14+{
15+    xs_dict *d = NULL;
16+    FILE *f;
17+
18+    if ((f = fopen(fn, "r")) != NULL) {
19+        d = xs_dict_new();
20+
21+        xs *k = NULL;
22+        xs *v = NULL;
23+        enum { IN_NONE, IN_K, IN_V } mode = IN_NONE;
24+
25+        while (!feof(f)) {
26+            xs *l = xs_strip_i(xs_readline(f));
27+
28+            /* discard empty lines and comments */
29+            if (*l == '\0' || *l == '#')
30+                continue;
31+
32+            if (xs_startswith(l, "msgid ")) {
33+                if (mode == IN_V) {
34+                    /* flush */
35+                    if (xs_is_string(k) && xs_is_string(v) && *v)
36+                        d = xs_dict_set(d, k, v);
37+
38+                    k = xs_free(k);
39+                    v = xs_free(v);
40+                }
41+
42+                l = xs_replace_i(l, "msgid ", "");
43+                mode = IN_K;
44+
45+                k = xs_str_new(NULL);
46+            }
47+            else
48+            if (xs_startswith(l, "msgstr ")) {
49+                if (mode != IN_K)
50+                    break;
51+
52+                l = xs_replace_i(l, "msgstr ", "");
53+                mode = IN_V;
54+
55+                v = xs_str_new(NULL);
56+            }
57+
58+            l = xs_replace_i(l, "\\n", "\n");
59+            l = xs_strip_chars_i(l, "\"");
60+
61+            switch (mode) {
62+            case IN_K:
63+                k = xs_str_cat(k, l);
64+                break;
65+
66+            case IN_V:
67+                v = xs_str_cat(v, l);
68+                break;
69+
70+            case IN_NONE:
71+                break;
72+            }
73+        }
74+
75+        /* final flush */
76+        if (xs_is_string(k) && xs_is_string(v) && *v)
77+            d = xs_dict_set(d, k, v);
78+
79+        fclose(f);
80+    }
81+
82+    return d;
83+}
84+
85+#endif /* XS_IMPLEMENTATION */
86+
87+#endif /* XS_PO_H */
+4, -2
 1@@ -43,11 +43,13 @@ xs_list *xs_regex_split_n(const char *str, const char *rx, int count)
 2     while (count > 0 && !regexec(&re, (p = str + offset), 1, &rm, offset > 0 ? REG_NOTBOL : 0)) {
 3         /* add first the leading part of the string */
 4         xs *s1 = xs_str_new_sz(p, rm.rm_so);
 5-        list = xs_list_append(list, s1);
 6+
 7+        list = xs_list_append(list, xs_is_string(s1) ? s1 : "");
 8 
 9         /* add now the matched text as the separator */
10         xs *s2 = xs_str_new_sz(p + rm.rm_so, rm.rm_eo - rm.rm_so);
11-        list = xs_list_append(list, s2);
12+
13+        list = xs_list_append(list, xs_is_string(s2) ? s2 : "");
14 
15         /* move forward */
16         offset += rm.rm_eo;
+1, -1
1@@ -1 +1 @@
2-/* 2f43b93e9d2b63360c802e09f4c68adfef74c673 2025-01-28T07:40:50+01:00 */
3+/* d467dc71e518603250a55c8a67e26cf40e1710e9 2025-02-14T10:21:15+01:00 */