summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGravatar Giacomo Tesio2024-12-05 22:53:40 +0100
committerGravatar Giacomo Tesio2024-12-05 22:53:40 +0100
commitbd74ffda5b25cc07e8d559815e027c8dab3b9d73 (patch)
tree00725a5d5f53ed421b923de6fc1892c58815cc16
parentMakefile: enable static compilation with musl (diff)
parentUpdated RELEASE_NOTES. (diff)
downloadpenes-snac2-bd74ffda5b25cc07e8d559815e027c8dab3b9d73.tar.gz
penes-snac2-bd74ffda5b25cc07e8d559815e027c8dab3b9d73.tar.xz
penes-snac2-bd74ffda5b25cc07e8d559815e027c8dab3b9d73.zip
Merge branch 'master' into build-with-musl
-rw-r--r--RELEASE_NOTES.md28
-rw-r--r--TODO.md14
-rw-r--r--activitypub.c121
-rw-r--r--data.c121
-rw-r--r--doc/snac.136
-rw-r--r--doc/snac.52
-rw-r--r--html.c234
-rw-r--r--httpd.c14
-rw-r--r--main.c22
-rw-r--r--mastoapi.c60
-rw-r--r--snac.h14
-rw-r--r--utils.c53
-rw-r--r--xs_unicode.h24
-rw-r--r--xs_unicode_tbl.h15
-rw-r--r--xs_url.h8
-rw-r--r--xs_version.h2
16 files changed, 673 insertions, 95 deletions
diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md
index f46f958..2218c20 100644
--- a/RELEASE_NOTES.md
+++ b/RELEASE_NOTES.md
@@ -1,5 +1,33 @@
1# Release Notes 1# Release Notes
2 2
3## UNRELEASED
4
5As many users have asked for it, there is now an option to make the number of followed and following accounts public (still disabled by default). These are only the numbers; the lists themselves are never published.
6
7Some fixes to blocked instances code (posts from them were sometimes shown).
8
9## 2.65
10
11Added a new user option to disable automatic follow confirmations (follow requests must be manually approved from the people page).
12
13The search box also searches for accounts (via webfinger).
14
15New command-line action `import_list`, to import a Mastodon list in CSV format (so that [Mastodon Follow Packs](https://mastodonmigration.wordpress.com/?p=995) can be directly used).
16
17New command-line action `import_block_list`, to import a Mastodon list of accounts to be blocked in CSV format.
18
19## 2.64
20
21Some tweaks for better integration with https://bsky.brid.gy (the BlueSky bridge by brid.gy).
22
23A corner case bug in the media proxying code has been fixed.
24
25Hashtags can now include underscores.
26
27The server now creates a pidfile inside the data directory.
28
29Mastodon API: fixed a crash in the notification code, fixed autocapitalization in the OAuth login field (contributed by fkooman).
30
3## 2.63 31## 2.63
4 32
5The server can now act as a proxy for all image, audio or video media coming from other account's posts (both from the Web UI and the Mastodon API). This way, other servers will see media requests coming from the server IP, not the user's, improving privacy. This is controlled by setting the `proxy_media` boolean field to `server.json` to true. 33The server can now act as a proxy for all image, audio or video media coming from other account's posts (both from the Web UI and the Mastodon API). This way, other servers will see media requests coming from the server IP, not the user's, improving privacy. This is controlled by setting the `proxy_media` boolean field to `server.json` to true.
diff --git a/TODO.md b/TODO.md
index 619364c..0f2cbd0 100644
--- a/TODO.md
+++ b/TODO.md
@@ -16,10 +16,6 @@ Important: deleting a follower should do more that just delete the object, see h
16 16
17## Wishlist 17## Wishlist
18 18
19Implement Proxying for Media Links to Enhance User Privacy (see https://codeberg.org/grunfink/snac2/issues/219 for more information).
20
21Consider showing only posts by the account owner (not full trees) (see https://codeberg.org/grunfink/snac2/issues/217 for more information).
22
23Add support for subscribing and posting to relays (see https://codeberg.org/grunfink/snac2/issues/216 for more information). 19Add support for subscribing and posting to relays (see https://codeberg.org/grunfink/snac2/issues/216 for more information).
24 20
25The instance timeline should also show boosts from users. 21The instance timeline should also show boosts from users.
@@ -357,3 +353,13 @@ Fix a crash when posting from the links browser (2.63, 2024-11-08T15:57:25+0100)
357Fix some repeated images in Lemmy posts (2.63, 2024-11-08T15:57:25+0100). 353Fix some repeated images in Lemmy posts (2.63, 2024-11-08T15:57:25+0100).
358 354
359Fix a crash when posting an image from the tooot mobile app (2.63, 2024-11-11T19:42:11+0100). 355Fix a crash when posting an image from the tooot mobile app (2.63, 2024-11-11T19:42:11+0100).
356
357Fix some URL proxying (2.64, 2024-11-16T07:26:23+0100).
358
359Allow underscores in hashtags (2.64, 2024-11-16T07:26:23+0100).
360
361Add a pidfile (2.64, 2024-11-17T10:21:29+0100).
362
363Implement Proxying for Media Links to Enhance User Privacy (see https://codeberg.org/grunfink/snac2/issues/219 for more information) (2024-11-18T20:36:39+0100).
364
365Consider showing only posts by the account owner (not full trees) (see https://codeberg.org/grunfink/snac2/issues/217 for more information) (2024-11-18T20:36:39+0100).
diff --git a/activitypub.c b/activitypub.c
index 0b2fc6a..773df78 100644
--- a/activitypub.c
+++ b/activitypub.c
@@ -183,6 +183,18 @@ const char *get_atto(const xs_dict *msg)
183} 183}
184 184
185 185
186const char *get_in_reply_to(const xs_dict *msg)
187/* gets the inReplyTo id */
188{
189 const xs_val *in_reply_to = xs_dict_get(msg, "inReplyTo");
190
191 if (xs_type(in_reply_to) == XSTYPE_DICT)
192 in_reply_to = xs_dict_get(in_reply_to, "id");
193
194 return in_reply_to;
195}
196
197
186xs_list *get_attachments(const xs_dict *msg) 198xs_list *get_attachments(const xs_dict *msg)
187/* unify the garbage fire that are the attachments */ 199/* unify the garbage fire that are the attachments */
188{ 200{
@@ -373,7 +385,7 @@ int timeline_request(snac *snac, const char **id, xs_str **wrk, int level)
373 } 385 }
374 386
375 /* does it have an ancestor? */ 387 /* does it have an ancestor? */
376 const char *in_reply_to = xs_dict_get(object, "inReplyTo"); 388 const char *in_reply_to = get_in_reply_to(object);
377 389
378 /* store */ 390 /* store */
379 timeline_add(snac, nid, object); 391 timeline_add(snac, nid, object);
@@ -671,7 +683,7 @@ int is_msg_for_me(snac *snac, const xs_dict *c_msg)
671 return 3; 683 return 3;
672 684
673 /* is this message a reply to another? */ 685 /* is this message a reply to another? */
674 const char *irt = xs_dict_get(msg, "inReplyTo"); 686 const char *irt = get_in_reply_to(msg);
675 if (!xs_is_null(irt)) { 687 if (!xs_is_null(irt)) {
676 xs *r_msg = NULL; 688 xs *r_msg = NULL;
677 689
@@ -724,7 +736,7 @@ xs_str *process_tags(snac *snac, const char *content, xs_list **tag)
724 /* use this same server */ 736 /* use this same server */
725 def_srv = xs_dup(xs_dict_get(srv_config, "host")); 737 def_srv = xs_dup(xs_dict_get(srv_config, "host"));
726 738
727 split = xs_regex_split(content, "(@[A-Za-z0-9_]+(@[A-Za-z0-9\\.-]+)?|&#[0-9]+;|#[^[:punct:][:space:]]+)"); 739 split = xs_regex_split(content, "(@[A-Za-z0-9_]+(@[A-Za-z0-9\\.-]+)?|&#[0-9]+;|#(_|[^[:punct:][:space:]])+)");
728 740
729 p = split; 741 p = split;
730 while (xs_list_iter(&p, &v)) { 742 while (xs_list_iter(&p, &v)) {
@@ -1026,15 +1038,14 @@ xs_dict *msg_base(snac *snac, const char *type, const char *id,
1026} 1038}
1027 1039
1028 1040
1029xs_dict *msg_collection(snac *snac, const char *id) 1041xs_dict *msg_collection(snac *snac, const char *id, int items)
1030/* creates an empty OrderedCollection message */ 1042/* creates an empty OrderedCollection message */
1031{ 1043{
1032 xs_dict *msg = msg_base(snac, "OrderedCollection", id, NULL, NULL, NULL); 1044 xs_dict *msg = msg_base(snac, "OrderedCollection", id, NULL, NULL, NULL);
1033 xs *ol = xs_list_new(); 1045 xs *n = xs_number_new(items);
1034 1046
1035 msg = xs_dict_append(msg, "attributedTo", snac->actor); 1047 msg = xs_dict_append(msg, "attributedTo", snac->actor);
1036 msg = xs_dict_append(msg, "orderedItems", ol); 1048 msg = xs_dict_append(msg, "totalItems", n);
1037 msg = xs_dict_append(msg, "totalItems", xs_stock(0));
1038 1049
1039 return msg; 1050 return msg;
1040} 1051}
@@ -1206,7 +1217,30 @@ xs_dict *msg_actor(snac *snac)
1206 } 1217 }
1207 1218
1208 /* add the metadata as attachments of PropertyValue */ 1219 /* add the metadata as attachments of PropertyValue */
1209 const xs_dict *metadata = xs_dict_get(snac->config, "metadata"); 1220 xs *metadata = NULL;
1221 const xs_dict *md = xs_dict_get(snac->config, "metadata");
1222
1223 if (xs_type(md) == XSTYPE_DICT)
1224 metadata = xs_dup(md);
1225 else
1226 if (xs_type(md) == XSTYPE_STRING) {
1227 metadata = xs_dict_new();
1228 xs *l = xs_split(md, "\n");
1229 const char *ll;
1230
1231 xs_list_foreach(l, ll) {
1232 xs *kv = xs_split_n(ll, "=", 1);
1233 const char *k = xs_list_get(kv, 0);
1234 const char *v = xs_list_get(kv, 1);
1235
1236 if (k && v) {
1237 xs *kk = xs_strip_i(xs_dup(k));
1238 xs *vv = xs_strip_i(xs_dup(v));
1239 metadata = xs_dict_set(metadata, kk, vv);
1240 }
1241 }
1242 }
1243
1210 if (xs_type(metadata) == XSTYPE_DICT) { 1244 if (xs_type(metadata) == XSTYPE_DICT) {
1211 xs *attach = xs_list_new(); 1245 xs *attach = xs_list_new();
1212 const xs_str *k; 1246 const xs_str *k;
@@ -1252,6 +1286,10 @@ xs_dict *msg_actor(snac *snac)
1252 msg = xs_dict_set(msg, "alsoKnownAs", loaka); 1286 msg = xs_dict_set(msg, "alsoKnownAs", loaka);
1253 } 1287 }
1254 1288
1289 const xs_val *manually = xs_dict_get(snac->config, "approve_followers");
1290 msg = xs_dict_set(msg, "manuallyApprovesFollowers",
1291 xs_stock(xs_is_true(manually) ? XSTYPE_TRUE : XSTYPE_FALSE));
1292
1255 return msg; 1293 return msg;
1256} 1294}
1257 1295
@@ -1888,22 +1926,31 @@ int process_input_message(snac *snac, const xs_dict *msg, const xs_dict *req)
1888 object_add(actor, actor_obj); 1926 object_add(actor, actor_obj);
1889 } 1927 }
1890 1928
1891 xs *f_msg = xs_dup(msg); 1929 if (xs_is_true(xs_dict_get(snac->config, "approve_followers"))) {
1892 xs *reply = msg_accept(snac, f_msg, actor); 1930 pending_add(snac, actor, msg);
1893
1894 post_message(snac, actor, reply);
1895 1931
1896 if (xs_is_null(xs_dict_get(f_msg, "published"))) { 1932 snac_log(snac, xs_fmt("new pending follower approval %s", actor));
1897 /* add a date if it doesn't include one (Mastodon) */
1898 xs *date = xs_str_utctime(0, ISO_DATE_SPEC);
1899 f_msg = xs_dict_set(f_msg, "published", date);
1900 } 1933 }
1934 else {
1935 /* automatic following */
1936 xs *f_msg = xs_dup(msg);
1937 xs *reply = msg_accept(snac, f_msg, actor);
1938
1939 post_message(snac, actor, reply);
1901 1940
1902 timeline_add(snac, id, f_msg); 1941 if (xs_is_null(xs_dict_get(f_msg, "published"))) {
1942 /* add a date if it doesn't include one (Mastodon) */
1943 xs *date = xs_str_utctime(0, ISO_DATE_SPEC);
1944 f_msg = xs_dict_set(f_msg, "published", date);
1945 }
1946
1947 timeline_add(snac, id, f_msg);
1903 1948
1904 follower_add(snac, actor); 1949 follower_add(snac, actor);
1950
1951 snac_log(snac, xs_fmt("new follower %s", actor));
1952 }
1905 1953
1906 snac_log(snac, xs_fmt("new follower %s", actor));
1907 do_notify = 1; 1954 do_notify = 1;
1908 } 1955 }
1909 else 1956 else
@@ -1925,6 +1972,11 @@ int process_input_message(snac *snac, const xs_dict *msg, const xs_dict *req)
1925 do_notify = 1; 1972 do_notify = 1;
1926 } 1973 }
1927 else 1974 else
1975 if (pending_check(snac, actor)) {
1976 pending_del(snac, actor);
1977 snac_log(snac, xs_fmt("cancelled pending follow from %s", actor));
1978 }
1979 else
1928 snac_log(snac, xs_fmt("error deleting follower %s", actor)); 1980 snac_log(snac, xs_fmt("error deleting follower %s", actor));
1929 } 1981 }
1930 } 1982 }
@@ -1957,7 +2009,7 @@ int process_input_message(snac *snac, const xs_dict *msg, const xs_dict *req)
1957 2009
1958 if (xs_match(utype, "Note|Article")) { /** **/ 2010 if (xs_match(utype, "Note|Article")) { /** **/
1959 const char *id = xs_dict_get(object, "id"); 2011 const char *id = xs_dict_get(object, "id");
1960 const char *in_reply_to = xs_dict_get(object, "inReplyTo"); 2012 const char *in_reply_to = get_in_reply_to(object);
1961 const char *atto = get_atto(object); 2013 const char *atto = get_atto(object);
1962 xs *wrk = NULL; 2014 xs *wrk = NULL;
1963 2015
@@ -2784,6 +2836,8 @@ int activitypub_get_handler(const xs_dict *req, const char *q_path,
2784 2836
2785 *ctype = "application/activity+json"; 2837 *ctype = "application/activity+json";
2786 2838
2839 int show_contact_metrics = xs_is_true(xs_dict_get(snac.config, "show_contact_metrics"));
2840
2787 if (p_path == NULL) { 2841 if (p_path == NULL) {
2788 /* if there was no component after the user, it's an actor request */ 2842 /* if there was no component after the user, it's an actor request */
2789 msg = msg_actor(&snac); 2843 msg = msg_actor(&snac);
@@ -2797,7 +2851,6 @@ int activitypub_get_handler(const xs_dict *req, const char *q_path,
2797 if (strcmp(p_path, "outbox") == 0 || strcmp(p_path, "featured") == 0) { 2851 if (strcmp(p_path, "outbox") == 0 || strcmp(p_path, "featured") == 0) {
2798 xs *id = xs_fmt("%s/%s", snac.actor, p_path); 2852 xs *id = xs_fmt("%s/%s", snac.actor, p_path);
2799 xs *list = xs_list_new(); 2853 xs *list = xs_list_new();
2800 msg = msg_collection(&snac, id);
2801 const char *v; 2854 const char *v;
2802 int tc = 0; 2855 int tc = 0;
2803 2856
@@ -2819,14 +2872,32 @@ int activitypub_get_handler(const xs_dict *req, const char *q_path,
2819 } 2872 }
2820 2873
2821 /* replace the 'orderedItems' with the latest posts */ 2874 /* replace the 'orderedItems' with the latest posts */
2822 xs *items = xs_number_new(xs_list_len(list)); 2875 msg = msg_collection(&snac, id, xs_list_len(list));
2823 msg = xs_dict_set(msg, "orderedItems", list); 2876 msg = xs_dict_set(msg, "orderedItems", list);
2824 msg = xs_dict_set(msg, "totalItems", items);
2825 } 2877 }
2826 else 2878 else
2827 if (strcmp(p_path, "followers") == 0 || strcmp(p_path, "following") == 0) { 2879 if (strcmp(p_path, "followers") == 0) {
2880 int total = 0;
2881
2882 if (show_contact_metrics) {
2883 xs *l = follower_list(&snac);
2884 total = xs_list_len(l);
2885 }
2886
2887 xs *id = xs_fmt("%s/%s", snac.actor, p_path);
2888 msg = msg_collection(&snac, id, total);
2889 }
2890 else
2891 if (strcmp(p_path, "following") == 0) {
2892 int total = 0;
2893
2894 if (show_contact_metrics) {
2895 xs *l = following_list(&snac);
2896 total = xs_list_len(l);
2897 }
2898
2828 xs *id = xs_fmt("%s/%s", snac.actor, p_path); 2899 xs *id = xs_fmt("%s/%s", snac.actor, p_path);
2829 msg = msg_collection(&snac, id); 2900 msg = msg_collection(&snac, id, total);
2830 } 2901 }
2831 else 2902 else
2832 if (xs_startswith(p_path, "p/")) { 2903 if (xs_startswith(p_path, "p/")) {
diff --git a/data.c b/data.c
index 1cd69a5..4e5851a 100644
--- a/data.c
+++ b/data.c
@@ -336,6 +336,35 @@ int user_persist(snac *snac, int publish)
336 xs *bfn = xs_fmt("%s.bak", fn); 336 xs *bfn = xs_fmt("%s.bak", fn);
337 FILE *f; 337 FILE *f;
338 338
339 if (publish) {
340 /* check if any of the relevant fields have really changed */
341 if ((f = fopen(fn, "r")) != NULL) {
342 xs *old = xs_json_load(f);
343 fclose(f);
344
345 if (old != NULL) {
346 int nw = 0;
347 const char *fields[] = { "header", "avatar", "name", "bio", "metadata", NULL };
348
349 for (int n = 0; fields[n]; n++) {
350 const char *of = xs_dict_get(old, fields[n]);
351 const char *nf = xs_dict_get(snac->config, fields[n]);
352
353 if (of == NULL && nf == NULL)
354 continue;
355
356 if (xs_type(of) != XSTYPE_STRING || xs_type(nf) != XSTYPE_STRING || strcmp(of, nf)) {
357 nw = 1;
358 break;
359 }
360 }
361
362 if (!nw)
363 publish = 0;
364 }
365 }
366 }
367
339 rename(fn, bfn); 368 rename(fn, bfn);
340 369
341 if ((f = fopen(fn, "w")) != NULL) { 370 if ((f = fopen(fn, "w")) != NULL) {
@@ -799,7 +828,7 @@ int _object_add(const char *id, const xs_dict *obj, int ow)
799 fclose(f); 828 fclose(f);
800 829
801 /* does this object has a parent? */ 830 /* does this object has a parent? */
802 const char *in_reply_to = xs_dict_get(obj, "inReplyTo"); 831 const char *in_reply_to = get_in_reply_to(obj);
803 832
804 if (!xs_is_null(in_reply_to) && *in_reply_to) { 833 if (!xs_is_null(in_reply_to) && *in_reply_to) {
805 /* update the children index of the parent */ 834 /* update the children index of the parent */
@@ -1176,6 +1205,96 @@ xs_list *follower_list(snac *snac)
1176} 1205}
1177 1206
1178 1207
1208/** pending followers **/
1209
1210int pending_add(snac *user, const char *actor, const xs_dict *msg)
1211/* stores the follow message for later confirmation */
1212{
1213 xs *dir = xs_fmt("%s/pending", user->basedir);
1214 xs *md5 = xs_md5_hex(actor, strlen(actor));
1215 xs *fn = xs_fmt("%s/%s.json", dir, md5);
1216 FILE *f;
1217
1218 mkdirx(dir);
1219
1220 if ((f = fopen(fn, "w")) == NULL)
1221 return -1;
1222
1223 xs_json_dump(msg, 4, f);
1224 fclose(f);
1225
1226 return 0;
1227}
1228
1229
1230int pending_check(snac *user, const char *actor)
1231/* checks if there is a pending follow confirmation for the actor */
1232{
1233 xs *md5 = xs_md5_hex(actor, strlen(actor));
1234 xs *fn = xs_fmt("%s/pending/%s.json", user->basedir, md5);
1235
1236 return mtime(fn) != 0;
1237}
1238
1239
1240xs_dict *pending_get(snac *user, const char *actor)
1241/* returns the pending follow confirmation for the actor */
1242{
1243 xs *md5 = xs_md5_hex(actor, strlen(actor));
1244 xs *fn = xs_fmt("%s/pending/%s.json", user->basedir, md5);
1245 xs_dict *msg = NULL;
1246 FILE *f;
1247
1248 if ((f = fopen(fn, "r")) != NULL) {
1249 msg = xs_json_load(f);
1250 fclose(f);
1251 }
1252
1253 return msg;
1254}
1255
1256
1257void pending_del(snac *user, const char *actor)
1258/* deletes a pending follow confirmation for the actor */
1259{
1260 xs *md5 = xs_md5_hex(actor, strlen(actor));
1261 xs *fn = xs_fmt("%s/pending/%s.json", user->basedir, md5);
1262
1263 unlink(fn);
1264}
1265
1266
1267xs_list *pending_list(snac *user)
1268/* returns a list of pending follow confirmations */
1269{
1270 xs *spec = xs_fmt("%s/pending/""*.json", user->basedir);
1271 xs *l = xs_glob(spec, 0, 0);
1272 xs_list *r = xs_list_new();
1273 const char *v;
1274
1275 xs_list_foreach(l, v) {
1276 FILE *f;
1277 xs *msg = NULL;
1278
1279 if ((f = fopen(v, "r")) == NULL)
1280 continue;
1281
1282 msg = xs_json_load(f);
1283 fclose(f);
1284
1285 if (msg == NULL)
1286 continue;
1287
1288 const char *actor = xs_dict_get(msg, "actor");
1289
1290 if (xs_type(actor) == XSTYPE_STRING)
1291 r = xs_list_append(r, actor);
1292 }
1293
1294 return r;
1295}
1296
1297
1179/** timeline **/ 1298/** timeline **/
1180 1299
1181double timeline_mtime(snac *snac) 1300double timeline_mtime(snac *snac)
diff --git a/doc/snac.1 b/doc/snac.1
index 4c40ac9..efba67e 100644
--- a/doc/snac.1
+++ b/doc/snac.1
@@ -129,6 +129,28 @@ Just what it says in the tin. This is to mitigate spammers
129coming from Fediverse instances with lax / open registration 129coming from Fediverse instances with lax / open registration
130processes. Please take note that this also avoids possibly 130processes. Please take note that this also avoids possibly
131legitimate people trying to contact you. 131legitimate people trying to contact you.
132.It This account is a bot
133Set this checkbox if this account behaves like a bot (i.e.
134posts are automatically generated).
135.It Auto-boost all mentions to this account
136If this toggle is set, all mentions to this account are boosted
137to all followers. This can be used to create groups.
138.It This account is private
139If this toggle is set, posts are not published via the public
140web interface, only via the ActivityPub protocol.
141.It Collapse top threads by default
142If this toggle is set, the private timeline will always show
143conversations collapsed by default. This allows easier navigation
144through long threads.
145.It Follow requests must be approved
146If this toggle is set, follow requests are not automatically
147accepted, but notified and stored for later review. Pending
148follow requests will be shown in the people page to be
149approved or discarded.
150.It Publish follower and following metrics
151If this toggle is set, the number of followers and following
152accounts are made public (this is only the number; the specific
153lists of accounts are never published).
132.It Password 154.It Password
133Write the same string in these two fields to change your 155Write the same string in these two fields to change your
134password. Don't write anything if you don't want to do this. 156password. Don't write anything if you don't want to do this.
@@ -262,6 +284,13 @@ section 'Migrating from snac to Mastodon').
262Starts a migration from this account to the one set as an alias (see 284Starts a migration from this account to the one set as an alias (see
263.Xr snac 8 , 285.Xr snac 8 ,
264section 'Migrating from snac to Mastodon'). 286section 'Migrating from snac to Mastodon').
287.It Cm import_csv Ar basedir Ar uid
288Imports CSV data files from a Mastodon export. This command expects the
289following files to be in the current directory:
290.Pa bookmarks.csv ,
291.Pa blocked_accounts.csv ,
292.Pa lists.csv , and
293.Pa following_accounts.csv .
265.It Cm state Ar basedir 294.It Cm state Ar basedir
266Dumps the current state of the server and its threads. For example: 295Dumps the current state of the server and its threads. For example:
267.Bd -literal -offset indent 296.Bd -literal -offset indent
@@ -284,6 +313,11 @@ in-memory job queue. The thread state can be: waiting (idle waiting
284for a job to be assigned), input or output (processing I/O packets) 313for a job to be assigned), input or output (processing I/O packets)
285or stopped (not running, only to be seen while starting or stopping 314or stopped (not running, only to be seen while starting or stopping
286the server). 315the server).
316.It Cm import_list Ar basedir Ar uid Ar file
317Imports a Mastodon list in CSV format. This option can be used to
318import "Mastodon Follow Packs".
319.It Cm import_block_list Ar basedir Ar uid Ar file
320Imports a Mastodon list of accounts to be blocked in CSV format.
287.El 321.El
288.Ss Migrating an account to/from Mastodon 322.Ss Migrating an account to/from Mastodon
289See 323See
@@ -349,4 +383,4 @@ See the LICENSE file for details.
349.Sh CAVEATS 383.Sh CAVEATS
350Use the Fediverse sparingly. Don't fear the MUTE button. 384Use the Fediverse sparingly. Don't fear the MUTE button.
351.Sh BUGS 385.Sh BUGS
352Probably plenty. Some issues may be even documented in the TODO.md file. 386Probably many. Some issues may be even documented in the TODO.md file.
diff --git a/doc/snac.5 b/doc/snac.5
index 1b28d25..0168430 100644
--- a/doc/snac.5
+++ b/doc/snac.5
@@ -209,6 +209,8 @@ web interface.
209.It Pa history/ 209.It Pa history/
210This directory contains generated HTML files. They may be snapshots of the 210This directory contains generated HTML files. They may be snapshots of the
211local timeline in previous months or other cached data. 211local timeline in previous months or other cached data.
212.It Pa server.pid
213This file stores the server PID in a single text line.
212.El 214.El
213.Sh SEE ALSO 215.Sh SEE ALSO
214.Xr snac 1 , 216.Xr snac 1 ,
diff --git a/html.c b/html.c
index 3a2b14f..edb7e1e 100644
--- a/html.c
+++ b/html.c
@@ -770,7 +770,7 @@ static xs_html *html_user_body(snac *user, int read_only)
770 xs_html_sctag("input", 770 xs_html_sctag("input",
771 xs_html_attr("type", "text"), 771 xs_html_attr("type", "text"),
772 xs_html_attr("name", "q"), 772 xs_html_attr("name", "q"),
773 xs_html_attr("title", L("Search posts by content (regular expression) or #tag")), 773 xs_html_attr("title", L("Search posts by content (regular expression), @user@host accounts, or #tag")),
774 xs_html_attr("placeholder", L("Content search"))))); 774 xs_html_attr("placeholder", L("Content search")))));
775 } 775 }
776 776
@@ -829,21 +829,45 @@ static xs_html *html_user_body(snac *user, int read_only)
829 } 829 }
830 830
831 if (read_only) { 831 if (read_only) {
832 xs *es1 = encode_html(xs_dict_get(user->config, "bio"));
833 xs *tags = xs_list_new(); 832 xs *tags = xs_list_new();
834 xs *bio1 = not_really_markdown(es1, NULL, &tags); 833 xs *bio1 = not_really_markdown(xs_dict_get(user->config, "bio"), NULL, &tags);
835 xs *bio2 = process_tags(user, bio1, &tags); 834 xs *bio2 = process_tags(user, bio1, &tags);
835 xs *bio3 = sanitize(bio2);
836 836
837 bio2 = replace_shortnames(bio2, tags, 2, proxy); 837 bio3 = replace_shortnames(bio3, tags, 2, proxy);
838 838
839 xs_html *top_user_bio = xs_html_tag("div", 839 xs_html *top_user_bio = xs_html_tag("div",
840 xs_html_attr("class", "p-note snac-top-user-bio"), 840 xs_html_attr("class", "p-note snac-top-user-bio"),
841 xs_html_raw(bio2)); /* already sanitized */ 841 xs_html_raw(bio3)); /* already sanitized */
842 842
843 xs_html_add(top_user, 843 xs_html_add(top_user,
844 top_user_bio); 844 top_user_bio);
845 845
846 const xs_dict *metadata = xs_dict_get(user->config, "metadata"); 846 xs *metadata = NULL;
847 const xs_dict *md = xs_dict_get(user->config, "metadata");
848
849 if (xs_type(md) == XSTYPE_DICT)
850 metadata = xs_dup(md);
851 else
852 if (xs_type(md) == XSTYPE_STRING) {
853 /* convert to dict for easier iteration */
854 metadata = xs_dict_new();
855 xs *l = xs_split(md, "\n");
856 const char *ll;
857
858 xs_list_foreach(l, ll) {
859 xs *kv = xs_split_n(ll, "=", 1);
860 const char *k = xs_list_get(kv, 0);
861 const char *v = xs_list_get(kv, 1);
862
863 if (k && v) {
864 xs *kk = xs_strip_i(xs_dup(k));
865 xs *vv = xs_strip_i(xs_dup(v));
866 metadata = xs_dict_set(metadata, kk, vv);
867 }
868 }
869 }
870
847 if (xs_type(metadata) == XSTYPE_DICT) { 871 if (xs_type(metadata) == XSTYPE_DICT) {
848 const xs_str *k; 872 const xs_str *k;
849 const xs_str *v; 873 const xs_str *v;
@@ -914,6 +938,18 @@ static xs_html *html_user_body(snac *user, int read_only)
914 xs_html_add(top_user, 938 xs_html_add(top_user,
915 snac_metadata); 939 snac_metadata);
916 } 940 }
941
942 if (xs_is_true(xs_dict_get(user->config, "show_contact_metrics"))) {
943 xs *fwers = follower_list(user);
944 xs *fwing = following_list(user);
945
946 xs *s1 = xs_fmt(L("%d following %d followers"),
947 xs_list_len(fwing), xs_list_len(fwers));
948
949 xs_html_add(top_user,
950 xs_html_tag("p",
951 xs_html_text(s1)));
952 }
917 } 953 }
918 954
919 xs_html_add(body, 955 xs_html_add(body,
@@ -1025,20 +1061,31 @@ xs_html *html_top_controls(snac *snac)
1025 const xs_val *a_private = xs_dict_get(snac->config, "private"); 1061 const xs_val *a_private = xs_dict_get(snac->config, "private");
1026 const xs_val *auto_boost = xs_dict_get(snac->config, "auto_boost"); 1062 const xs_val *auto_boost = xs_dict_get(snac->config, "auto_boost");
1027 const xs_val *coll_thrds = xs_dict_get(snac->config, "collapse_threads"); 1063 const xs_val *coll_thrds = xs_dict_get(snac->config, "collapse_threads");
1064 const xs_val *pending = xs_dict_get(snac->config, "approve_followers");
1065 const xs_val *show_foll = xs_dict_get(snac->config, "show_contact_metrics");
1028 1066
1029 xs *metadata = xs_str_new(NULL); 1067 xs *metadata = NULL;
1030 const xs_dict *md = xs_dict_get(snac->config, "metadata"); 1068 const xs_dict *md = xs_dict_get(snac->config, "metadata");
1031 const xs_str *k;
1032 const xs_str *v;
1033 1069
1034 int c = 0; 1070 if (xs_type(md) == XSTYPE_DICT) {
1035 while (xs_dict_next(md, &k, &v, &c)) { 1071 const xs_str *k;
1036 xs *kp = xs_fmt("%s=%s", k, v); 1072 const xs_str *v;
1037 1073
1038 if (*metadata) 1074 metadata = xs_str_new(NULL);
1039 metadata = xs_str_cat(metadata, "\n"); 1075
1040 metadata = xs_str_cat(metadata, kp); 1076 xs_dict_foreach(md, k, v) {
1077 xs *kp = xs_fmt("%s=%s", k, v);
1078
1079 if (*metadata)
1080 metadata = xs_str_cat(metadata, "\n");
1081 metadata = xs_str_cat(metadata, kp);
1082 }
1041 } 1083 }
1084 else
1085 if (xs_type(md) == XSTYPE_STRING)
1086 metadata = xs_dup(md);
1087 else
1088 metadata = xs_str_new(NULL);
1042 1089
1043 xs *user_setup_action = xs_fmt("%s/admin/user-setup", snac->actor); 1090 xs *user_setup_action = xs_fmt("%s/admin/user-setup", snac->actor);
1044 1091
@@ -1188,6 +1235,24 @@ xs_html *html_top_controls(snac *snac)
1188 xs_html_attr("for", "collapse_threads"), 1235 xs_html_attr("for", "collapse_threads"),
1189 xs_html_text(L("Collapse top threads by default")))), 1236 xs_html_text(L("Collapse top threads by default")))),
1190 xs_html_tag("p", 1237 xs_html_tag("p",
1238 xs_html_sctag("input",
1239 xs_html_attr("type", "checkbox"),
1240 xs_html_attr("name", "approve_followers"),
1241 xs_html_attr("id", "approve_followers"),
1242 xs_html_attr(xs_is_true(pending) ? "checked" : "", NULL)),
1243 xs_html_tag("label",
1244 xs_html_attr("for", "approve_followers"),
1245 xs_html_text(L("Follow requests must be approved")))),
1246 xs_html_tag("p",
1247 xs_html_sctag("input",
1248 xs_html_attr("type", "checkbox"),
1249 xs_html_attr("name", "show_contact_metrics"),
1250 xs_html_attr("id", "show_contact_metrics"),
1251 xs_html_attr(xs_is_true(show_foll) ? "checked" : "", NULL)),
1252 xs_html_tag("label",
1253 xs_html_attr("for", "show_contact_metrics"),
1254 xs_html_text(L("Publish follower and following metrics")))),
1255 xs_html_tag("p",
1191 xs_html_text(L("Profile metadata (key=value pairs in each line):")), 1256 xs_html_text(L("Profile metadata (key=value pairs in each line):")),
1192 xs_html_sctag("br", NULL), 1257 xs_html_sctag("br", NULL),
1193 xs_html_tag("textarea", 1258 xs_html_tag("textarea",
@@ -1481,6 +1546,9 @@ xs_html *html_entry(snac *user, xs_dict *msg, int read_only,
1481 if ((read_only || !user) && !is_msg_public(msg)) 1546 if ((read_only || !user) && !is_msg_public(msg))
1482 return NULL; 1547 return NULL;
1483 1548
1549 if (id && is_instance_blocked(id))
1550 return NULL;
1551
1484 if (user && level == 0 && xs_is_true(xs_dict_get(user->config, "collapse_threads"))) 1552 if (user && level == 0 && xs_is_true(xs_dict_get(user->config, "collapse_threads")))
1485 collapse_threads = 1; 1553 collapse_threads = 1;
1486 1554
@@ -1670,7 +1738,7 @@ xs_html *html_entry(snac *user, xs_dict *msg, int read_only,
1670 if (strcmp(type, "Note") == 0) { 1738 if (strcmp(type, "Note") == 0) {
1671 if (level == 0) { 1739 if (level == 0) {
1672 /* is the parent not here? */ 1740 /* is the parent not here? */
1673 const char *parent = xs_dict_get(msg, "inReplyTo"); 1741 const char *parent = get_in_reply_to(msg);
1674 1742
1675 if (user && !xs_is_null(parent) && *parent && !timeline_here(user, parent)) { 1743 if (user && !xs_is_null(parent) && *parent && !timeline_here(user, parent)) {
1676 xs_html_add(post_header, 1744 xs_html_add(post_header,
@@ -2329,7 +2397,7 @@ xs_str *html_timeline(snac *user, const xs_list *list, int read_only,
2329 2397
2330 /* is this message a non-public reply? */ 2398 /* is this message a non-public reply? */
2331 if (user != NULL && !is_msg_public(msg)) { 2399 if (user != NULL && !is_msg_public(msg)) {
2332 const char *irt = xs_dict_get(msg, "inReplyTo"); 2400 const char *irt = get_in_reply_to(msg);
2333 2401
2334 /* is it a reply to something not in the storage? */ 2402 /* is it a reply to something not in the storage? */
2335 if (!xs_is_null(irt) && !object_here(irt)) { 2403 if (!xs_is_null(irt) && !object_here(irt)) {
@@ -2437,10 +2505,9 @@ xs_html *html_people_list(snac *snac, xs_list *list, char *header, char *t, cons
2437 xs_html_tag("summary", 2505 xs_html_tag("summary",
2438 xs_html_text("...")))); 2506 xs_html_text("..."))));
2439 2507
2440 xs_list *p = list;
2441 const char *actor_id; 2508 const char *actor_id;
2442 2509
2443 while (xs_list_iter(&p, &actor_id)) { 2510 xs_list_foreach(list, actor_id) {
2444 xs *md5 = xs_md5_hex(actor_id, strlen(actor_id)); 2511 xs *md5 = xs_md5_hex(actor_id, strlen(actor_id));
2445 xs *actor = NULL; 2512 xs *actor = NULL;
2446 2513
@@ -2509,6 +2576,15 @@ xs_html *html_people_list(snac *snac, xs_list *list, char *header, char *t, cons
2509 html_button("limit", L("Limit"), 2576 html_button("limit", L("Limit"),
2510 L("Block announces (boosts) from this user"))); 2577 L("Block announces (boosts) from this user")));
2511 } 2578 }
2579 else
2580 if (pending_check(snac, actor_id)) {
2581 xs_html_add(form,
2582 html_button("approve", L("Approve"),
2583 L("Approve this follow request")));
2584
2585 xs_html_add(form,
2586 html_button("discard", L("Discard"), L("Discard this follow request")));
2587 }
2512 else { 2588 else {
2513 xs_html_add(form, 2589 xs_html_add(form,
2514 html_button("follow", L("Follow"), 2590 html_button("follow", L("Follow"),
@@ -2563,13 +2639,23 @@ xs_str *html_people(snac *user)
2563 xs *wing = following_list(user); 2639 xs *wing = following_list(user);
2564 xs *wers = follower_list(user); 2640 xs *wers = follower_list(user);
2565 2641
2642 xs_html *lists = xs_html_tag("div",
2643 xs_html_attr("class", "snac-posts"));
2644
2645 if (xs_is_true(xs_dict_get(user->config, "approve_followers"))) {
2646 xs *pending = pending_list(user);
2647 xs_html_add(lists,
2648 html_people_list(user, pending, L("Pending follow confirmations"), "p", proxy));
2649 }
2650
2651 xs_html_add(lists,
2652 html_people_list(user, wing, L("People you follow"), "i", proxy),
2653 html_people_list(user, wers, L("People that follow you"), "e", proxy));
2654
2566 xs_html *html = xs_html_tag("html", 2655 xs_html *html = xs_html_tag("html",
2567 html_user_head(user, NULL, NULL), 2656 html_user_head(user, NULL, NULL),
2568 xs_html_add(html_user_body(user, 0), 2657 xs_html_add(html_user_body(user, 0),
2569 xs_html_tag("div", 2658 lists,
2570 xs_html_attr("class", "snac-posts"),
2571 html_people_list(user, wing, L("People you follow"), "i", proxy),
2572 html_people_list(user, wers, L("People that follow you"), "e", proxy)),
2573 html_footer())); 2659 html_footer()));
2574 2660
2575 return xs_html_render_s(html, "<!DOCTYPE html>\n"); 2661 return xs_html_render_s(html, "<!DOCTYPE html>\n");
@@ -2661,6 +2747,9 @@ xs_str *html_notifications(snac *user, int skip, int show)
2661 label = wrk; 2747 label = wrk;
2662 } 2748 }
2663 } 2749 }
2750 else
2751 if (strcmp(type, "Follow") == 0 && pending_check(user, actor_id))
2752 label = L("Follow Request");
2664 2753
2665 xs *s_date = xs_crop_i(xs_dup(date), 0, 10); 2754 xs *s_date = xs_crop_i(xs_dup(date), 0, 10);
2666 2755
@@ -2909,6 +2998,48 @@ int html_get_handler(const xs_dict *req, const char *q_path,
2909 const char *q = xs_dict_get(q_vars, "q"); 2998 const char *q = xs_dict_get(q_vars, "q");
2910 2999
2911 if (q && *q) { 3000 if (q && *q) {
3001 if (xs_regex_match(q, "^@?[a-zA-Z0-9_]+@[a-zA-Z0-9-]+\\.")) {
3002 /** search account **/
3003 xs *actor = NULL;
3004 xs *acct = NULL;
3005 xs *l = xs_list_new();
3006 xs_html *page = NULL;
3007
3008 if (valid_status(webfinger_request(q, &actor, &acct))) {
3009 xs *actor_obj = NULL;
3010
3011 if (valid_status(actor_request(&snac, actor, &actor_obj))) {
3012 actor_add(actor, actor_obj);
3013
3014 /* create a people list with only one element */
3015 l = xs_list_append(xs_list_new(), actor);
3016
3017 xs *title = xs_fmt(L("Search results for account %s"), q);
3018
3019 page = html_people_list(&snac, l, title, "wf", NULL);
3020 }
3021 }
3022
3023 if (page == NULL) {
3024 xs *title = xs_fmt(L("Account %s not found"), q);
3025
3026 page = xs_html_tag("div",
3027 xs_html_tag("h2",
3028 xs_html_attr("class", "snac-header"),
3029 xs_html_text(title)));
3030 }
3031
3032 xs_html *html = xs_html_tag("html",
3033 html_user_head(&snac, NULL, NULL),
3034 xs_html_add(html_user_body(&snac, 0),
3035 page,
3036 html_footer()));
3037
3038 *body = xs_html_render_s(html, "<!DOCTYPE html>\n");
3039 *b_size = strlen(*body);
3040 status = HTTP_STATUS_OK;
3041 }
3042 else
2912 if (*q == '#') { 3043 if (*q == '#') {
2913 /** search by tag **/ 3044 /** search by tag **/
2914 xs *tl = tag_search(q, skip, show + 1); 3045 xs *tl = tag_search(q, skip, show + 1);
@@ -3647,6 +3778,34 @@ int html_post_handler(const xs_dict *req, const char *q_path,
3647 timeline_touch(&snac); 3778 timeline_touch(&snac);
3648 } 3779 }
3649 else 3780 else
3781 if (strcmp(action, L("Approve")) == 0) { /** **/
3782 xs *fwreq = pending_get(&snac, actor);
3783
3784 if (fwreq != NULL) {
3785 xs *reply = msg_accept(&snac, fwreq, actor);
3786
3787 enqueue_message(&snac, reply);
3788
3789 if (xs_is_null(xs_dict_get(fwreq, "published"))) {
3790 /* add a date if it doesn't include one (Mastodon) */
3791 xs *date = xs_str_utctime(0, ISO_DATE_SPEC);
3792 fwreq = xs_dict_set(fwreq, "published", date);
3793 }
3794
3795 timeline_add(&snac, xs_dict_get(fwreq, "id"), fwreq);
3796
3797 follower_add(&snac, actor);
3798
3799 pending_del(&snac, actor);
3800
3801 snac_log(&snac, xs_fmt("new follower %s", actor));
3802 }
3803 }
3804 else
3805 if (strcmp(action, L("Discard")) == 0) { /** **/
3806 pending_del(&snac, actor);
3807 }
3808 else
3650 status = HTTP_STATUS_NOT_FOUND; 3809 status = HTTP_STATUS_NOT_FOUND;
3651 3810
3652 /* delete the cached timeline */ 3811 /* delete the cached timeline */
@@ -3705,26 +3864,17 @@ int html_post_handler(const xs_dict *req, const char *q_path,
3705 snac.config = xs_dict_set(snac.config, "collapse_threads", xs_stock(XSTYPE_TRUE)); 3864 snac.config = xs_dict_set(snac.config, "collapse_threads", xs_stock(XSTYPE_TRUE));
3706 else 3865 else
3707 snac.config = xs_dict_set(snac.config, "collapse_threads", xs_stock(XSTYPE_FALSE)); 3866 snac.config = xs_dict_set(snac.config, "collapse_threads", xs_stock(XSTYPE_FALSE));
3867 if ((v = xs_dict_get(p_vars, "approve_followers")) != NULL && strcmp(v, "on") == 0)
3868 snac.config = xs_dict_set(snac.config, "approve_followers", xs_stock(XSTYPE_TRUE));
3869 else
3870 snac.config = xs_dict_set(snac.config, "approve_followers", xs_stock(XSTYPE_FALSE));
3871 if ((v = xs_dict_get(p_vars, "show_contact_metrics")) != NULL && strcmp(v, "on") == 0)
3872 snac.config = xs_dict_set(snac.config, "show_contact_metrics", xs_stock(XSTYPE_TRUE));
3873 else
3874 snac.config = xs_dict_set(snac.config, "show_contact_metrics", xs_stock(XSTYPE_FALSE));
3708 3875
3709 if ((v = xs_dict_get(p_vars, "metadata")) != NULL) { 3876 if ((v = xs_dict_get(p_vars, "metadata")) != NULL)
3710 /* split the metadata and store it as a dict */ 3877 snac.config = xs_dict_set(snac.config, "metadata", v);
3711 xs_dict *md = xs_dict_new();
3712 xs *l = xs_split(v, "\n");
3713 xs_list *p = l;
3714 const xs_str *kp;
3715
3716 while (xs_list_iter(&p, &kp)) {
3717 xs *kpl = xs_split_n(kp, "=", 1);
3718 if (xs_list_len(kpl) == 2) {
3719 xs *k2 = xs_strip_i(xs_dup(xs_list_get(kpl, 0)));
3720 xs *v2 = xs_strip_i(xs_dup(xs_list_get(kpl, 1)));
3721
3722 md = xs_dict_set(md, k2, v2);
3723 }
3724 }
3725
3726 snac.config = xs_dict_set(snac.config, "metadata", md);
3727 }
3728 3878
3729 /* uploads */ 3879 /* uploads */
3730 const char *uploads[] = { "avatar", "header", NULL }; 3880 const char *uploads[] = { "avatar", "header", NULL };
diff --git a/httpd.c b/httpd.c
index 1613e1f..81d2f9e 100644
--- a/httpd.c
+++ b/httpd.c
@@ -774,6 +774,7 @@ void httpd(void)
774 xs *sem_name = NULL; 774 xs *sem_name = NULL;
775 xs *shm_name = NULL; 775 xs *shm_name = NULL;
776 sem_t anon_job_sem; 776 sem_t anon_job_sem;
777 xs *pidfile = xs_fmt("%s/server.pid", srv_basedir);
777 778
778 address = xs_dict_get(srv_config, "address"); 779 address = xs_dict_get(srv_config, "address");
779 780
@@ -809,6 +810,17 @@ void httpd(void)
809 srv_log(xs_fmt("httpd%s start %s %s", p_state->use_fcgi ? " (FastCGI)" : "", 810 srv_log(xs_fmt("httpd%s start %s %s", p_state->use_fcgi ? " (FastCGI)" : "",
810 full_address, USER_AGENT)); 811 full_address, USER_AGENT));
811 812
813 {
814 FILE *f;
815
816 if ((f = fopen(pidfile, "w")) != NULL) {
817 fprintf(f, "%d\n", getpid());
818 fclose(f);
819 }
820 else
821 srv_log(xs_fmt("Cannot create %s: %s", pidfile, strerror(errno)));
822 }
823
812 /* show the number of usable file descriptors */ 824 /* show the number of usable file descriptors */
813 struct rlimit r; 825 struct rlimit r;
814 getrlimit(RLIMIT_NOFILE, &r); 826 getrlimit(RLIMIT_NOFILE, &r);
@@ -894,4 +906,6 @@ void httpd(void)
894 srv_log(xs_fmt("httpd%s stop %s (run time: %s)", 906 srv_log(xs_fmt("httpd%s stop %s (run time: %s)",
895 p_state->use_fcgi ? " (FastCGI)" : "", 907 p_state->use_fcgi ? " (FastCGI)" : "",
896 full_address, uptime)); 908 full_address, uptime));
909
910 unlink(pidfile);
897} 911}
diff --git a/main.c b/main.c
index c6fff5f..76a7961 100644
--- a/main.c
+++ b/main.c
@@ -51,7 +51,9 @@ int usage(void)
51 printf("export_csv {basedir} {uid} Exports data as CSV files into current directory\n"); 51 printf("export_csv {basedir} {uid} Exports data as CSV files into current directory\n");
52 printf("alias {basedir} {uid} {account} Sets account (@user@host or actor url) as an alias\n"); 52 printf("alias {basedir} {uid} {account} Sets account (@user@host or actor url) as an alias\n");
53 printf("migrate {basedir} {uid} Migrates to the account defined as the alias\n"); 53 printf("migrate {basedir} {uid} Migrates to the account defined as the alias\n");
54 printf("import_csv {basedir} {uid} Imports data from CSV files into current directory\n"); 54 printf("import_csv {basedir} {uid} Imports data from CSV files in the current directory\n");
55 printf("import_list {basedir} {uid} {file} Imports a Mastodon CSV list file\n");
56 printf("import_block_list {basedir} {uid} {file} Imports a Mastodon CSV block list file\n");
55 57
56 return 1; 58 return 1;
57} 59}
@@ -558,7 +560,11 @@ int main(int argc, char *argv[])
558 if (data != NULL) { 560 if (data != NULL) {
559 xs_json_dump(data, 4, stdout); 561 xs_json_dump(data, 4, stdout);
560 enqueue_actor_refresh(&snac, xs_dict_get(data, "attributedTo"), 0); 562 enqueue_actor_refresh(&snac, xs_dict_get(data, "attributedTo"), 0);
561 timeline_add(&snac, url, data); 563
564 if (!timeline_here(&snac, url))
565 timeline_add(&snac, url, data);
566 else
567 printf("Post %s already here\n", url);
562 } 568 }
563 569
564 return 0; 570 return 0;
@@ -585,6 +591,18 @@ int main(int argc, char *argv[])
585 return 0; 591 return 0;
586 } 592 }
587 593
594 if (strcmp(cmd, "import_list") == 0) { /** **/
595 import_list_csv(&snac, url);
596
597 return 0;
598 }
599
600 if (strcmp(cmd, "import_block_list") == 0) { /** **/
601 import_blocked_accounts_csv(&snac, url);
602
603 return 0;
604 }
605
588 if (strcmp(cmd, "note") == 0) { /** **/ 606 if (strcmp(cmd, "note") == 0) { /** **/
589 xs *content = NULL; 607 xs *content = NULL;
590 xs *msg = NULL; 608 xs *msg = NULL;
diff --git a/mastoapi.c b/mastoapi.c
index c9d71b9..990898b 100644
--- a/mastoapi.c
+++ b/mastoapi.c
@@ -171,7 +171,7 @@ const char *login_page = ""
171"<body><h1>%s OAuth identify</h1>\n" 171"<body><h1>%s OAuth identify</h1>\n"
172"<div style=\"background-color: red; color: white\">%s</div>\n" 172"<div style=\"background-color: red; color: white\">%s</div>\n"
173"<form method=\"post\" action=\"%s:/" "/%s/%s\">\n" 173"<form method=\"post\" action=\"%s:/" "/%s/%s\">\n"
174"<p>Login: <input type=\"text\" name=\"login\"></p>\n" 174"<p>Login: <input type=\"text\" name=\"login\" autocapitalize=\"off\"></p>\n"
175"<p>Password: <input type=\"password\" name=\"passwd\"></p>\n" 175"<p>Password: <input type=\"password\" name=\"passwd\"></p>\n"
176"<input type=\"hidden\" name=\"redir\" value=\"%s\">\n" 176"<input type=\"hidden\" name=\"redir\" value=\"%s\">\n"
177"<input type=\"hidden\" name=\"cid\" value=\"%s\">\n" 177"<input type=\"hidden\" name=\"cid\" value=\"%s\">\n"
@@ -663,6 +663,17 @@ xs_dict *mastoapi_account(snac *logged, const xs_dict *actor)
663 if (user_open(&user, prefu)) { 663 if (user_open(&user, prefu)) {
664 val_links = user.links; 664 val_links = user.links;
665 metadata = xs_dict_get_def(user.config, "metadata", xs_stock(XSTYPE_DICT)); 665 metadata = xs_dict_get_def(user.config, "metadata", xs_stock(XSTYPE_DICT));
666
667 /* does this user want to publish their contact metrics? */
668 if (xs_is_true(xs_dict_get(user.config, "show_contact_metrics"))) {
669 xs *fwing = following_list(&user);
670 xs *fwers = follower_list(&user);
671 xs *ni = xs_number_new(xs_list_len(fwing));
672 xs *ne = xs_number_new(xs_list_len(fwers));
673
674 acct = xs_dict_append(acct, "followers_count", ne);
675 acct = xs_dict_append(acct, "following_count", ni);
676 }
666 } 677 }
667 } 678 }
668 679
@@ -827,7 +838,16 @@ xs_dict *mastoapi_status(snac *snac, const xs_dict *msg)
827 st = xs_dict_append(st, "url", id); 838 st = xs_dict_append(st, "url", id);
828 st = xs_dict_append(st, "account", acct); 839 st = xs_dict_append(st, "account", acct);
829 840
830 xs *fd = mastoapi_date(xs_dict_get(msg, "published")); 841 const char *published = xs_dict_get(msg, "published");
842 xs *fd = NULL;
843
844 if (published)
845 fd = mastoapi_date(published);
846 else {
847 xs *p = xs_str_iso_date(0);
848 fd = mastoapi_date(p);
849 }
850
831 st = xs_dict_append(st, "created_at", fd); 851 st = xs_dict_append(st, "created_at", fd);
832 852
833 { 853 {
@@ -1024,7 +1044,7 @@ xs_dict *mastoapi_status(snac *snac, const xs_dict *msg)
1024 st = xs_dict_append(st, "in_reply_to_id", xs_stock(XSTYPE_NULL)); 1044 st = xs_dict_append(st, "in_reply_to_id", xs_stock(XSTYPE_NULL));
1025 st = xs_dict_append(st, "in_reply_to_account_id", xs_stock(XSTYPE_NULL)); 1045 st = xs_dict_append(st, "in_reply_to_account_id", xs_stock(XSTYPE_NULL));
1026 1046
1027 tmp = xs_dict_get(msg, "inReplyTo"); 1047 tmp = get_in_reply_to(msg);
1028 if (!xs_is_null(tmp)) { 1048 if (!xs_is_null(tmp)) {
1029 xs *irto = NULL; 1049 xs *irto = NULL;
1030 1050
@@ -1266,6 +1286,17 @@ void credentials_get(char **body, char **ctype, int *status, snac snac)
1266 acct = xs_dict_append(acct, "following_count", xs_stock(0)); 1286 acct = xs_dict_append(acct, "following_count", xs_stock(0));
1267 acct = xs_dict_append(acct, "statuses_count", xs_stock(0)); 1287 acct = xs_dict_append(acct, "statuses_count", xs_stock(0));
1268 1288
1289 /* does this user want to publish their contact metrics? */
1290 if (xs_is_true(xs_dict_get(snac.config, "show_contact_metrics"))) {
1291 xs *fwing = following_list(&snac);
1292 xs *fwers = follower_list(&snac);
1293 xs *ni = xs_number_new(xs_list_len(fwing));
1294 xs *ne = xs_number_new(xs_list_len(fwers));
1295
1296 acct = xs_dict_append(acct, "followers_count", ne);
1297 acct = xs_dict_append(acct, "following_count", ni);
1298 }
1299
1269 *body = xs_json_dumps(acct, 4); 1300 *body = xs_json_dumps(acct, 4);
1270 *ctype = "application/json"; 1301 *ctype = "application/json";
1271 *status = HTTP_STATUS_OK; 1302 *status = HTTP_STATUS_OK;
@@ -1340,6 +1371,9 @@ xs_list *mastoapi_timeline(snac *user, const xs_dict *args, const char *index_fn
1340 if (!xs_match(type, POSTLIKE_OBJECT_TYPE)) 1371 if (!xs_match(type, POSTLIKE_OBJECT_TYPE))
1341 continue; 1372 continue;
1342 1373
1374 if (id && is_instance_blocked(id))
1375 continue;
1376
1343 const char *from = NULL; 1377 const char *from = NULL;
1344 if (strcmp(type, "Page") == 0) 1378 if (strcmp(type, "Page") == 0)
1345 from = xs_dict_get(msg, "audience"); 1379 from = xs_dict_get(msg, "audience");
@@ -1727,11 +1761,11 @@ int mastoapi_get_handler(const xs_dict *req, const char *q_path,
1727 if (logged_in) { 1761 if (logged_in) {
1728 xs *l = notify_list(&snac1, 0, 64); 1762 xs *l = notify_list(&snac1, 0, 64);
1729 xs *out = xs_list_new(); 1763 xs *out = xs_list_new();
1730 xs_list *p = l;
1731 const xs_dict *v; 1764 const xs_dict *v;
1732 const xs_list *excl = xs_dict_get(args, "exclude_types[]"); 1765 const xs_list *excl = xs_dict_get(args, "exclude_types[]");
1766 const char *max_id = xs_dict_get(args, "max_id");
1733 1767
1734 while (xs_list_iter(&p, &v)) { 1768 xs_list_foreach(l, v) {
1735 xs *noti = notify_get(&snac1, v); 1769 xs *noti = notify_get(&snac1, v);
1736 1770
1737 if (noti == NULL) 1771 if (noti == NULL)
@@ -1740,6 +1774,8 @@ int mastoapi_get_handler(const xs_dict *req, const char *q_path,
1740 const char *type = xs_dict_get(noti, "type"); 1774 const char *type = xs_dict_get(noti, "type");
1741 const char *utype = xs_dict_get(noti, "utype"); 1775 const char *utype = xs_dict_get(noti, "utype");
1742 const char *objid = xs_dict_get(noti, "objid"); 1776 const char *objid = xs_dict_get(noti, "objid");
1777 const char *id = xs_dict_get(noti, "id");
1778 xs *fid = xs_replace(id, ".", "");
1743 xs *actor = NULL; 1779 xs *actor = NULL;
1744 xs *entry = NULL; 1780 xs *entry = NULL;
1745 1781
@@ -1752,6 +1788,13 @@ int mastoapi_get_handler(const xs_dict *req, const char *q_path,
1752 if (is_hidden(&snac1, objid)) 1788 if (is_hidden(&snac1, objid))
1753 continue; 1789 continue;
1754 1790
1791 if (max_id) {
1792 if (strcmp(fid, max_id) == 0)
1793 max_id = NULL;
1794
1795 continue;
1796 }
1797
1755 /* convert the type */ 1798 /* convert the type */
1756 if (strcmp(type, "Like") == 0 || strcmp(type, "EmojiReact") == 0) 1799 if (strcmp(type, "Like") == 0 || strcmp(type, "EmojiReact") == 0)
1757 type = "favourite"; 1800 type = "favourite";
@@ -1778,12 +1821,15 @@ int mastoapi_get_handler(const xs_dict *req, const char *q_path,
1778 1821
1779 mn = xs_dict_append(mn, "type", type); 1822 mn = xs_dict_append(mn, "type", type);
1780 1823
1781 xs *id = xs_replace(xs_dict_get(noti, "id"), ".", ""); 1824 mn = xs_dict_append(mn, "id", fid);
1782 mn = xs_dict_append(mn, "id", id);
1783 1825
1784 mn = xs_dict_append(mn, "created_at", xs_dict_get(noti, "date")); 1826 mn = xs_dict_append(mn, "created_at", xs_dict_get(noti, "date"));
1785 1827
1786 xs *acct = mastoapi_account(&snac1, actor); 1828 xs *acct = mastoapi_account(&snac1, actor);
1829
1830 if (acct == NULL)
1831 continue;
1832
1787 mn = xs_dict_append(mn, "account", acct); 1833 mn = xs_dict_append(mn, "account", acct);
1788 1834
1789 if (strcmp(type, "follow") != 0 && !xs_is_null(objid)) { 1835 if (strcmp(type, "follow") != 0 && !xs_is_null(objid)) {
diff --git a/snac.h b/snac.h
index ad2793e..a3c055b 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 - 2024 grunfink et al. / MIT license */ 2/* copyright (c) 2022 - 2024 grunfink et al. / MIT license */
3 3
4#define VERSION "2.63" 4#define VERSION "2.66-dev"
5 5
6#define USER_AGENT "snac/" VERSION 6#define USER_AGENT "snac/" VERSION
7 7
@@ -141,6 +141,12 @@ int follower_del(snac *snac, const char *actor);
141int follower_check(snac *snac, const char *actor); 141int follower_check(snac *snac, const char *actor);
142xs_list *follower_list(snac *snac); 142xs_list *follower_list(snac *snac);
143 143
144int pending_add(snac *user, const char *actor, const xs_dict *msg);
145int pending_check(snac *user, const char *actor);
146xs_dict *pending_get(snac *user, const char *actor);
147void pending_del(snac *user, const char *actor);
148xs_list *pending_list(snac *user);
149
144double timeline_mtime(snac *snac); 150double timeline_mtime(snac *snac);
145int timeline_touch(snac *snac); 151int timeline_touch(snac *snac);
146int timeline_here(snac *snac, const char *md5); 152int timeline_here(snac *snac, const char *md5);
@@ -296,6 +302,7 @@ const char *default_avatar_base64(void);
296xs_str *process_tags(snac *snac, const char *content, xs_list **tag); 302xs_str *process_tags(snac *snac, const char *content, xs_list **tag);
297 303
298const char *get_atto(const xs_dict *msg); 304const char *get_atto(const xs_dict *msg);
305const char *get_in_reply_to(const xs_dict *msg);
299xs_list *get_attachments(const xs_dict *msg); 306xs_list *get_attachments(const xs_dict *msg);
300 307
301xs_dict *msg_admiration(snac *snac, const char *object, const char *type); 308xs_dict *msg_admiration(snac *snac, const char *object, const char *type);
@@ -313,6 +320,7 @@ xs_dict *msg_update(snac *snac, const xs_dict *object);
313xs_dict *msg_ping(snac *user, const char *rcpt); 320xs_dict *msg_ping(snac *user, const char *rcpt);
314xs_dict *msg_pong(snac *user, const char *rcpt, const char *object); 321xs_dict *msg_pong(snac *user, const char *rcpt, const char *object);
315xs_dict *msg_move(snac *user, const char *new_account); 322xs_dict *msg_move(snac *user, const char *new_account);
323xs_dict *msg_accept(snac *snac, const xs_val *object, const char *to);
316xs_dict *msg_question(snac *user, const char *content, xs_list *attach, 324xs_dict *msg_question(snac *user, const char *content, xs_list *attach,
317 const xs_list *opts, int multiple, int end_secs); 325 const xs_list *opts, int multiple, int end_secs);
318 326
@@ -396,6 +404,10 @@ void verify_links(snac *user);
396 404
397void export_csv(snac *user); 405void export_csv(snac *user);
398int migrate_account(snac *user); 406int migrate_account(snac *user);
407
408void import_blocked_accounts_csv(snac *user, const char *fn);
409void import_following_accounts_csv(snac *user, const char *fn);
410void import_list_csv(snac *user, const char *fn);
399void import_csv(snac *user); 411void import_csv(snac *user);
400 412
401typedef enum { 413typedef enum {
diff --git a/utils.c b/utils.c
index 4f5ac55..df3b55d 100644
--- a/utils.c
+++ b/utils.c
@@ -670,20 +670,18 @@ void export_csv(snac *user)
670} 670}
671 671
672 672
673void import_csv(snac *user) 673void import_blocked_accounts_csv(snac *user, const char *fn)
674/* import CSV files from Mastodon */ 674/* imports a Mastodon CSV file of blocked accounts */
675{ 675{
676 FILE *f; 676 FILE *f;
677 const char *fn;
678 677
679 fn = "blocked_accounts.csv";
680 if ((f = fopen(fn, "r")) != NULL) { 678 if ((f = fopen(fn, "r")) != NULL) {
681 snac_log(user, xs_fmt("Importing from %s...", fn)); 679 snac_log(user, xs_fmt("Importing from %s...", fn));
682 680
683 while (!feof(f)) { 681 while (!feof(f)) {
684 xs *l = xs_strip_i(xs_readline(f)); 682 xs *l = xs_strip_i(xs_readline(f));
685 683
686 if (*l) { 684 if (*l && strchr(l, '@') != NULL) {
687 xs *url = NULL; 685 xs *url = NULL;
688 xs *uid = NULL; 686 xs *uid = NULL;
689 687
@@ -704,8 +702,14 @@ void import_csv(snac *user)
704 } 702 }
705 else 703 else
706 snac_log(user, xs_fmt("Cannot open file %s", fn)); 704 snac_log(user, xs_fmt("Cannot open file %s", fn));
705}
706
707
708void import_following_accounts_csv(snac *user, const char *fn)
709/* imports a Mastodon CSV file of accounts to follow */
710{
711 FILE *f;
707 712
708 fn = "following_accounts.csv";
709 if ((f = fopen(fn, "r")) != NULL) { 713 if ((f = fopen(fn, "r")) != NULL) {
710 snac_log(user, xs_fmt("Importing from %s...", fn)); 714 snac_log(user, xs_fmt("Importing from %s...", fn));
711 715
@@ -757,8 +761,14 @@ void import_csv(snac *user)
757 } 761 }
758 else 762 else
759 snac_log(user, xs_fmt("Cannot open file %s", fn)); 763 snac_log(user, xs_fmt("Cannot open file %s", fn));
764}
765
766
767void import_list_csv(snac *user, const char *fn)
768/* imports a Mastodon CSV file list */
769{
770 FILE *f;
760 771
761 fn = "lists.csv";
762 if ((f = fopen(fn, "r")) != NULL) { 772 if ((f = fopen(fn, "r")) != NULL) {
763 snac_log(user, xs_fmt("Importing from %s...", fn)); 773 snac_log(user, xs_fmt("Importing from %s...", fn));
764 774
@@ -782,6 +792,21 @@ void import_csv(snac *user)
782 792
783 list_content(user, list_id, actor_md5, 1); 793 list_content(user, list_id, actor_md5, 1);
784 snac_log(user, xs_fmt("Added %s to list %s", url, lname)); 794 snac_log(user, xs_fmt("Added %s to list %s", url, lname));
795
796 if (!following_check(user, url)) {
797 xs *msg = msg_follow(user, url);
798
799 if (msg == NULL) {
800 snac_log(user, xs_fmt("Cannot follow %s -- server down?", acct));
801 continue;
802 }
803
804 following_add(user, url, msg);
805
806 enqueue_output_by_actor(user, msg, url, 0);
807
808 snac_log(user, xs_fmt("Following %s", url));
809 }
785 } 810 }
786 else 811 else
787 snac_log(user, xs_fmt("Webfinger error while adding %s to list %s", acct, lname)); 812 snac_log(user, xs_fmt("Webfinger error while adding %s to list %s", acct, lname));
@@ -793,6 +818,20 @@ void import_csv(snac *user)
793 } 818 }
794 else 819 else
795 snac_log(user, xs_fmt("Cannot open file %s", fn)); 820 snac_log(user, xs_fmt("Cannot open file %s", fn));
821}
822
823
824void import_csv(snac *user)
825/* import CSV files from Mastodon */
826{
827 FILE *f;
828 const char *fn;
829
830 import_blocked_accounts_csv(user, "blocked_accounts.csv");
831
832 import_following_accounts_csv(user, "following_accounts.csv");
833
834 import_list_csv(user, "lists.csv");
796 835
797 fn = "bookmarks.csv"; 836 fn = "bookmarks.csv";
798 if ((f = fopen(fn, "r")) != NULL) { 837 if ((f = fopen(fn, "r")) != NULL) {
diff --git a/xs_unicode.h b/xs_unicode.h
index 9663190..cfcd8ff 100644
--- a/xs_unicode.h
+++ b/xs_unicode.h
@@ -21,6 +21,7 @@
21 int xs_unicode_nfd(unsigned int cpoint, unsigned int *base, unsigned int *diac); 21 int xs_unicode_nfd(unsigned int cpoint, unsigned int *base, unsigned int *diac);
22 int xs_unicode_nfc(unsigned int base, unsigned int diac, unsigned int *cpoint); 22 int xs_unicode_nfc(unsigned int base, unsigned int diac, unsigned int *cpoint);
23 int xs_unicode_is_alpha(unsigned int cpoint); 23 int xs_unicode_is_alpha(unsigned int cpoint);
24 int xs_unicode_is_right_to_left(unsigned int cpoint);
24 25
25#ifdef _XS_H 26#ifdef _XS_H
26 xs_str *xs_utf8_insert(xs_str *str, unsigned int cpoint, int *offset); 27 xs_str *xs_utf8_insert(xs_str *str, unsigned int cpoint, int *offset);
@@ -350,6 +351,29 @@ int xs_unicode_is_alpha(unsigned int cpoint)
350} 351}
351 352
352 353
354int xs_unicode_is_right_to_left(unsigned int cpoint)
355/* checks if a codepoint is a right-to-left letter */
356{
357 int b = 0;
358 int t = xs_countof(xs_unicode_right_to_left_table) / 2 - 1;
359
360 while (t >= b) {
361 int n = (b + t) / 2;
362 unsigned int *p = &xs_unicode_right_to_left_table[n * 2];
363
364 if (cpoint < p[0])
365 t = n - 1;
366 else
367 if (cpoint > p[1])
368 b = n + 1;
369 else
370 return 1;
371 }
372
373 return 0;
374}
375
376
353#ifdef _XS_H 377#ifdef _XS_H
354 378
355xs_str *xs_utf8_to_upper(const char *str) 379xs_str *xs_utf8_to_upper(const char *str)
diff --git a/xs_unicode_tbl.h b/xs_unicode_tbl.h
index ecd537b..ee8ce97 100644
--- a/xs_unicode_tbl.h
+++ b/xs_unicode_tbl.h
@@ -726,5 +726,20 @@ static unsigned int xs_unicode_alpha_table[] = {
726 0x1E7E0, 0x1E8C4, 0x1E900, 0x1E943, 0x1EE00, 0x1EEBB, 0x20000, 0x323AF, 726 0x1E7E0, 0x1E8C4, 0x1E900, 0x1E943, 0x1EE00, 0x1EEBB, 0x20000, 0x323AF,
727}; 727};
728 728
729static unsigned int xs_unicode_right_to_left_table[] = {
730 0x05BE, 0x05BE, 0x05C0, 0x05C0, 0x05C3, 0x05C3, 0x05C6, 0x05C6,
731 0x05D0, 0x05F4, 0x0608, 0x0608, 0x060B, 0x060B, 0x060D, 0x060D,
732 0x061B, 0x064A, 0x066D, 0x066F, 0x0671, 0x06D5, 0x06E5, 0x06E6,
733 0x06EE, 0x06EF, 0x06FA, 0x0710, 0x0712, 0x072F, 0x074D, 0x07A5,
734 0x07B1, 0x07EA, 0x07F4, 0x07F5, 0x07FA, 0x07FA, 0x07FE, 0x0815,
735 0x081A, 0x081A, 0x0824, 0x0824, 0x0828, 0x0828, 0x0830, 0x0858,
736 0x085E, 0x088E, 0x08A0, 0x08C9, 0x200F, 0x200F, 0xFB1D, 0xFB1D,
737 0xFB1F, 0xFB28, 0xFB2A, 0xFD3D, 0xFD50, 0xFDC7, 0xFDF0, 0xFDFC,
738 0xFE70, 0xFEFC, 0x10800, 0x1091B, 0x10920, 0x10A00, 0x10A10, 0x10A35,
739 0x10A40, 0x10AE4, 0x10AEB, 0x10B35, 0x10B40, 0x10D23, 0x10E80, 0x10EA9,
740 0x10EAD, 0x10EB1, 0x10F00, 0x10F45, 0x10F51, 0x10F81, 0x10F86, 0x10FF6,
741 0x1E800, 0x1E8CF, 0x1E900, 0x1E943, 0x1E94B, 0x1EEBB,
742};
743
729#endif /* _XS_UNICODE_TBL_H */ 744#endif /* _XS_UNICODE_TBL_H */
730 745
diff --git a/xs_url.h b/xs_url.h
index cd540fa..ac43585 100644
--- a/xs_url.h
+++ b/xs_url.h
@@ -106,13 +106,13 @@ xs_dict *xs_multipart_form_data(const char *payload, int p_size, const char *hea
106 if (xs_list_len(l1) != 2) 106 if (xs_list_len(l1) != 2)
107 return NULL; 107 return NULL;
108 108
109 boundary = xs_dup(xs_list_get(l1, 1)); 109 xs *t_boundary = xs_dup(xs_list_get(l1, 1));
110 110
111 /* Tokodon sends the boundary header with double quotes surrounded */ 111 /* Tokodon sends the boundary header with double quotes surrounded */
112 if (xs_between("\"", boundary, "\"") != 0) 112 if (xs_between("\"", t_boundary, "\"") != 0)
113 boundary = xs_strip_chars_i(boundary, "\""); 113 t_boundary = xs_strip_chars_i(t_boundary, "\"");
114 114
115 boundary = xs_fmt("--%s", boundary); 115 boundary = xs_fmt("--%s", t_boundary);
116 } 116 }
117 117
118 bsz = strlen(boundary); 118 bsz = strlen(boundary);
diff --git a/xs_version.h b/xs_version.h
index 84f7c5b..770366a 100644
--- a/xs_version.h
+++ b/xs_version.h
@@ -1 +1 @@
/* 35997d2dbc505320a62d3130daa95f638be8bb26 2024-11-05T16:47:36+01:00 */ /* 297f71e198be7819213e9122e1e78c3b963111bc 2024-11-24T18:48:42+01:00 */