summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--Makefile16
-rw-r--r--Makefile.NetBSD16
-rw-r--r--RELEASE_NOTES.md46
-rw-r--r--TODO.md4
-rw-r--r--activitypub.c39
-rw-r--r--data.c70
-rw-r--r--doc/snac.199
-rw-r--r--doc/snac.810
-rw-r--r--doc/style.css1
-rwxr-xr-xexamples/auto_follower_webhook.py55
-rw-r--r--format.c32
-rw-r--r--html.c224
-rw-r--r--httpd.c120
-rw-r--r--main.c166
-rw-r--r--mastoapi.c147
-rw-r--r--po/ru.po8
-rw-r--r--po/uk.po768
-rw-r--r--rss.c274
-rw-r--r--sandbox.c14
-rw-r--r--snac.h18
-rw-r--r--utils.c76
-rw-r--r--webfinger.c4
-rw-r--r--xs.h15
-rw-r--r--xs_fcgi.h4
-rw-r--r--xs_httpd.h12
-rw-r--r--xs_json.h163
-rw-r--r--xs_version.h2
-rw-r--r--xs_webmention.h42
28 files changed, 2079 insertions, 366 deletions
diff --git a/Makefile b/Makefile
index 46fdb09..5de2882 100644
--- a/Makefile
+++ b/Makefile
@@ -5,7 +5,7 @@ CFLAGS?=-g -Wall -Wextra -pedantic
5all: snac 5all: snac
6 6
7snac: snac.o main.o sandbox.o data.o http.o httpd.o webfinger.o \ 7snac: snac.o main.o sandbox.o data.o http.o httpd.o webfinger.o \
8 activitypub.o html.o utils.o format.o upgrade.o mastoapi.o 8 activitypub.o html.o utils.o format.o upgrade.o mastoapi.o rss.o
9 $(CC) $(CFLAGS) -L$(PREFIX)/lib *.o -lcurl -lcrypto $(LDFLAGS) -pthread -o $@ 9 $(CC) $(CFLAGS) -L$(PREFIX)/lib *.o -lcurl -lcrypto $(LDFLAGS) -pthread -o $@
10 10
11test: tests/smtp 11test: tests/smtp
@@ -48,25 +48,27 @@ update-po:
48 48
49activitypub.o: activitypub.c xs.h xs_json.h xs_curl.h xs_mime.h \ 49activitypub.o: activitypub.c xs.h xs_json.h xs_curl.h xs_mime.h \
50 xs_openssl.h xs_regex.h xs_time.h xs_set.h xs_match.h xs_unicode.h \ 50 xs_openssl.h xs_regex.h xs_time.h xs_set.h xs_match.h xs_unicode.h \
51 snac.h http_codes.h 51 xs_webmention.h snac.h http_codes.h
52data.o: data.c xs.h xs_hex.h xs_io.h xs_json.h xs_openssl.h xs_glob.h \ 52data.o: data.c xs.h xs_hex.h xs_io.h xs_json.h xs_openssl.h xs_glob.h \
53 xs_set.h xs_time.h xs_regex.h xs_match.h xs_unicode.h xs_random.h \ 53 xs_set.h xs_time.h xs_regex.h xs_match.h xs_unicode.h xs_random.h \
54 xs_po.h snac.h http_codes.h 54 xs_po.h snac.h http_codes.h
55format.o: format.c xs.h xs_regex.h xs_mime.h xs_html.h xs_json.h \ 55format.o: format.c xs.h xs_regex.h xs_mime.h xs_html.h xs_json.h \
56 xs_time.h xs_match.h snac.h http_codes.h 56 xs_time.h xs_match.h xs_unicode.h snac.h http_codes.h
57html.o: html.c xs.h xs_io.h xs_json.h xs_regex.h xs_set.h xs_openssl.h \ 57html.o: html.c xs.h xs_io.h xs_json.h xs_regex.h xs_set.h xs_openssl.h \
58 xs_time.h xs_mime.h xs_match.h xs_html.h xs_curl.h xs_unicode.h xs_url.h \ 58 xs_time.h xs_mime.h xs_match.h xs_html.h xs_curl.h xs_unicode.h xs_url.h \
59 xs_random.h snac.h http_codes.h 59 xs_random.h snac.h http_codes.h
60http.o: http.c xs.h xs_io.h xs_openssl.h xs_curl.h xs_time.h xs_json.h \ 60http.o: http.c xs.h xs_io.h xs_openssl.h xs_curl.h xs_time.h xs_json.h \
61 snac.h http_codes.h 61 snac.h http_codes.h
62httpd.o: httpd.c xs.h xs_io.h xs_json.h xs_socket.h xs_unix_socket.h \ 62httpd.o: httpd.c xs.h xs_io.h xs_json.h xs_socket.h xs_unix_socket.h \
63 xs_httpd.h xs_mime.h xs_time.h xs_openssl.h xs_fcgi.h xs_html.h snac.h \ 63 xs_httpd.h xs_mime.h xs_time.h xs_openssl.h xs_fcgi.h xs_html.h \
64 http_codes.h 64 xs_webmention.h snac.h http_codes.h
65main.o: main.c xs.h xs_io.h xs_json.h xs_time.h xs_openssl.h xs_match.h \ 65main.o: main.c xs.h xs_io.h xs_json.h xs_time.h xs_openssl.h xs_match.h \
66 snac.h http_codes.h 66 xs_random.h snac.h http_codes.h
67mastoapi.o: mastoapi.c xs.h xs_hex.h xs_openssl.h xs_json.h xs_io.h \ 67mastoapi.o: mastoapi.c xs.h xs_hex.h xs_openssl.h xs_json.h xs_io.h \
68 xs_time.h xs_glob.h xs_set.h xs_random.h xs_url.h xs_mime.h xs_match.h \ 68 xs_time.h xs_glob.h xs_set.h xs_random.h xs_url.h xs_mime.h xs_match.h \
69 snac.h http_codes.h 69 xs_unicode.h snac.h http_codes.h
70rss.o: rss.c xs.h xs_html.h xs_regex.h xs_time.h xs_match.h xs_curl.h \
71 xs_openssl.h xs_json.h snac.h http_codes.h
70sandbox.o: sandbox.c xs.h snac.h http_codes.h 72sandbox.o: sandbox.c xs.h snac.h http_codes.h
71snac.o: snac.c xs.h xs_hex.h xs_io.h xs_unicode_tbl.h xs_unicode.h \ 73snac.o: snac.c xs.h xs_hex.h xs_io.h xs_unicode_tbl.h xs_unicode.h \
72 xs_json.h xs_curl.h xs_openssl.h xs_socket.h xs_unix_socket.h xs_url.h \ 74 xs_json.h xs_curl.h xs_openssl.h xs_socket.h xs_unix_socket.h xs_url.h \
diff --git a/Makefile.NetBSD b/Makefile.NetBSD
index ecf8205..ac5b052 100644
--- a/Makefile.NetBSD
+++ b/Makefile.NetBSD
@@ -6,7 +6,7 @@ LDFLAGS=-lrt
6all: snac 6all: snac
7 7
8snac: snac.o main.o sandbox.o data.o http.o httpd.o webfinger.o \ 8snac: snac.o main.o sandbox.o data.o http.o httpd.o webfinger.o \
9 activitypub.o html.o utils.o format.o upgrade.o mastoapi.o 9 activitypub.o html.o utils.o format.o upgrade.o mastoapi.o rss.o
10 $(CC) $(CFLAGS) -L/usr/pkg/lib *.o -lcurl -lcrypto -pthread $(LDFLAGS) -Wl,-rpath,/usr/lib -Wl,-rpath,/usr/pkg/lib -o $@ 10 $(CC) $(CFLAGS) -L/usr/pkg/lib *.o -lcurl -lcrypto -pthread $(LDFLAGS) -Wl,-rpath,/usr/lib -Wl,-rpath,/usr/pkg/lib -o $@
11 11
12 12
@@ -37,25 +37,27 @@ uninstall:
37 37
38activitypub.o: activitypub.c xs.h xs_json.h xs_curl.h xs_mime.h \ 38activitypub.o: activitypub.c xs.h xs_json.h xs_curl.h xs_mime.h \
39 xs_openssl.h xs_regex.h xs_time.h xs_set.h xs_match.h xs_unicode.h \ 39 xs_openssl.h xs_regex.h xs_time.h xs_set.h xs_match.h xs_unicode.h \
40 snac.h http_codes.h 40 xs_webmention.h snac.h http_codes.h
41data.o: data.c xs.h xs_hex.h xs_io.h xs_json.h xs_openssl.h xs_glob.h \ 41data.o: data.c xs.h xs_hex.h xs_io.h xs_json.h xs_openssl.h xs_glob.h \
42 xs_set.h xs_time.h xs_regex.h xs_match.h xs_unicode.h xs_random.h \ 42 xs_set.h xs_time.h xs_regex.h xs_match.h xs_unicode.h xs_random.h \
43 xs_po.h snac.h http_codes.h 43 xs_po.h snac.h http_codes.h
44format.o: format.c xs.h xs_regex.h xs_mime.h xs_html.h xs_json.h \ 44format.o: format.c xs.h xs_regex.h xs_mime.h xs_html.h xs_json.h \
45 xs_time.h xs_match.h snac.h http_codes.h 45 xs_time.h xs_match.h xs_unicode.h snac.h http_codes.h
46html.o: html.c xs.h xs_io.h xs_json.h xs_regex.h xs_set.h xs_openssl.h \ 46html.o: html.c xs.h xs_io.h xs_json.h xs_regex.h xs_set.h xs_openssl.h \
47 xs_time.h xs_mime.h xs_match.h xs_html.h xs_curl.h xs_unicode.h xs_url.h \ 47 xs_time.h xs_mime.h xs_match.h xs_html.h xs_curl.h xs_unicode.h xs_url.h \
48 xs_random.h snac.h http_codes.h 48 xs_random.h snac.h http_codes.h
49http.o: http.c xs.h xs_io.h xs_openssl.h xs_curl.h xs_time.h xs_json.h \ 49http.o: http.c xs.h xs_io.h xs_openssl.h xs_curl.h xs_time.h xs_json.h \
50 snac.h http_codes.h 50 snac.h http_codes.h
51httpd.o: httpd.c xs.h xs_io.h xs_json.h xs_socket.h xs_unix_socket.h \ 51httpd.o: httpd.c xs.h xs_io.h xs_json.h xs_socket.h xs_unix_socket.h \
52 xs_httpd.h xs_mime.h xs_time.h xs_openssl.h xs_fcgi.h xs_html.h snac.h \ 52 xs_httpd.h xs_mime.h xs_time.h xs_openssl.h xs_fcgi.h xs_html.h \
53 http_codes.h 53 xs_webmention.h snac.h http_codes.h
54main.o: main.c xs.h xs_io.h xs_json.h xs_time.h xs_openssl.h xs_match.h \ 54main.o: main.c xs.h xs_io.h xs_json.h xs_time.h xs_openssl.h xs_match.h \
55 snac.h http_codes.h 55 xs_random.h snac.h http_codes.h
56mastoapi.o: mastoapi.c xs.h xs_hex.h xs_openssl.h xs_json.h xs_io.h \ 56mastoapi.o: mastoapi.c xs.h xs_hex.h xs_openssl.h xs_json.h xs_io.h \
57 xs_time.h xs_glob.h xs_set.h xs_random.h xs_url.h xs_mime.h xs_match.h \ 57 xs_time.h xs_glob.h xs_set.h xs_random.h xs_url.h xs_mime.h xs_match.h \
58 snac.h http_codes.h 58 xs_unicode.h snac.h http_codes.h
59rss.o: rss.c xs.h xs_html.h xs_regex.h xs_time.h xs_match.h xs_curl.h \
60 xs_openssl.h xs_json.h snac.h http_codes.h
59sandbox.o: sandbox.c xs.h snac.h http_codes.h 61sandbox.o: sandbox.c xs.h snac.h http_codes.h
60snac.o: snac.c xs.h xs_hex.h xs_io.h xs_unicode_tbl.h xs_unicode.h \ 62snac.o: snac.c xs.h xs_hex.h xs_io.h xs_unicode_tbl.h xs_unicode.h \
61 xs_json.h xs_curl.h xs_openssl.h xs_socket.h xs_unix_socket.h xs_url.h \ 63 xs_json.h xs_curl.h xs_openssl.h xs_socket.h xs_unix_socket.h xs_url.h \
diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md
index 484e425..b932cc2 100644
--- a/RELEASE_NOTES.md
+++ b/RELEASE_NOTES.md
@@ -2,11 +2,55 @@
2 2
3## UNRELEASED 3## UNRELEASED
4 4
5Included a small tweak to avoid being confused by implementations that return valid webfinger queries for non-account URLs (like i.e. the Wordpress ActivityPub plugin in some configurations).
6
7Added Ukrainian translation (contributed by wincentbalin).
8
9## 2.80
10
11Mastodon API: fixed a regression (introduced in previous version in the "boosts disappear in Tusky" fix) that interrupted timelines.
12
13## 2.79
14
15Added a server-wide Webmention hook; this way, if somebody out there (that supports Webmention) links to a user or post in this instance, a notification is sent (this is the complementary of what was implemented in version 2.76).
16
17Fixed regression while sending email via pipe on OpenBSD.
18
19Fixed Markdown parsing when the URL has parenthesis.
20
21Always show the 'pending follow confirmations' section if there are any (even if the toggle is off).
22
23If a metadata value is an account handler, it's also tried to be validated (rel="me" links).
24
25Another search by URL tweak (this time for Pixelfed links).
26
27Mastodon API: fixed a bug that made some boosts disappear after being shown in apps like Tusky, added followed hashtags maintenance, other minor changes.
28
29Renamed command-line actions `create_list` to `list_create` and `delete_list` to `list_remove`.
30
31The default favicon URL can be changed from the server configuration.
32
33New command-line option `export_posts`, to export all posts by a user in a JSON format compatible with the one generated by Mastodon.
34
35The command-line options to send notes also allow an optional `-r` argument, to set the URL of a Fediverse post this note is a reply to.
36
37## 2.78
38
39Hashtag following also allow URLs to RSS feeds of ActivityPub objects (like e.g. https://mastodon.social/tags/ThankYouTuesday).
40
41Users can now configure a webhook to receive an HTTP POST for every notification. This can be useful for implementing bots that react to activities, like autorepliers, chatbots or interactive textual games (see `snac(1)` for more information).
42
5The number of pending follow confirmations is shown next to the "people" link. 43The number of pending follow confirmations is shown next to the "people" link.
6 44
7Faster performance metrics (contributed by dandelions). 45Faster performance metrics (contributed by dandelions).
8 46
9Mastodon API: added follow confirmation endpoints. 47Improved lowercasing in hashtags (contributed by postscriptum).
48
49A search-by-url tweak for implementations that return 200 for invalid webfinger queries (e.g. piefed).
50
51Mastodon API: added follow confirmation endpoints, fixed collisions in attachment file names.
52
53Fixed potential crashes in attachment uploads.
10 54
11## 2.77 "Ugly Links Everywhere" 55## 2.77 "Ugly Links Everywhere"
12 56
diff --git a/TODO.md b/TODO.md
index 8b87d37..4ed3692 100644
--- a/TODO.md
+++ b/TODO.md
@@ -14,6 +14,8 @@ Important: deleting a follower should do more that just delete the object, see h
14 14
15## Wishlist 15## Wishlist
16 16
17The local purge should generate `Delete` activities for local posts.
18
17Add account reporting. 19Add account reporting.
18 20
19Add a list option to hide member posts from the main timeline, see https://codeberg.org/grunfink/snac2/issues/383 21Add a list option to hide member posts from the main timeline, see https://codeberg.org/grunfink/snac2/issues/383
@@ -22,6 +24,8 @@ The instance timeline should also show boosts from users.
22 24
23Mastoapi: implement /v1/conversations. 25Mastoapi: implement /v1/conversations.
24 26
27Track "BadgeFed - ActivityPub Badges" https://github.com/tryvocalcat/badgefed
28
25Track 'Event' data types standardization; how to add plan-to-attend and similar activities (more info: https://event-federation.eu/). Friendica interacts with events via activities `Accept` (will go), `TentativeAccept` (will try to go) or `Reject` (cannot go) (`object` field as id, not object). `Undo` for any of these activities cancel (`object` as an object, not id). 29Track 'Event' data types standardization; how to add plan-to-attend and similar activities (more info: https://event-federation.eu/). Friendica interacts with events via activities `Accept` (will go), `TentativeAccept` (will try to go) or `Reject` (cannot go) (`object` field as id, not object). `Undo` for any of these activities cancel (`object` as an object, not id).
26 30
27Implement "FEP-3b86: Activity Intents" https://codeberg.org/fediverse/fep/src/branch/main/fep/3b86/fep-3b86.md 31Implement "FEP-3b86: Activity Intents" https://codeberg.org/fediverse/fep/src/branch/main/fep/3b86/fep-3b86.md
diff --git a/activitypub.c b/activitypub.c
index 120b4a1..100db67 100644
--- a/activitypub.c
+++ b/activitypub.c
@@ -903,7 +903,7 @@ xs_str *process_tags(snac *snac, const char *content, xs_list **tag)
903 if (*v == '#') { 903 if (*v == '#') {
904 /* hashtag */ 904 /* hashtag */
905 xs *d = xs_dict_new(); 905 xs *d = xs_dict_new();
906 xs *n = xs_tolower_i(xs_dup(v)); 906 xs *n = xs_utf8_to_lower(v);
907 xs *h = xs_fmt("%s?t=%s", srv_baseurl, n + 1); 907 xs *h = xs_fmt("%s?t=%s", srv_baseurl, n + 1);
908 xs *l = xs_fmt("<a href=\"%s\" class=\"mention hashtag\" rel=\"tag\">%s</a>", h, v); 908 xs *l = xs_fmt("<a href=\"%s\" class=\"mention hashtag\" rel=\"tag\">%s</a>", h, v);
909 909
@@ -2766,6 +2766,39 @@ void process_user_queue_item(snac *user, xs_dict *q_item)
2766 } 2766 }
2767 } 2767 }
2768 else 2768 else
2769 if (strcmp(type, "notify_webhook") == 0) {
2770 const char *webhook = xs_dict_get(user->config, "notify_webhook");
2771
2772 if (xs_is_string(webhook) && xs_match(webhook, "https://*|http://*")) { /** **/
2773 const xs_dict *msg = xs_dict_get(q_item, "message");
2774 int retries = xs_number_get(xs_dict_get(q_item, "retries"));
2775
2776 xs *hdrs = xs_dict_new();
2777
2778 hdrs = xs_dict_set(hdrs, "content-type", "application/json");
2779 hdrs = xs_dict_set(hdrs, "user-agent", USER_AGENT);
2780
2781 xs *body = xs_json_dumps(msg, 4);
2782
2783 int status;
2784 xs *rsp = xs_http_request("POST", webhook, hdrs, body, strlen(body), &status, NULL, NULL, 0);
2785
2786 snac_debug(user, 0, xs_fmt("webhook post %s %d", webhook, status));
2787
2788 if (!valid_status(status)) {
2789 retries++;
2790
2791 if (retries > queue_retry_max)
2792 snac_debug(user, 0, xs_fmt("webhook post giving up %s", webhook));
2793 else {
2794 snac_debug(user, 0, xs_fmt("webhook post requeue %s %d", webhook, retries));
2795
2796 enqueue_notify_webhook(user, msg, retries);
2797 }
2798 }
2799 }
2800 }
2801 else
2769 snac_log(user, xs_fmt("unexpected user q_item type '%s'", type)); 2802 snac_log(user, xs_fmt("unexpected user q_item type '%s'", type));
2770} 2803}
2771 2804
@@ -3050,6 +3083,10 @@ void process_queue_item(xs_dict *q_item)
3050 } 3083 }
3051 } 3084 }
3052 else 3085 else
3086 if (strcmp(type, "rss_hashtag_poll") == 0) {
3087 rss_poll_hashtags();
3088 }
3089 else
3053 srv_log(xs_fmt("unexpected q_item type '%s'", type)); 3090 srv_log(xs_fmt("unexpected q_item type '%s'", type));
3054} 3091}
3055 3092
diff --git a/data.c b/data.c
index f9d27f9..f26da49 100644
--- a/data.c
+++ b/data.c
@@ -333,6 +333,7 @@ int user_open_by_md5(snac *snac, const char *md5)
333 return 0; 333 return 0;
334} 334}
335 335
336
336int user_persist(snac *snac, int publish) 337int user_persist(snac *snac, int publish)
337/* store user */ 338/* store user */
338{ 339{
@@ -348,7 +349,7 @@ int user_persist(snac *snac, int publish)
348 349
349 if (old != NULL) { 350 if (old != NULL) {
350 int nw = 0; 351 int nw = 0;
351 const char *fields[] = { "header", "avatar", "name", "bio", 352 const char *fields[] = { "header", "avatar", "name", "bio", "alias", "alias_raw",
352 "metadata", "latitude", "longitude", NULL }; 353 "metadata", "latitude", "longitude", NULL };
353 354
354 for (int n = 0; fields[n]; n++) { 355 for (int n = 0; fields[n]; n++) {
@@ -1391,7 +1392,7 @@ xs_str *timeline_fn_by_md5(snac *snac, const char *md5)
1391} 1392}
1392 1393
1393 1394
1394int timeline_here(snac *snac, const char *md5) 1395int timeline_here_by_md5(snac *snac, const char *md5)
1395/* checks if an object is in the user cache */ 1396/* checks if an object is in the user cache */
1396{ 1397{
1397 xs *fn = timeline_fn_by_md5(snac, md5); 1398 xs *fn = timeline_fn_by_md5(snac, md5);
@@ -1400,6 +1401,14 @@ int timeline_here(snac *snac, const char *md5)
1400} 1401}
1401 1402
1402 1403
1404int timeline_here(snac *user, const char *id)
1405{
1406 xs *md5 = xs_md5_hex(id, strlen(id));
1407
1408 return timeline_here_by_md5(user, md5);
1409}
1410
1411
1403int timeline_get_by_md5(snac *snac, const char *md5, xs_dict **msg) 1412int timeline_get_by_md5(snac *snac, const char *md5, xs_dict **msg)
1404/* gets a message from the timeline */ 1413/* gets a message from the timeline */
1405{ 1414{
@@ -1515,7 +1524,7 @@ xs_list *timeline_top_level(snac *snac, const xs_list *list)
1515 break; 1524 break;
1516 1525
1517 /* well, there is a parent... but is it here? */ 1526 /* well, there is a parent... but is it here? */
1518 if (!timeline_here(snac, line2)) 1527 if (!timeline_here_by_md5(snac, line2))
1519 break; 1528 break;
1520 1529
1521 /* it's here! try again with its own parent */ 1530 /* it's here! try again with its own parent */
@@ -2217,7 +2226,7 @@ void tag_index(const char *id, const xs_dict *obj)
2217 if (*name == '\0') 2226 if (*name == '\0')
2218 continue; 2227 continue;
2219 2228
2220 name = xs_tolower_i((xs_str *)name); 2229 name = xs_utf8_to_lower((xs_str *)name);
2221 2230
2222 xs *md5_tag = xs_md5_hex(name, strlen(name)); 2231 xs *md5_tag = xs_md5_hex(name, strlen(name));
2223 xs *tag_dir = xs_fmt("%s/%c%c", g_tag_dir, md5_tag[0], md5_tag[1]); 2232 xs *tag_dir = xs_fmt("%s/%c%c", g_tag_dir, md5_tag[0], md5_tag[1]);
@@ -2235,7 +2244,7 @@ void tag_index(const char *id, const xs_dict *obj)
2235 fclose(f); 2244 fclose(f);
2236 } 2245 }
2237 2246
2238 srv_debug(0, xs_fmt("tagged %s #%s (#%s)", id, name, md5_tag)); 2247 srv_debug(1, xs_fmt("tagged %s #%s (#%s)", id, name, md5_tag));
2239 } 2248 }
2240 } 2249 }
2241 } 2250 }
@@ -2247,7 +2256,7 @@ xs_str *tag_fn(const char *tag)
2247 if (*tag == '#') 2256 if (*tag == '#')
2248 tag++; 2257 tag++;
2249 2258
2250 xs *lw_tag = xs_tolower_i(xs_dup(tag)); 2259 xs *lw_tag = xs_utf8_to_lower(tag);
2251 xs *md5 = xs_md5_hex(lw_tag, strlen(lw_tag)); 2260 xs *md5 = xs_md5_hex(lw_tag, strlen(lw_tag));
2252 2261
2253 return xs_fmt("%s/tag/%c%c/%s.idx", srv_basedir, md5[0], md5[1], md5); 2262 return xs_fmt("%s/tag/%c%c/%s.idx", srv_basedir, md5[0], md5[1], md5);
@@ -2832,9 +2841,9 @@ int content_match(const char *file, const xs_dict *msg)
2832 srv_debug(1, xs_fmt("content_match: loading regexes from %s", fn)); 2841 srv_debug(1, xs_fmt("content_match: loading regexes from %s", fn));
2833 2842
2834 /* massage content (strip HTML tags, etc.) */ 2843 /* massage content (strip HTML tags, etc.) */
2835 xs *c = xs_regex_replace(v, "<[^>]+>", " "); 2844 xs *c1 = xs_regex_replace(v, "<[^>]+>", " ");
2836 c = xs_regex_replace_i(c, " {2,}", " "); 2845 c1 = xs_regex_replace_i(c1, " {2,}", " ");
2837 c = xs_tolower_i(c); 2846 xs *c = xs_utf8_to_lower(c1);
2838 2847
2839 while (!r && !feof(f)) { 2848 while (!r && !feof(f)) {
2840 xs *rx = xs_strip_i(xs_readline(f)); 2849 xs *rx = xs_strip_i(xs_readline(f));
@@ -3158,6 +3167,9 @@ void notify_add(snac *snac, const char *type, const char *utype,
3158 3167
3159 pthread_mutex_unlock(&data_mutex); 3168 pthread_mutex_unlock(&data_mutex);
3160 } 3169 }
3170
3171 if (!xs_is_true(xs_dict_get(srv_config, "disable_notify_webhook")))
3172 enqueue_notify_webhook(snac, noti, 0);
3161} 3173}
3162 3174
3163 3175
@@ -3513,6 +3525,46 @@ void enqueue_webmention(const xs_dict *msg)
3513} 3525}
3514 3526
3515 3527
3528void enqueue_notify_webhook(snac *user, const xs_dict *noti, int retries)
3529/* enqueues a notification webhook */
3530{
3531 const char *webhook = xs_dict_get(user->config, "notify_webhook");
3532
3533 if (xs_is_string(webhook) && xs_match(webhook, "https://*|http://*")) { /** **/
3534 xs *msg = xs_dup(noti);
3535
3536 /* add more data */
3537 msg = xs_dict_set(msg, "target", user->actor);
3538 msg = xs_dict_set(msg, "uid", user->uid);
3539 msg = xs_dict_set(msg, "basedir", srv_basedir);
3540 msg = xs_dict_set(msg, "baseurl", srv_baseurl);
3541
3542 xs *actor_obj = NULL;
3543
3544 if (valid_status(object_get(xs_dict_get(noti, "actor"), &actor_obj)) && actor_obj)
3545 msg = xs_dict_set(msg, "account", actor_obj);
3546
3547 /* if this post is a reply, also add the inReplyTo object */
3548 const char *in_reply_to = xs_dict_get_path(msg, "msg.object.inReplyTo");
3549
3550 if (xs_is_string(in_reply_to)) {
3551 xs *irt_obj = NULL;
3552
3553 if (valid_status(object_get(in_reply_to, &irt_obj)))
3554 msg = xs_dict_set(msg, "reply", irt_obj);
3555 }
3556
3557 xs *qmsg = _new_qmsg("notify_webhook", msg, retries);
3558 const char *ntid = xs_dict_get(qmsg, "ntid");
3559 xs *fn = xs_fmt("%s/queue/%s.json", user->basedir, ntid);
3560
3561 qmsg = _enqueue_put(fn, qmsg);
3562
3563 snac_debug(user, 1, xs_fmt("notify_webhook"));
3564 }
3565}
3566
3567
3516int was_question_voted(snac *user, const char *id) 3568int was_question_voted(snac *user, const char *id)
3517/* returns true if the user voted in this poll */ 3569/* returns true if the user voted in this poll */
3518{ 3570{
diff --git a/doc/snac.1 b/doc/snac.1
index 92e30b7..e905610 100644
--- a/doc/snac.1
+++ b/doc/snac.1
@@ -24,9 +24,9 @@ For file and data formats, see
24.Ss Web Interface 24.Ss Web Interface
25The web interface provided by 25The web interface provided by
26.Nm 26.Nm
27is split in two data streams: the public timeline and the 27is split in three data streams: the public timeline, the private
28private timeline. There are no other feeds like the server-scoped 28timeline and the instance timeline. There are no other feeds like
29or the federated firehoses provided by other similar ActivityPub 29the federated firehoses provided by other similar ActivityPub
30implementations like Mastodon or Pleroma. 30implementations like Mastodon or Pleroma.
31.Pp 31.Pp
32The public timeline, also called the local timeline, is what an 32The public timeline, also called the local timeline, is what an
@@ -67,9 +67,23 @@ sent to those people you mention in the post body.
67If you fill this optional text field with the URL of another one's 67If you fill this optional text field with the URL of another one's
68post, your text will be considered as a reply to it, not a 68post, your text will be considered as a reply to it, not a
69standalone one. 69standalone one.
70.It Draft
71If you set this checkbox, your text will not be sent when you
72push the Post button, but stored for later modification in
73the "Drafts" section.
74.It Scheduled post...
75This dropdown menu allows setting a date and time for the
76post publication.
77.It Attachments...
78This dropdown menu allows uploading media attachments (images,
79audio, video, etc.) to your post.
80.It Poll...
81this dropdown menu gives access to the voting options, that
82will make your post a poll. You can set the options to be
83voted, if it's a multiple choice poll and the due date.
70.El 84.El
71.Pp 85.Pp
72More options are hidden under a toggle control. They are the 86More options are hidden under dropdown menus. They are the
73following: 87following:
74.Bl -tag -offset indent 88.Bl -tag -offset indent
75.It Follow (by URL or user@host) 89.It Follow (by URL or user@host)
@@ -85,7 +99,9 @@ liked.
85This option opens the user setup dialog. 99This option opens the user setup dialog.
86.It Followed hashtags... 100.It Followed hashtags...
87Enter here the list of hashtags you want to follow, one 101Enter here the list of hashtags you want to follow, one
88per line, with or without the # symbol. 102per line, with or without the # symbol. Since version 2.78,
103URLs to RSS feeds of ActivityPub objects are also allowed
104(like e.g. https://mastodon.social/tags/bloomscrolling).
89.It Blocked hashtags... 105.It Blocked hashtags...
90Enter here the list of hashtags you want to block, one 106Enter here the list of hashtags you want to block, one
91per line, with or without the # symbol. 107per line, with or without the # symbol.
@@ -125,6 +141,10 @@ standard ntfy.sh server), fill the two provided
125fields (ntfy server/topic and, if protected, the token). 141fields (ntfy server/topic and, if protected, the token).
126You need to refer to the https://ntfy.sh web site for 142You need to refer to the https://ntfy.sh web site for
127more information on this process. 143more information on this process.
144.It Notify webhook
145If this is set to an URL, an HTTP POST will be sent to it
146whenever a new notification happens (see the 'Webhook for
147notifications' section below for more information).
128.It Maximum days to keep posts 148.It Maximum days to keep posts
129This numeric value specifies the number of days to pass before 149This numeric value specifies the number of days to pass before
130posts (yours and others') will be purged. This value overrides 150posts (yours and others') will be purged. This value overrides
@@ -264,7 +284,7 @@ Requests an object and dumps it to stdout. This is a very low
264level command that is not very useful to you. 284level command that is not very useful to you.
265.It Cm announce Ar basedir Ar uid Ar url 285.It Cm announce Ar basedir Ar uid Ar url
266Announces (boosts) a post via its URL. 286Announces (boosts) a post via its URL.
267.It Cm note Ar basedir Ar uid Ar text Op file file ... 287.It Cm note Ar basedir Ar uid Ar text Op file file ... Op -r inReplyTo
268Enqueues a Create + Note message to all followers. If the 288Enqueues a Create + Note message to all followers. If the
269.Ar text 289.Ar text
270argument is -e, the external editor defined by the EDITOR 290argument is -e, the external editor defined by the EDITOR
@@ -272,10 +292,11 @@ environment variable will be invoked to prepare a message; if
272it's - (a lonely hyphen), the post content will be read from stdin. 292it's - (a lonely hyphen), the post content will be read from stdin.
273The rest of command line arguments are treated as media files to be 293The rest of command line arguments are treated as media files to be
274attached to the post. The LANG environment variable (if defined) is used 294attached to the post. The LANG environment variable (if defined) is used
275as the post language. 295as the post language. An optional URL to a Fediverse post, prefixed by -r,
276.It Cm note_unlisted Ar basedir Ar uid Ar text Op file file ... 296can be specified for this note to be a reply to.
297.It Cm note_unlisted Ar basedir Ar uid Ar text Op file file ... Op -r inReplyTo
277Like the previous one, but creates an "unlisted" (or "quiet public") post. 298Like the previous one, but creates an "unlisted" (or "quiet public") post.
278.It Cm note_mention Ar basedir Ar uid Ar text Op file file ... 299.It Cm note_mention Ar basedir Ar uid Ar text Op file file ... Op -r inReplyTo
279Like the previous one, but creates a post only for accounts mentioned 300Like the previous one, but creates a post only for accounts mentioned
280in the post body. 301in the post body.
281.It Cm block Ar basedir Ar instance_url 302.It Cm block Ar basedir Ar instance_url
@@ -285,9 +306,9 @@ blocked without further inspection.
285.It Cm unblock Ar basedir Ar instance_url 306.It Cm unblock Ar basedir Ar instance_url
286Unblocks a previously blocked instance. 307Unblocks a previously blocked instance.
287.It Cm verify_links Ar basedir Ar uid 308.It Cm verify_links Ar basedir Ar uid
288Verifies all links stored as metadata for the given user. This verification 309Verifies all links or account handles stored as metadata for the given user.
289is done by downloading the link content and searching for a link back to 310This verification is done by downloading the link content and searching for
290the 311a link back to the
291.Nm 312.Nm
292user url that also contains a rel="me" attribute. These links are specially 313user url that also contains a rel="me" attribute. These links are specially
293marked as verified in the user's public timeline and also via the Mastodon API. 314marked as verified in the user's public timeline and also via the Mastodon API.
@@ -300,6 +321,13 @@ subdirectory inside the user directory:
300.Pa blocked_accounts.csv , 321.Pa blocked_accounts.csv ,
301.Pa lists.csv , and 322.Pa lists.csv , and
302.Pa following_accounts.csv . 323.Pa following_accounts.csv .
324.It Cm export_posts Ar basedir Ar uid
325Exports all posts written by the user to the file
326.Pa outbox.json
327inside the
328.Pa export/
329subdirectory inside the user directory. The format is compatible with the
330one generated by the equivalent option in Mastodon.
303.It Cm alias Ar basedir Ar uid Ar "@account@remotehost" 331.It Cm alias Ar basedir Ar uid Ar "@account@remotehost"
304Sets an account as an alias of this one. This is a necessary step to migrate 332Sets an account as an alias of this one. This is a necessary step to migrate
305an account to a remote Mastodon instance (see 333an account to a remote Mastodon instance (see
@@ -354,10 +382,10 @@ subdirectory of a user's directory inside the server base directory.
354Prints the name of the user created lists. 382Prints the name of the user created lists.
355.It Cm list_members Ar basedir Ar uid Ar name 383.It Cm list_members Ar basedir Ar uid Ar name
356Prints the list of actors in the named list. 384Prints the list of actors in the named list.
357.It Cm create_list Ar basedir Ar uid Ar name 385.It Cm list_create Ar basedir Ar uid Ar name
358Creates a new list. 386Creates a new list.
359.It Cm delete_list Ar basedir Ar uid Ar name 387.It Cm list_remove Ar basedir Ar uid Ar name
360Deletes an existing list. 388Removes an existing list.
361.It Cm list_add Ar basedir Ar uid Ar name Ar account 389.It Cm list_add Ar basedir Ar uid Ar name Ar account
362Adds an account (by its @name@host handle or actor URL) to a list. 390Adds an account (by its @name@host handle or actor URL) to a list.
363.It Cm list_del Ar basedir Ar uid Ar name Ar actor_url 391.It Cm list_del Ar basedir Ar uid Ar name Ar actor_url
@@ -408,6 +436,43 @@ You can obtain an API Token by connecting to the following URL:
408https://$SNAC_HOST/oauth/x-snac-get-token 436https://$SNAC_HOST/oauth/x-snac-get-token
409.Ed 437.Ed
410.Pp 438.Pp
439.Ss Webhook for notifications
440Since version 2.78, users can set the URL to a webhook that will receive
441an HTTP POST with every notification (in JSON format). This can be used to
442implement some automation whenever a new activity happens, like autorepliers,
443chatbots, interactive textual games or whatever. The
444.Pa examples/
445subdirectory contains a tiny Python program that implements an auto-follower
446for every new follow. The JSON notification object includes the following data:
447.Bl -tag -offset indent
448.It id
449a unique notification identifier
450.It actor
451the origin actor id
452.It target
453the target actor id
454.It date
455the notification date
456.It msg
457the full ActivityPub action JSON object
458.It objid
459the object identifier (extracted from msg, may be null)
460.It type
461the action type (extracted from msg)
462.It utype
463the action subtype (extracted from msg, may be null)
464.It uid
465the user identifier (account name)
466.It basedir
467the server base directory
468.It baseurl
469the server base URL
470.It account
471the origin actor object
472.It reply
473the activity this post is a reply to (may not exist)
474.El
475.Pp
411.Sh ENVIRONMENT 476.Sh ENVIRONMENT
412.Bl -tag -width Ds 477.Bl -tag -width Ds
413.It SNAC_BASEDIR 478.It SNAC_BASEDIR
@@ -422,9 +487,9 @@ Overrides the debugging level from the server 'dbglevel' configuration
422variable. Set it to an integer value. The higher, the deeper in meaningless 487variable. Set it to an integer value. The higher, the deeper in meaningless
423verbiage you'll find yourself into. 488verbiage you'll find yourself into.
424.It Ev EDITOR 489.It Ev EDITOR
425The user-preferred interactive text editor to prepare messages. 490The user-preferred interactive text editor to prepare notes.
426.It Ev LANG 491.It Ev LANG
427The language of the post when sending messages. 492The language of the post when sending notes from the command line.
428.El 493.El
429.Sh SEE ALSO 494.Sh SEE ALSO
430.Xr snac 5 , 495.Xr snac 5 ,
diff --git a/doc/snac.8 b/doc/snac.8
index 1537f89..89c00b4 100644
--- a/doc/snac.8
+++ b/doc/snac.8
@@ -207,6 +207,8 @@ fields are set (see below), they are also shown.
207The email address of the instance administrator (optional). 207The email address of the instance administrator (optional).
208.It Ic admin_account 208.It Ic admin_account
209The user name of the instance administrator (optional). 209The user name of the instance administrator (optional).
210.It Ic title
211The name of the instance (optional).
210.It Ic short_description 212.It Ic short_description
211A textual short description about the instance (optional). 213A textual short description about the instance (optional).
212.It Ic short_description_raw 214.It Ic short_description_raw
@@ -277,6 +279,14 @@ the usual one, like in smtp://mail.example.com:587.
277.It Ic smtp_user 279.It Ic smtp_user
278.It Ic smtp_password 280.It Ic smtp_password
279To be filled if the SMTP server defined by the previous directive needs credentials. 281To be filled if the SMTP server defined by the previous directive needs credentials.
282.It Ic rss_hashtag_poll_hours
283The periodic number of hours hashtag RSS are polled (default: 4). It has a minimum
284value of 1 to avoid hammering servers.
285.It Ic disable_notify_webhook
286Since version 2.78, users can set a webhook URL to receive notifications. Set this
287to true if you don't want your users to have this privilege.
288.It Ic favicon_url
289The URL to a favicon. If it's not set, the default one is used instead.
280.El 290.El
281.Pp 291.Pp
282You must restart the server to make effective these changes. 292You must restart the server to make effective these changes.
diff --git a/doc/style.css b/doc/style.css
index 027fc43..5289332 100644
--- a/doc/style.css
+++ b/doc/style.css
@@ -1,5 +1,6 @@
1body { max-width: 48em; margin: auto; line-height: 1.5; padding: 0.8em; word-wrap: break-word; } 1body { max-width: 48em; margin: auto; line-height: 1.5; padding: 0.8em; word-wrap: break-word; }
2pre { overflow-x: scroll; } 2pre { overflow-x: scroll; }
3blockquote { font-style: italic; }
3.snac-embedded-video, img { max-width: 100% } 4.snac-embedded-video, img { max-width: 100% }
4.snac-origin { font-size: 85% } 5.snac-origin { font-size: 85% }
5.snac-score { float: right; font-size: 85% } 6.snac-score { float: right; font-size: 85% }
diff --git a/examples/auto_follower_webhook.py b/examples/auto_follower_webhook.py
new file mode 100755
index 0000000..eb632a3
--- /dev/null
+++ b/examples/auto_follower_webhook.py
@@ -0,0 +1,55 @@
1#!/usr/bin/env python3
2
3# This is an example of a snac webhook that automatically follows all new followers.
4
5# To use it, configure the user webhook to be http://localhost:12345, and run this program.
6
7# copyright (C) 2025 grunfink et al. / MIT license
8
9from http.server import BaseHTTPRequestHandler, HTTPServer
10import time
11import json
12import os
13
14host_name = "localhost"
15server_port = 12345
16
17class SnacAutoResponderServer(BaseHTTPRequestHandler):
18
19 def do_POST(self):
20 self.send_response(200)
21 self.end_headers()
22
23 content_type = self.headers["content-type"]
24 content_length = int(self.headers["content-length"])
25 payload = self.rfile.read(content_length).decode("utf-8")
26
27 if content_type == "application/json":
28 try:
29 noti = json.loads(payload)
30
31 type = noti["type"]
32
33 if type == "Follow":
34 actor = noti["actor"]
35 uid = noti["uid"]
36 basedir = noti["basedir"]
37
38 cmd = "snac follow %s %s %s" % (basedir, uid, actor)
39
40 os.system(cmd)
41
42 except:
43 print("Error parsing notification")
44
45if __name__ == "__main__":
46 webServer = HTTPServer((host_name, server_port), SnacAutoResponderServer)
47 print("Webhook started http://%s:%s" % (host_name, server_port))
48
49 try:
50 webServer.serve_forever()
51 except KeyboardInterrupt:
52 pass
53
54 webServer.server_close()
55 print("Webhook stopped.")
diff --git a/format.c b/format.c
index 2f30a0d..7b18909 100644
--- a/format.c
+++ b/format.c
@@ -8,6 +8,7 @@
8#include "xs_json.h" 8#include "xs_json.h"
9#include "xs_time.h" 9#include "xs_time.h"
10#include "xs_match.h" 10#include "xs_match.h"
11#include "xs_unicode.h"
11 12
12#include "snac.h" 13#include "snac.h"
13 14
@@ -95,8 +96,8 @@ static xs_str *format_line(const char *line, xs_list **attach)
95 "~~[^~]+~~" "|" 96 "~~[^~]+~~" "|"
96 "\\*\\*?\\*?[^\\*]+\\*?\\*?\\*" "|" 97 "\\*\\*?\\*?[^\\*]+\\*?\\*?\\*" "|"
97 "__[^_]+__" "|" //anzu 98 "__[^_]+__" "|" //anzu
98 "!\\[[^]]+\\]\\([^\\)]+\\)" "|" 99 "!\\[[^]]+\\]\\([^\\)]+\\)\\)?" "|"
99 "\\[[^]]+\\]\\([^\\)]+\\)" "|" 100 "\\[[^]]+\\]\\([^\\)]+\\)\\)?" "|"
100 "[a-z]+:/" "/" NOSPACE "|" 101 "[a-z]+:/" "/" NOSPACE "|"
101 "(mailto|xmpp):[^@[:space:]]+@" NOSPACE 102 "(mailto|xmpp):[^@[:space:]]+@" NOSPACE
102 ")"); 103 ")");
@@ -148,14 +149,15 @@ static xs_str *format_line(const char *line, xs_list **attach)
148 else 149 else
149 if (*v == '[') { 150 if (*v == '[') {
150 /* markdown-like links [label](url) */ 151 /* markdown-like links [label](url) */
151 xs *w = xs_strip_chars_i( 152 xs *w = xs_replace_i(xs_replace(v, "#", "&#35;"), "@", "&#64;");
152 xs_replace_i(xs_replace(v, "#", "&#35;"), "@", "&#64;"),
153 "![)");
154 xs *l = xs_split_n(w, "](", 1); 153 xs *l = xs_split_n(w, "](", 1);
155 154
156 if (xs_list_len(l) == 2) { 155 if (xs_list_len(l) == 2) {
157 const char *name = xs_list_get(l, 0); 156 xs *name = xs_dup(xs_list_get(l, 0));
158 const char *url = xs_list_get(l, 1); 157 xs *url = xs_dup(xs_list_get(l, 1));
158
159 name = xs_crop_i(name, 1, 0);
160 url = xs_crop_i(url, 0, -1);
159 161
160 xs *link = xs_fmt("<a href=\"%s\">%s</a>", url, name); 162 xs *link = xs_fmt("<a href=\"%s\">%s</a>", url, name);
161 163
@@ -167,15 +169,17 @@ static xs_str *format_line(const char *line, xs_list **attach)
167 else 169 else
168 if (*v == '!') { 170 if (*v == '!') {
169 /* markdown-like images ![alt text](url to image) */ 171 /* markdown-like images ![alt text](url to image) */
170 xs *w = xs_strip_chars_i( 172 xs *w = xs_replace_i(xs_replace(v, "#", "&#35;"), "@", "&#64;");
171 xs_replace_i(xs_replace(v, "#", "&#35;"), "@", "&#64;"),
172 "![)");
173 xs *l = xs_split_n(w, "](", 1); 173 xs *l = xs_split_n(w, "](", 1);
174 174
175 if (xs_list_len(l) == 2) { 175 if (xs_list_len(l) == 2) {
176 const char *alt_text = xs_list_get(l, 0); 176 xs *alt_text = xs_dup(xs_list_get(l, 0));
177 const char *img_url = xs_list_get(l, 1); 177 xs *img_url = xs_dup(xs_list_get(l, 1));
178 const char *mime = xs_mime_by_ext(img_url); 178
179 alt_text = xs_crop_i(alt_text, 2, 0);
180 img_url = xs_crop_i(img_url, 0, -1);
181
182 const char *mime = xs_mime_by_ext(img_url);
179 183
180 if (attach != NULL && xs_startswith(mime, "image/")) { 184 if (attach != NULL && xs_startswith(mime, "image/")) {
181 const xs_dict *ad; 185 const xs_dict *ad;
@@ -443,7 +447,7 @@ xs_str *sanitize(const char *content)
443 if (n & 0x1) { 447 if (n & 0x1) {
444 xs *s1 = xs_strip_i(xs_crop_i(xs_dup(v), v[1] == '/' ? 2 : 1, -1)); 448 xs *s1 = xs_strip_i(xs_crop_i(xs_dup(v), v[1] == '/' ? 2 : 1, -1));
445 xs *l1 = xs_split_n(s1, " ", 1); 449 xs *l1 = xs_split_n(s1, " ", 1);
446 xs *tag = xs_tolower_i(xs_dup(xs_list_get(l1, 0))); 450 xs *tag = xs_utf8_to_lower(xs_list_get(l1, 0));
447 xs *s2 = NULL; 451 xs *s2 = NULL;
448 int i; 452 int i;
449 453
diff --git a/html.c b/html.c
index de2fdce..7d748f9 100644
--- a/html.c
+++ b/html.c
@@ -636,13 +636,18 @@ static xs_html *html_base_head(void)
636 xs_html_attr("content", USER_AGENT))); 636 xs_html_attr("content", USER_AGENT)));
637 637
638 /* add server CSS and favicon */ 638 /* add server CSS and favicon */
639 xs *f; 639 xs *f = NULL;
640 f = xs_fmt("%s/favicon.ico", srv_baseurl); 640 const char *favicon = xs_dict_get(srv_config, "favicon_url");
641
642 if (xs_is_string(favicon))
643 f = xs_dup(favicon);
644 else
645 f = xs_fmt("%s/favicon.ico", srv_baseurl);
646
641 const xs_list *p = xs_dict_get(srv_config, "cssurls"); 647 const xs_list *p = xs_dict_get(srv_config, "cssurls");
642 const char *v; 648 const char *v;
643 int c = 0;
644 649
645 while (xs_list_next(p, &v, &c)) { 650 xs_list_foreach(p, v) {
646 xs_html_add(head, 651 xs_html_add(head,
647 xs_html_sctag("link", 652 xs_html_sctag("link",
648 xs_html_attr("rel", "stylesheet"), 653 xs_html_attr("rel", "stylesheet"),
@@ -863,6 +868,14 @@ xs_html *html_user_head(snac *user, const char *desc, const char *url)
863 xs_html_attr("type", "application/activity+json"), 868 xs_html_attr("type", "application/activity+json"),
864 xs_html_attr("href", url ? url : user->actor))); 869 xs_html_attr("href", url ? url : user->actor)));
865 870
871 /* webmention hook */
872 xs *wbh = xs_fmt("%s/webmention-hook", srv_baseurl);
873
874 xs_html_add(head,
875 xs_html_sctag("link",
876 xs_html_attr("rel", "webmention"),
877 xs_html_attr("href", wbh)));
878
866 return head; 879 return head;
867} 880}
868 881
@@ -1077,10 +1090,17 @@ static xs_html *html_user_body(snac *user, int read_only)
1077 while (xs_dict_next(metadata, &k, &v, &c)) { 1090 while (xs_dict_next(metadata, &k, &v, &c)) {
1078 xs_html *value; 1091 xs_html *value;
1079 1092
1080 if (xs_startswith(v, "https:/") || xs_startswith(v, "http:/")) { 1093 if (xs_startswith(v, "https:/") || xs_startswith(v, "http:/") || *v == '@') {
1081 /* is this link validated? */ 1094 /* is this link validated? */
1082 xs *verified_link = NULL; 1095 xs *verified_link = NULL;
1083 const xs_number *val_time = xs_dict_get(val_links, v); 1096 const xs_number *val_time = xs_dict_get(val_links, v);
1097 const char *url = NULL;
1098
1099 if (xs_is_string(val_time)) {
1100 /* resolve again, as it may be an account handle */
1101 url = val_time;
1102 val_time = xs_dict_get(val_links, val_time);
1103 }
1084 1104
1085 if (xs_type(val_time) == XSTYPE_NUMBER) { 1105 if (xs_type(val_time) == XSTYPE_NUMBER) {
1086 time_t t = xs_number_get(val_time); 1106 time_t t = xs_number_get(val_time);
@@ -1098,13 +1118,13 @@ static xs_html *html_user_body(snac *user, int read_only)
1098 xs_html_tag("a", 1118 xs_html_tag("a",
1099 xs_html_attr("rel", "me"), 1119 xs_html_attr("rel", "me"),
1100 xs_html_attr("target", "_blank"), 1120 xs_html_attr("target", "_blank"),
1101 xs_html_attr("href", v), 1121 xs_html_attr("href", url ? url : v),
1102 xs_html_text(v))); 1122 xs_html_text(v)));
1103 } 1123 }
1104 else { 1124 else {
1105 value = xs_html_tag("a", 1125 value = xs_html_tag("a",
1106 xs_html_attr("rel", "me"), 1126 xs_html_attr("rel", "me"),
1107 xs_html_attr("href", v), 1127 xs_html_attr("href", url ? url : v),
1108 xs_html_text(v)); 1128 xs_html_text(v));
1109 } 1129 }
1110 } 1130 }
@@ -1290,6 +1310,7 @@ xs_html *html_top_controls(snac *user)
1290 const xs_val *show_foll = xs_dict_get(user->config, "show_contact_metrics"); 1310 const xs_val *show_foll = xs_dict_get(user->config, "show_contact_metrics");
1291 const char *latitude = xs_dict_get_def(user->config, "latitude", ""); 1311 const char *latitude = xs_dict_get_def(user->config, "latitude", "");
1292 const char *longitude = xs_dict_get_def(user->config, "longitude", ""); 1312 const char *longitude = xs_dict_get_def(user->config, "longitude", "");
1313 const char *webhook = xs_dict_get_def(user->config, "notify_webhook", "");
1293 1314
1294 xs *metadata = NULL; 1315 xs *metadata = NULL;
1295 const xs_dict *md = xs_dict_get(user->config, "metadata"); 1316 const xs_dict *md = xs_dict_get(user->config, "metadata");
@@ -1452,6 +1473,14 @@ xs_html *html_top_controls(snac *user)
1452 xs_html_attr("value", ntfy_token), 1473 xs_html_attr("value", ntfy_token),
1453 xs_html_attr("placeholder", L("ntfy token - if needed")))), 1474 xs_html_attr("placeholder", L("ntfy token - if needed")))),
1454 xs_html_tag("p", 1475 xs_html_tag("p",
1476 xs_html_text(L("Notify webhook:")),
1477 xs_html_sctag("br", NULL),
1478 xs_html_sctag("input",
1479 xs_html_attr("type", "url"),
1480 xs_html_attr("name", "notify_webhook"),
1481 xs_html_attr("value", webhook),
1482 xs_html_attr("placeholder", L("http://example.com/webhook")))),
1483 xs_html_tag("p",
1455 xs_html_text(L("Maximum days to keep posts (0: server settings):")), 1484 xs_html_text(L("Maximum days to keep posts (0: server settings):")),
1456 xs_html_sctag("br", NULL), 1485 xs_html_sctag("br", NULL),
1457 xs_html_sctag("input", 1486 xs_html_sctag("input",
@@ -1601,7 +1630,8 @@ xs_html *html_top_controls(snac *user)
1601 xs_html_attr("name", "followed_hashtags"), 1630 xs_html_attr("name", "followed_hashtags"),
1602 xs_html_attr("cols", "40"), 1631 xs_html_attr("cols", "40"),
1603 xs_html_attr("rows", "4"), 1632 xs_html_attr("rows", "4"),
1604 xs_html_attr("placeholder", "#cats\n#windowfriday\n#classicalmusic"), 1633 xs_html_attr("placeholder", "#cats\n#windowfriday\n#classicalmusic\nhttps:/"
1634 "/mastodon.social/tags/dogs"),
1605 xs_html_text(followed_hashtags)), 1635 xs_html_text(followed_hashtags)),
1606 1636
1607 xs_html_tag("br", NULL), 1637 xs_html_tag("br", NULL),
@@ -1978,8 +2008,13 @@ xs_html *html_entry(snac *user, xs_dict *msg, int read_only,
1978 } 2008 }
1979 2009
1980 if ((user == NULL || strcmp(actor, user->actor) != 0) 2010 if ((user == NULL || strcmp(actor, user->actor) != 0)
1981 && !valid_status(actor_get(actor, NULL))) 2011 && !valid_status(actor_get(actor, NULL))) {
2012
2013 if (user)
2014 enqueue_actor_refresh(user, actor, 0);
2015
1982 return NULL; 2016 return NULL;
2017 }
1983 2018
1984 /** html_entry top tag **/ 2019 /** html_entry top tag **/
1985 xs_html *entry_top = xs_html_tag("div", NULL); 2020 xs_html *entry_top = xs_html_tag("div", NULL);
@@ -2115,9 +2150,7 @@ xs_html *html_entry(snac *user, xs_dict *msg, int read_only,
2115 const char *parent = get_in_reply_to(msg); 2150 const char *parent = get_in_reply_to(msg);
2116 2151
2117 if (!xs_is_null(parent) && *parent) { 2152 if (!xs_is_null(parent) && *parent) {
2118 xs *md5 = xs_md5_hex(parent, strlen(parent)); 2153 if (!timeline_here(user, parent)) {
2119
2120 if (!timeline_here(user, md5)) {
2121 xs_html_add(post_header, 2154 xs_html_add(post_header,
2122 xs_html_tag("div", 2155 xs_html_tag("div",
2123 xs_html_attr("class", "snac-origin"), 2156 xs_html_attr("class", "snac-origin"),
@@ -2419,6 +2452,9 @@ xs_html *html_entry(snac *user, xs_dict *msg, int read_only,
2419 const char *o_href = xs_dict_get(a, "href"); 2452 const char *o_href = xs_dict_get(a, "href");
2420 const char *name = xs_dict_get(a, "name"); 2453 const char *name = xs_dict_get(a, "name");
2421 2454
2455 if (!xs_is_string(type) || !xs_is_string(o_href))
2456 continue;
2457
2422 /* if this URL is already in the post content, skip */ 2458 /* if this URL is already in the post content, skip */
2423 if (content && xs_str_in(content, o_href) != -1) 2459 if (content && xs_str_in(content, o_href) != -1)
2424 continue; 2460 continue;
@@ -3284,12 +3320,12 @@ xs_str *html_people(snac *user)
3284 3320
3285 xs *wing = following_list(user); 3321 xs *wing = following_list(user);
3286 xs *wers = follower_list(user); 3322 xs *wers = follower_list(user);
3323 xs *pending = pending_list(user);
3287 3324
3288 xs_html *lists = xs_html_tag("div", 3325 xs_html *lists = xs_html_tag("div",
3289 xs_html_attr("class", "snac-posts")); 3326 xs_html_attr("class", "snac-posts"));
3290 3327
3291 if (xs_is_true(xs_dict_get(user->config, "approve_followers"))) { 3328 if (xs_list_len(pending) || xs_is_true(xs_dict_get(user->config, "approve_followers"))) {
3292 xs *pending = pending_list(user);
3293 xs_html_add(lists, 3329 xs_html_add(lists,
3294 html_people_list(user, pending, L("Pending follow confirmations"), "p", proxy)); 3330 html_people_list(user, pending, L("Pending follow confirmations"), "p", proxy));
3295 } 3331 }
@@ -3384,13 +3420,15 @@ xs_str *html_notifications(snac *user, int skip, int show)
3384 3420
3385 const char *actor_id = xs_dict_get(noti, "actor"); 3421 const char *actor_id = xs_dict_get(noti, "actor");
3386 xs *actor = NULL; 3422 xs *actor = NULL;
3423 xs *a_name = NULL;
3387 3424
3388 if (!valid_status(actor_get(actor_id, &actor))) 3425 if (valid_status(actor_get(actor_id, &actor)))
3389 continue; 3426 a_name = actor_name(actor, proxy);
3427 else
3428 a_name = xs_dup(actor_id);
3390 3429
3391 xs *a_name = actor_name(actor, proxy); 3430 xs *label_sanitized = sanitize(type);
3392 xs *label_sanatized = sanitize(type); 3431 const char *label = label_sanitized;
3393 const char *label = label_sanatized;
3394 3432
3395 if (strcmp(type, "Create") == 0) 3433 if (strcmp(type, "Create") == 0)
3396 label = L("Mention"); 3434 label = L("Mention");
@@ -3462,10 +3500,11 @@ xs_str *html_notifications(snac *user, int skip, int show)
3462 html_label); 3500 html_label);
3463 3501
3464 if (strcmp(type, "Follow") == 0 || strcmp(utype, "Follow") == 0 || strcmp(type, "Block") == 0) { 3502 if (strcmp(type, "Follow") == 0 || strcmp(utype, "Follow") == 0 || strcmp(type, "Block") == 0) {
3465 xs_html_add(entry, 3503 if (actor)
3466 xs_html_tag("div", 3504 xs_html_add(entry,
3467 xs_html_attr("class", "snac-post"), 3505 xs_html_tag("div",
3468 html_actor_icon(user, actor, NULL, NULL, NULL, 0, 0, proxy, NULL, NULL))); 3506 xs_html_attr("class", "snac-post"),
3507 html_actor_icon(user, actor, NULL, NULL, NULL, 0, 0, proxy, NULL, NULL)));
3469 } 3508 }
3470 else 3509 else
3471 if (strcmp(type, "Move") == 0) { 3510 if (strcmp(type, "Move") == 0) {
@@ -3498,6 +3537,13 @@ xs_str *html_notifications(snac *user, int skip, int show)
3498 xs_html_text(L("Context")))), 3537 xs_html_text(L("Context")))),
3499 h); 3538 h);
3500 } 3539 }
3540 else
3541 xs_html_add(entry,
3542 xs_html_tag("p",
3543 xs_html_text(L("Location: ")),
3544 xs_html_tag("a",
3545 xs_html_attr("href", id),
3546 xs_html_text(id))));
3501 } 3547 }
3502 3548
3503 if (strcmp(v, n_time) > 0) { 3549 if (strcmp(v, n_time) > 0) {
@@ -3748,7 +3794,7 @@ int html_get_handler(const xs_dict *req, const char *q_path,
3748 /* may by an actor; try a webfinger */ 3794 /* may by an actor; try a webfinger */
3749 xs *actor_obj = NULL; 3795 xs *actor_obj = NULL;
3750 3796
3751 if (valid_status(webfinger_request(q, &actor_obj, &url_acct))) { 3797 if (valid_status(webfinger_request(q, &actor_obj, &url_acct)) && xs_is_string(url_acct)) {
3752 /* it's an actor; do the dirty trick of changing q to the account name */ 3798 /* it's an actor; do the dirty trick of changing q to the account name */
3753 q = url_acct; 3799 q = url_acct;
3754 } 3800 }
@@ -3773,12 +3819,18 @@ int html_get_handler(const xs_dict *req, const char *q_path,
3773 q = url_acct; 3819 q = url_acct;
3774 3820
3775 /* add the post to the timeline */ 3821 /* add the post to the timeline */
3776 xs *md5 = xs_md5_hex(q, strlen(q)); 3822 if (!timeline_here(&snac, q))
3777
3778 if (!timeline_here(&snac, md5))
3779 timeline_add(&snac, q, object); 3823 timeline_add(&snac, q, object);
3780 } 3824 }
3781 } 3825 }
3826 else {
3827 /* retry webfinger, this time with the 'official' id */
3828 const char *id = xs_dict_get(object, "id");
3829
3830 if (xs_is_string(id) && valid_status(webfinger_request(id, &actor_obj, &url_acct)) &&
3831 xs_is_string(url_acct))
3832 q = url_acct;
3833 }
3782 } 3834 }
3783 } 3835 }
3784 3836
@@ -3917,7 +3969,7 @@ int html_get_handler(const xs_dict *req, const char *q_path,
3917 xs *l = xs_split(p_path, "/"); 3969 xs *l = xs_split(p_path, "/");
3918 const char *md5 = xs_list_get(l, -1); 3970 const char *md5 = xs_list_get(l, -1);
3919 3971
3920 if (md5 && *md5 && timeline_here(&snac, md5)) { 3972 if (md5 && *md5 && timeline_here_by_md5(&snac, md5)) {
3921 xs *list0 = xs_list_append(xs_list_new(), md5); 3973 xs *list0 = xs_list_append(xs_list_new(), md5);
3922 xs *list = timeline_top_level(&snac, list0); 3974 xs *list = timeline_top_level(&snac, list0);
3923 3975
@@ -4127,7 +4179,7 @@ int html_get_handler(const xs_dict *req, const char *q_path,
4127 xs_dict_get(srv_config, "host")); 4179 xs_dict_get(srv_config, "host"));
4128 xs *rss_link = xs_fmt("%s.rss", snac.actor); 4180 xs *rss_link = xs_fmt("%s.rss", snac.actor);
4129 4181
4130 *body = timeline_to_rss(&snac, elems, rss_title, rss_link, bio); 4182 *body = rss_from_timeline(&snac, elems, rss_title, rss_link, bio);
4131 *b_size = strlen(*body); 4183 *b_size = strlen(*body);
4132 *ctype = "application/rss+xml; charset=utf-8"; 4184 *ctype = "application/rss+xml; charset=utf-8";
4133 status = HTTP_STATUS_OK; 4185 status = HTTP_STATUS_OK;
@@ -4365,7 +4417,7 @@ int html_post_handler(const xs_dict *req, const char *q_path,
4365 xs_rnd_buf(rnd, sizeof(rnd)); 4417 xs_rnd_buf(rnd, sizeof(rnd));
4366 4418
4367 const char *ext = strrchr(fn, '.'); 4419 const char *ext = strrchr(fn, '.');
4368 xs *hash = xs_md5_hex(rnd, strlen(rnd)); 4420 xs *hash = xs_md5_hex(rnd, sizeof(rnd));
4369 xs *id = xs_fmt("post-%s%s", hash, ext ? ext : ""); 4421 xs *id = xs_fmt("post-%s%s", hash, ext ? ext : "");
4370 xs *url = xs_fmt("%s/s/%s", snac.actor, id); 4422 xs *url = xs_fmt("%s/s/%s", snac.actor, id);
4371 int fo = xs_number_get(xs_list_get(attach_file, 1)); 4423 int fo = xs_number_get(xs_list_get(attach_file, 1));
@@ -4825,6 +4877,8 @@ int html_post_handler(const xs_dict *req, const char *q_path,
4825 snac.config = xs_dict_set(snac.config, "latitude", xs_dict_get_def(p_vars, "latitude", "")); 4877 snac.config = xs_dict_set(snac.config, "latitude", xs_dict_get_def(p_vars, "latitude", ""));
4826 snac.config = xs_dict_set(snac.config, "longitude", xs_dict_get_def(p_vars, "longitude", "")); 4878 snac.config = xs_dict_set(snac.config, "longitude", xs_dict_get_def(p_vars, "longitude", ""));
4827 4879
4880 snac.config = xs_dict_set(snac.config, "notify_webhook", xs_dict_get_def(p_vars, "notify_webhook", ""));
4881
4828 if ((v = xs_dict_get(p_vars, "metadata")) != NULL) 4882 if ((v = xs_dict_get(p_vars, "metadata")) != NULL)
4829 snac.config = xs_dict_set(snac.config, "metadata", v); 4883 snac.config = xs_dict_set(snac.config, "metadata", v);
4830 4884
@@ -4960,9 +5014,16 @@ int html_post_handler(const xs_dict *req, const char *q_path,
4960 if (*s1 == '\0') 5014 if (*s1 == '\0')
4961 continue; 5015 continue;
4962 5016
4963 xs *s2 = xs_utf8_to_lower(s1); 5017 xs *s2 = NULL;
4964 if (*s2 != '#') 5018
4965 s2 = xs_str_prepend_i(s2, "#"); 5019 if (xs_startswith(s1, "https:/"))
5020 s2 = xs_dup(s1);
5021 else {
5022 s2 = xs_utf8_to_lower(s1);
5023
5024 if (*s2 != '#')
5025 s2 = xs_str_prepend_i(s2, "#");
5026 }
4966 5027
4967 new_hashtags = xs_list_append(new_hashtags, s2); 5028 new_hashtags = xs_list_append(new_hashtags, s2);
4968 } 5029 }
@@ -5024,100 +5085,3 @@ int html_post_handler(const xs_dict *req, const char *q_path,
5024 5085
5025 return status; 5086 return status;
5026} 5087}
5027
5028
5029xs_str *timeline_to_rss(snac *user, const xs_list *timeline,
5030 const char *title, const char *link, const char *desc)
5031/* converts a timeline to rss */
5032{
5033 xs_html *rss = xs_html_tag("rss",
5034 xs_html_attr("xmlns:content", "http:/" "/purl.org/rss/1.0/modules/content/"),
5035 xs_html_attr("version", "2.0"),
5036 xs_html_attr("xmlns:atom", "http:/" "/www.w3.org/2005/Atom"));
5037
5038 xs_html *channel = xs_html_tag("channel",
5039 xs_html_tag("title",
5040 xs_html_text(title)),
5041 xs_html_tag("language",
5042 xs_html_text("en")),
5043 xs_html_tag("link",
5044 xs_html_text(link)),
5045 xs_html_sctag("atom:link",
5046 xs_html_attr("href", link),
5047 xs_html_attr("rel", "self"),
5048 xs_html_attr("type", "application/rss+xml")),
5049 xs_html_tag("generator",
5050 xs_html_text(USER_AGENT)),
5051 xs_html_tag("description",
5052 xs_html_text(desc)));
5053
5054 xs_html_add(rss, channel);
5055
5056 int cnt = 0;
5057 const char *v;
5058
5059 xs_list_foreach(timeline, v) {
5060 xs *msg = NULL;
5061
5062 if (user) {
5063 if (!valid_status(timeline_get_by_md5(user, v, &msg)))
5064 continue;
5065 }
5066 else {
5067 if (!valid_status(object_get_by_md5(v, &msg)))
5068 continue;
5069 }
5070
5071 const char *id = xs_dict_get(msg, "id");
5072 const char *content = xs_dict_get(msg, "content");
5073 const char *published = xs_dict_get(msg, "published");
5074
5075 if (user && !xs_startswith(id, user->actor))
5076 continue;
5077
5078 if (!id || !content || !published)
5079 continue;
5080
5081 /* create a title with the first line of the content */
5082 xs *title = xs_replace(content, "<br>", "\n");
5083 title = xs_regex_replace_i(title, "<[^>]+>", " ");
5084 title = xs_regex_replace_i(title, "&[^;]+;", " ");
5085 int i;
5086
5087 for (i = 0; title[i] && title[i] != '\n' && i < 50; i++);
5088
5089 if (title[i] != '\0') {
5090 title[i] = '\0';
5091 title = xs_str_cat(title, "...");
5092 }
5093
5094 title = xs_strip_i(title);
5095
5096 /* convert the date */
5097 time_t t = xs_parse_iso_date(published, 0);
5098 xs *rss_date = xs_str_utctime(t, "%a, %d %b %Y %T +0000");
5099
5100 /* if it's the first one, add it to the header */
5101 if (cnt == 0)
5102 xs_html_add(channel,
5103 xs_html_tag("lastBuildDate",
5104 xs_html_text(rss_date)));
5105
5106 xs_html_add(channel,
5107 xs_html_tag("item",
5108 xs_html_tag("title",
5109 xs_html_text(title)),
5110 xs_html_tag("link",
5111 xs_html_text(id)),
5112 xs_html_tag("guid",
5113 xs_html_text(id)),
5114 xs_html_tag("pubDate",
5115 xs_html_text(rss_date)),
5116 xs_html_tag("description",
5117 xs_html_text(content))));
5118
5119 cnt++;
5120 }
5121
5122 return xs_html_render_s(rss, "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
5123}
diff --git a/httpd.c b/httpd.c
index 6f7d69b..d01eb9c 100644
--- a/httpd.c
+++ b/httpd.c
@@ -12,6 +12,7 @@
12#include "xs_openssl.h" 12#include "xs_openssl.h"
13#include "xs_fcgi.h" 13#include "xs_fcgi.h"
14#include "xs_html.h" 14#include "xs_html.h"
15#include "xs_webmention.h"
15 16
16#include "snac.h" 17#include "snac.h"
17 18
@@ -65,7 +66,9 @@ const char *nodeinfo_2_0_template = ""
65 "\"services\":{\"outbound\":[],\"inbound\":[]}," 66 "\"services\":{\"outbound\":[],\"inbound\":[]},"
66 "\"usage\":{\"users\":{\"total\":%d,\"activeMonth\":%d,\"activeHalfyear\":%d}," 67 "\"usage\":{\"users\":{\"total\":%d,\"activeMonth\":%d,\"activeHalfyear\":%d},"
67 "\"localPosts\":%d}," 68 "\"localPosts\":%d},"
68 "\"openRegistrations\":false,\"metadata\":{}}"; 69 "\"openRegistrations\":false,\"metadata\":{"
70 "\"nodeDescription\":\"%s\",\"nodeName\":\"%s\""
71 "}}";
69 72
70xs_str *nodeinfo_2_0(void) 73xs_str *nodeinfo_2_0(void)
71/* builds a nodeinfo json object */ 74/* builds a nodeinfo json object */
@@ -98,7 +101,10 @@ xs_str *nodeinfo_2_0(void)
98 n_posts += index_len(pidxfn); 101 n_posts += index_len(pidxfn);
99 } 102 }
100 103
101 return xs_fmt(nodeinfo_2_0_template, n_utotal, n_umonth, n_uhyear, n_posts); 104 const char *name = xs_dict_get_def(srv_config, "title", "");
105 const char *desc = xs_dict_get_def(srv_config, "short_description", "");
106
107 return xs_fmt(nodeinfo_2_0_template, n_utotal, n_umonth, n_uhyear, n_posts, desc, name);
102} 108}
103 109
104 110
@@ -244,7 +250,7 @@ int server_get_handler(xs_dict *req, const char *q_path,
244 if (!xs_is_null(accept) && strcmp(accept, "application/rss+xml") == 0) { 250 if (!xs_is_null(accept) && strcmp(accept, "application/rss+xml") == 0) {
245 xs *link = xs_fmt("%s/?t=%s", srv_baseurl, t); 251 xs *link = xs_fmt("%s/?t=%s", srv_baseurl, t);
246 252
247 *body = timeline_to_rss(NULL, tl, link, link, link); 253 *body = rss_from_timeline(NULL, tl, link, link, link);
248 *ctype = "application/rss+xml; charset=utf-8"; 254 *ctype = "application/rss+xml; charset=utf-8";
249 } 255 }
250 else { 256 else {
@@ -373,6 +379,68 @@ int server_get_handler(xs_dict *req, const char *q_path,
373} 379}
374 380
375 381
382int server_post_handler(const xs_dict *req, const char *q_path,
383 char *payload, int p_size,
384 char **body, int *b_size, char **ctype)
385{
386 int status = 0;
387
388 (void)payload;
389 (void)p_size;
390 (void)body;
391 (void)b_size;
392 (void)ctype;
393
394 if (strcmp(q_path, "/webmention-hook") == 0) {
395 status = HTTP_STATUS_BAD_REQUEST;
396
397 const xs_dict *p_vars = xs_dict_get(req, "p_vars");
398
399 if (!xs_is_dict(p_vars))
400 return status;
401
402 const char *source = xs_dict_get(p_vars, "source");
403 const char *target = xs_dict_get(p_vars, "target");
404
405 if (!xs_is_string(source) || !xs_is_string(target)) {
406 srv_debug(1, xs_fmt("webmention-hook bad source or target"));
407 return status;
408 }
409
410 if (!xs_startswith(target, srv_baseurl)) {
411 srv_debug(1, xs_fmt("webmention-hook unknown target %s", target));
412 return status;
413 }
414
415 /* get the user */
416 xs *s1 = xs_replace(target, srv_baseurl, "");
417
418 xs *l1 = xs_split(s1, "/");
419 const char *uid = xs_list_get(l1, 1);
420 snac user;
421
422 if (!xs_is_string(uid) || !user_open(&user, uid)) {
423 srv_debug(1, xs_fmt("webmention-hook cannot find user for %s", target));
424 return status;
425 }
426
427 int r = xs_webmention_hook(source, target, USER_AGENT);
428
429 if (r > 0) {
430 notify_add(&user, "Webmention", NULL, source, target, xs_stock(XSTYPE_DICT));
431 timeline_touch(&user);
432 }
433
434 srv_log(xs_fmt("webmention-hook source=%s target=%s %d", source, target, r));
435
436 user_free(&user);
437 status = HTTP_STATUS_OK;
438 }
439
440 return status;
441}
442
443
376void httpd_connection(FILE *f) 444void httpd_connection(FILE *f)
377/* the connection processor */ 445/* the connection processor */
378{ 446{
@@ -444,6 +512,10 @@ void httpd_connection(FILE *f)
444 else 512 else
445 if (strcmp(method, "POST") == 0) { 513 if (strcmp(method, "POST") == 0) {
446 514
515 if (status == 0)
516 status = server_post_handler(req, q_path,
517 payload, p_size, &body, &b_size, &ctype);
518
447#ifndef NO_MASTODON_API 519#ifndef NO_MASTODON_API
448 if (status == 0) 520 if (status == 0)
449 status = oauth_post_handler(req, q_path, 521 status = oauth_post_handler(req, q_path,
@@ -705,34 +777,36 @@ static pthread_cond_t sleep_cond;
705static void *background_thread(void *arg) 777static void *background_thread(void *arg)
706/* background thread (queue management and other things) */ 778/* background thread (queue management and other things) */
707{ 779{
708 time_t purge_time; 780 time_t t, purge_time, rss_time;
709 781
710 (void)arg; 782 (void)arg;
711 783
784 t = time(NULL);
785
712 /* first purge time */ 786 /* first purge time */
713 purge_time = time(NULL) + 10 * 60; 787 purge_time = t + 10 * 60;
788
789 /* first RSS polling time */
790 rss_time = t + 15 * 60;
714 791
715 srv_log(xs_fmt("background thread started")); 792 srv_log(xs_fmt("background thread started"));
716 793
717 while (p_state->srv_running) { 794 while (p_state->srv_running) {
718 time_t t;
719 int cnt = 0; 795 int cnt = 0;
720 796
721 p_state->th_state[0] = THST_QUEUE; 797 p_state->th_state[0] = THST_QUEUE;
722 798
723 { 799 {
724 xs *list = user_list(); 800 xs *list = user_list();
725 char *p;
726 const char *uid; 801 const char *uid;
727 802
728 /* process queues for all users */ 803 /* process queues for all users */
729 p = list; 804 xs_list_foreach(list, uid) {
730 while (xs_list_iter(&p, &uid)) { 805 snac user;
731 snac snac;
732 806
733 if (user_open(&snac, uid)) { 807 if (user_open(&user, uid)) {
734 cnt += process_user_queue(&snac); 808 cnt += process_user_queue(&user);
735 user_free(&snac); 809 user_free(&user);
736 } 810 }
737 } 811 }
738 } 812 }
@@ -740,8 +814,10 @@ static void *background_thread(void *arg)
740 /* global queue */ 814 /* global queue */
741 cnt += process_queue(); 815 cnt += process_queue();
742 816
817 t = time(NULL);
818
743 /* time to purge? */ 819 /* time to purge? */
744 if ((t = time(NULL)) > purge_time) { 820 if (t > purge_time) {
745 /* next purge time is tomorrow */ 821 /* next purge time is tomorrow */
746 purge_time = t + 24 * 60 * 60; 822 purge_time = t + 24 * 60 * 60;
747 823
@@ -750,6 +826,22 @@ static void *background_thread(void *arg)
750 job_post(q_item, 0); 826 job_post(q_item, 0);
751 } 827 }
752 828
829 /* time to poll the RSS? */
830 if (t > rss_time) {
831 /* next RSS poll time */
832 int hours = xs_number_get(xs_dict_get_def(srv_config, "rss_hashtag_poll_hours", "4"));
833
834 /* don't hammer servers too much */
835 if (hours < 1)
836 hours = 1;
837
838 rss_time = t + 60 * 60 * hours;
839
840 xs *q_item = xs_dict_new();
841 q_item = xs_dict_append(q_item, "type", "rss_hashtag_poll");
842 job_post(q_item, 0);
843 }
844
753 if (cnt == 0) { 845 if (cnt == 0) {
754 /* sleep 3 seconds */ 846 /* sleep 3 seconds */
755 847
diff --git a/main.c b/main.c
index 80df2d0..ce4e7ce 100644
--- a/main.c
+++ b/main.c
@@ -7,64 +7,90 @@
7#include "xs_time.h" 7#include "xs_time.h"
8#include "xs_openssl.h" 8#include "xs_openssl.h"
9#include "xs_match.h" 9#include "xs_match.h"
10#include "xs_random.h"
10 11
11#include "snac.h" 12#include "snac.h"
12 13
13#include <sys/stat.h> 14#include <sys/stat.h>
14#include <sys/wait.h> 15#include <sys/wait.h>
15 16
16int usage(void) 17int usage(const char *cmd)
17{ 18{
18 printf("snac " VERSION " - A simple, minimalistic ActivityPub instance\n"); 19 printf("snac " VERSION " - A simple, minimalistic ActivityPub instance\n");
19 printf("Copyright (c) 2022 - 2025 grunfink et al. / MIT license\n"); 20 printf("Copyright (c) 2022 - 2025 grunfink et al. / MIT license\n");
20 printf("\n"); 21 printf("\n");
21 printf("Commands:\n"); 22
22 printf("\n"); 23 if (cmd == NULL) {
23 printf("init [{basedir}] Initializes the data storage\n"); 24 printf("Commands:\n");
24 printf("upgrade {basedir} Upgrade to a new version\n"); 25 printf("\n");
25 printf("adduser {basedir} [{uid}] Adds a new user\n"); 26 }
26 printf("deluser {basedir} {uid} Deletes a user\n"); 27
27 printf("httpd {basedir} Starts the HTTPD daemon\n"); 28 const char *cmds =
28 printf("purge {basedir} Purges old data\n"); 29 "init [{basedir}] Initializes the data storage\n"
29 printf("state {basedir} Prints server state\n"); 30 "upgrade {basedir} Upgrade to a new version\n"
30 printf("webfinger {basedir} {account} Queries about an account (@user@host or actor url)\n"); 31 "adduser {basedir} [{uid}] Adds a new user\n"
31 printf("queue {basedir} {uid} Processes a user queue\n"); 32 "deluser {basedir} {uid} Deletes a user\n"
32 printf("follow {basedir} {uid} {actor} Follows an actor\n"); 33 "httpd {basedir} Starts the HTTPD daemon\n"
33 printf("unfollow {basedir} {uid} {actor} Unfollows an actor\n"); 34 "purge {basedir} Purges old data\n"
34 printf("request {basedir} {uid} {url} Requests an object\n"); 35 "state {basedir} Prints server state\n"
35 printf("insert {basedir} {uid} {url} Requests an object and inserts it into the timeline\n"); 36 "webfinger {basedir} {account} Queries about an account (@user@host or actor url)\n"
36 printf("actor {basedir} [{uid}] {url} Requests an actor\n"); 37 "queue {basedir} {uid} Processes a user queue\n"
37 printf("note {basedir} {uid} {text} [files...] Sends a note with optional attachments\n"); 38 "follow {basedir} {uid} {actor} Follows an actor\n"
38 printf("note_unlisted {basedir} {uid} {text} [files...] Sends an unlisted note with optional attachments\n"); 39 "unfollow {basedir} {uid} {actor} Unfollows an actor\n"
39 printf("note_mention {basedir} {uid} {text} [files...] Sends a note only to mentioned accounts\n"); 40 "request {basedir} {uid} {url} Requests an object\n"
40 printf("boost|announce {basedir} {uid} {url} Boosts (announces) a post\n"); 41 "insert {basedir} {uid} {url} Requests an object and inserts it into the timeline\n"
41 printf("unboost {basedir} {uid} {url} Unboosts a post\n"); 42 "actor {basedir} [{uid}] {url} Requests an actor\n"
42 printf("resetpwd {basedir} {uid} Resets the password of a user\n"); 43 "note {basedir} {uid} {text} [files...] Sends a note with optional attachments\n"
43 printf("ping {basedir} {uid} {actor} Pings an actor\n"); 44 "note_unlisted {basedir} {uid} {text} [files...] Sends an unlisted note with optional attachments\n"
44 printf("webfinger_s {basedir} {uid} {account} Queries about an account (@user@host or actor url)\n"); 45 "note_mention {basedir} {uid} {text} [files...] Sends a note only to mentioned accounts\n"
45 printf("pin {basedir} {uid} {msg_url} Pins a message\n"); 46 "boost|announce {basedir} {uid} {url} Boosts (announces) a post\n"
46 printf("unpin {basedir} {uid} {msg_url} Unpins a message\n"); 47 "unboost {basedir} {uid} {url} Unboosts a post\n"
47 printf("bookmark {basedir} {uid} {msg_url} Bookmarks a message\n"); 48 "resetpwd {basedir} {uid} Resets the password of a user\n"
48 printf("unbookmark {basedir} {uid} {msg_url} Unbookmarks a message\n"); 49 "ping {basedir} {uid} {actor} Pings an actor\n"
49 printf("block {basedir} {instance_url} Blocks a full instance\n"); 50 "webfinger_s {basedir} {uid} {account} Queries about an account (@user@host or actor url)\n"
50 printf("unblock {basedir} {instance_url} Unblocks a full instance\n"); 51 "pin {basedir} {uid} {msg_url} Pins a message\n"
51 printf("limit {basedir} {uid} {actor} Limits an actor (drops their announces)\n"); 52 "unpin {basedir} {uid} {msg_url} Unpins a message\n"
52 printf("unlimit {basedir} {uid} {actor} Unlimits an actor\n"); 53 "bookmark {basedir} {uid} {msg_url} Bookmarks a message\n"
53 printf("unmute {basedir} {uid} {actor} Unmutes a previously muted actor\n"); 54 "unbookmark {basedir} {uid} {msg_url} Unbookmarks a message\n"
54 printf("verify_links {basedir} {uid} Verifies a user's links (in the metadata)\n"); 55 "block {basedir} {instance_url} Blocks a full instance\n"
55 printf("search {basedir} {uid} {regex} Searches posts by content\n"); 56 "unblock {basedir} {instance_url} Unblocks a full instance\n"
56 printf("export_csv {basedir} {uid} Exports data as CSV files\n"); 57 "limit {basedir} {uid} {actor} Limits an actor (drops their announces)\n"
57 printf("alias {basedir} {uid} {account} Sets account (@user@host or actor url) as an alias\n"); 58 "unlimit {basedir} {uid} {actor} Unlimits an actor\n"
58 printf("migrate {basedir} {uid} Migrates to the account defined as the alias\n"); 59 "unmute {basedir} {uid} {actor} Unmutes a previously muted actor\n"
59 printf("import_csv {basedir} {uid} Imports data from CSV files\n"); 60 "verify_links {basedir} {uid} Verifies a user's links (in the metadata)\n"
60 printf("import_list {basedir} {uid} {file} Imports a Mastodon CSV list file\n"); 61 "search {basedir} {uid} {regex} Searches posts by content\n"
61 printf("import_block_list {basedir} {uid} {file} Imports a Mastodon CSV block list file\n"); 62 "export_csv {basedir} {uid} Exports followers, lists, MUTEd and bookmarks to CSV\n"
62 printf("lists {basedir} {uid} Returns the names of the lists created by the user\n"); 63 "export_posts {basedir} {iod} Exports all posts to outbox.json\n"
63 printf("list_members {basedir} {uid} {name} Returns the list of accounts inside a list\n"); 64 "alias {basedir} {uid} {account} Sets account (@user@host or actor url) as an alias\n"
64 printf("create_list {basedir} {uid} {name} Creates a new list\n"); 65 "migrate {basedir} {uid} Migrates to the account defined as the alias\n"
65 printf("delete_list {basedir} {uid} {name} Deletes an existing list\n"); 66 "import_csv {basedir} {uid} Imports data from CSV files\n"
66 printf("list_add {basedir} {uid} {name} {acct} Adds an account (@user@host or actor url) to a list\n"); 67 "import_list {basedir} {uid} {file} Imports a Mastodon CSV list file\n"
67 printf("list_del {basedir} {uid} {name} {actor} Deletes an actor URL from a list\n"); 68 "import_block_list {basedir} {uid} {file} Imports a Mastodon CSV block list file\n"
69 "lists {basedir} {uid} Returns the names of the lists created by the user\n"
70 "list_members {basedir} {uid} {name} Returns the list of accounts inside a list\n"
71 "list_create {basedir} {uid} {name} Creates a new list\n"
72 "list_remove {basedir} {uid} {name} Removes an existing list\n"
73 "list_add {basedir} {uid} {name} {acct} Adds an account (@user@host or actor url) to a list\n"
74 "list_del {basedir} {uid} {name} {actor} Deletes an actor URL from a list\n";
75
76 if (cmd == NULL)
77 printf("%s", cmds);
78 else {
79 /* only show help for the entered command */
80 xs *l = xs_split(cmds, "\n");
81 const char *v;
82 int cnt = 0;
83
84 xs_list_foreach(l, v) {
85 if (xs_str_in(v, cmd) != -1) {
86 printf("%s\n", v);
87 cnt++;
88 }
89 }
90
91 if (cnt == 0)
92 printf("%s", cmds);
93 }
68 94
69 return 1; 95 return 1;
70} 96}
@@ -94,7 +120,7 @@ int main(int argc, char *argv[])
94 umask(0007); 120 umask(0007);
95 121
96 if ((cmd = GET_ARGV()) == NULL) 122 if ((cmd = GET_ARGV()) == NULL)
97 return usage(); 123 return usage(cmd);
98 124
99 if (strcmp(cmd, "init") == 0) { /** **/ 125 if (strcmp(cmd, "init") == 0) { /** **/
100 /* initialize the data storage */ 126 /* initialize the data storage */
@@ -106,7 +132,7 @@ int main(int argc, char *argv[])
106 132
107 if ((basedir = getenv("SNAC_BASEDIR")) == NULL) { 133 if ((basedir = getenv("SNAC_BASEDIR")) == NULL) {
108 if ((basedir = GET_ARGV()) == NULL) 134 if ((basedir = GET_ARGV()) == NULL)
109 return usage(); 135 return usage(cmd);
110 } 136 }
111 137
112 if (strcmp(cmd, "upgrade") == 0) { /** **/ 138 if (strcmp(cmd, "upgrade") == 0) { /** **/
@@ -143,6 +169,11 @@ int main(int argc, char *argv[])
143 return 0; 169 return 0;
144 } 170 }
145 171
172 if (strcmp(cmd, "poll_hashtag_rss") == 0) { /** **/
173 rss_poll_hashtags();
174 return 0;
175 }
176
146 if (strcmp(cmd, "state") == 0) { /** **/ 177 if (strcmp(cmd, "state") == 0) { /** **/
147 xs *shm_name = NULL; 178 xs *shm_name = NULL;
148 srv_state *p_state = srv_state_op(&shm_name, 1); 179 srv_state *p_state = srv_state_op(&shm_name, 1);
@@ -167,7 +198,7 @@ int main(int argc, char *argv[])
167 } 198 }
168 199
169 if ((user = GET_ARGV()) == NULL) 200 if ((user = GET_ARGV()) == NULL)
170 return usage(); 201 return usage(cmd);
171 202
172 if (strcmp(cmd, "block") == 0) { /** **/ 203 if (strcmp(cmd, "block") == 0) { /** **/
173 int ret = instance_block(user); 204 int ret = instance_block(user);
@@ -279,6 +310,11 @@ int main(int argc, char *argv[])
279 return 0; 310 return 0;
280 } 311 }
281 312
313 if (strcmp(cmd, "export_posts") == 0) { /** **/
314 export_posts(&snac);
315 return 0;
316 }
317
282 if (strcmp(cmd, "import_csv") == 0) { /** **/ 318 if (strcmp(cmd, "import_csv") == 0) { /** **/
283 import_csv(&snac); 319 import_csv(&snac);
284 return 0; 320 return 0;
@@ -300,7 +336,7 @@ int main(int argc, char *argv[])
300 } 336 }
301 337
302 if ((url = GET_ARGV()) == NULL) 338 if ((url = GET_ARGV()) == NULL)
303 return usage(); 339 return usage(cmd);
304 340
305 if (strcmp(cmd, "list_members") == 0) { /** **/ 341 if (strcmp(cmd, "list_members") == 0) { /** **/
306 xs *lid = list_maint(&snac, url, 4); 342 xs *lid = list_maint(&snac, url, 4);
@@ -323,7 +359,7 @@ int main(int argc, char *argv[])
323 return 0; 359 return 0;
324 } 360 }
325 361
326 if (strcmp(cmd, "create_list") == 0) { /** **/ 362 if (strcmp(cmd, "list_create") == 0) { /** **/
327 xs *lid = list_maint(&snac, url, 4); 363 xs *lid = list_maint(&snac, url, 4);
328 364
329 if (lid == NULL) { 365 if (lid == NULL) {
@@ -336,7 +372,7 @@ int main(int argc, char *argv[])
336 return 0; 372 return 0;
337 } 373 }
338 374
339 if (strcmp(cmd, "delete_list") == 0) { /** **/ 375 if (strcmp(cmd, "list_remove") == 0) { /** **/
340 xs *lid = list_maint(&snac, url, 4); 376 xs *lid = list_maint(&snac, url, 4);
341 377
342 if (lid != NULL) { 378 if (lid != NULL) {
@@ -744,12 +780,24 @@ int main(int argc, char *argv[])
744 xs *msg = NULL; 780 xs *msg = NULL;
745 xs *c_msg = NULL; 781 xs *c_msg = NULL;
746 xs *attl = xs_list_new(); 782 xs *attl = xs_list_new();
747 char *fn = NULL; 783 const char *fn = NULL;
784 const char *in_reply_to = NULL;
785 const char **next = NULL;
748 786
749 /* iterate possible attachments */ 787 /* iterate possible attachments */
750 while ((fn = GET_ARGV())) { 788 while ((fn = GET_ARGV())) {
751 FILE *f; 789 FILE *f;
752 790
791 if (next) {
792 *next = fn;
793 next = NULL;
794 }
795 else
796 if (strcmp(fn, "-r") == 0) {
797 /* next argument is an inReplyTo */
798 next = &in_reply_to;
799 }
800 else
753 if ((f = fopen(fn, "rb")) != NULL) { 801 if ((f = fopen(fn, "rb")) != NULL) {
754 /* get the file size and content */ 802 /* get the file size and content */
755 fseek(f, 0, SEEK_END); 803 fseek(f, 0, SEEK_END);
@@ -759,8 +807,10 @@ int main(int argc, char *argv[])
759 fclose(f); 807 fclose(f);
760 808
761 char *ext = strrchr(fn, '.'); 809 char *ext = strrchr(fn, '.');
762 xs *hash = xs_md5_hex(fn, strlen(fn)); 810 char rnd[32];
763 xs *id = xs_fmt("%s%s", hash, ext); 811 xs_rnd_buf(rnd, sizeof(rnd));
812 xs *hash = xs_md5_hex(rnd, sizeof(rnd));
813 xs *id = xs_fmt("post-%s%s", hash, ext ? ext : "");
764 xs *url = xs_fmt("%s/s/%s", snac.actor, id); 814 xs *url = xs_fmt("%s/s/%s", snac.actor, id);
765 815
766 /* store */ 816 /* store */
@@ -821,7 +871,7 @@ int main(int argc, char *argv[])
821 if (strcmp(cmd, "note_unlisted") == 0) 871 if (strcmp(cmd, "note_unlisted") == 0)
822 scope = 2; 872 scope = 2;
823 873
824 msg = msg_note(&snac, content, NULL, NULL, attl, scope, getenv("LANG"), NULL); 874 msg = msg_note(&snac, content, NULL, in_reply_to, attl, scope, getenv("LANG"), NULL);
825 875
826 c_msg = msg_create(&snac, msg); 876 c_msg = msg_create(&snac, msg);
827 877
diff --git a/mastoapi.c b/mastoapi.c
index a7d9c34..7fb995a 100644
--- a/mastoapi.c
+++ b/mastoapi.c
@@ -15,6 +15,7 @@
15#include "xs_url.h" 15#include "xs_url.h"
16#include "xs_mime.h" 16#include "xs_mime.h"
17#include "xs_match.h" 17#include "xs_match.h"
18#include "xs_unicode.h"
18 19
19#include "snac.h" 20#include "snac.h"
20 21
@@ -381,7 +382,7 @@ int oauth_post_handler(const xs_dict *req, const char *q_path,
381 } 382 }
382 } 383 }
383 384
384 /* no code? 385 /* no code?
385 I'm not sure of the impacts of this right now, but Subway Tooter does not 386 I'm not sure of the impacts of this right now, but Subway Tooter does not
386 provide a code so one must be generated */ 387 provide a code so one must be generated */
387 if (xs_is_null(code)){ 388 if (xs_is_null(code)){
@@ -1133,9 +1134,14 @@ xs_dict *mastoapi_status(snac *snac, const xs_dict *msg)
1133 bst = xs_dict_set(bst, "content", ""); 1134 bst = xs_dict_set(bst, "content", "");
1134 bst = xs_dict_set(bst, "reblog", st); 1135 bst = xs_dict_set(bst, "reblog", st);
1135 1136
1137 xs *b_id = xs_toupper_i(xs_dup(xs_dict_get(st, "id")));
1138 bst = xs_dict_set(bst, "id", b_id);
1139
1136 xs_free(st); 1140 xs_free(st);
1137 st = bst; 1141 st = bst;
1138 } 1142 }
1143 else
1144 xs_free(bst);
1139 } 1145 }
1140 1146
1141 return st; 1147 return st;
@@ -1338,9 +1344,9 @@ xs_list *mastoapi_timeline(snac *user, const xs_dict *args, const char *index_fn
1338 if ((f = fopen(index_fn, "r")) == NULL) 1344 if ((f = fopen(index_fn, "r")) == NULL)
1339 return out; 1345 return out;
1340 1346
1341 const char *max_id = xs_dict_get(args, "max_id"); 1347 const char *o_max_id = xs_dict_get(args, "max_id");
1342 const char *since_id = xs_dict_get(args, "since_id"); 1348 const char *o_since_id = xs_dict_get(args, "since_id");
1343 const char *min_id = xs_dict_get(args, "min_id"); /* unsupported old-to-new navigation */ 1349 const char *o_min_id = xs_dict_get(args, "min_id"); /* unsupported old-to-new navigation */
1344 const char *limit_s = xs_dict_get(args, "limit"); 1350 const char *limit_s = xs_dict_get(args, "limit");
1345 int (*iterator)(FILE *, char *); 1351 int (*iterator)(FILE *, char *);
1346 int initial_status = 0; 1352 int initial_status = 0;
@@ -1348,6 +1354,10 @@ xs_list *mastoapi_timeline(snac *user, const xs_dict *args, const char *index_fn
1348 int limit = 0; 1354 int limit = 0;
1349 int cnt = 0; 1355 int cnt = 0;
1350 1356
1357 xs *max_id = o_max_id ? xs_tolower_i(xs_dup(o_max_id)) : NULL;
1358 xs *since_id = o_since_id ? xs_tolower_i(xs_dup(o_since_id)) : NULL;
1359 xs *min_id = o_min_id ? xs_tolower_i(xs_dup(o_min_id)) : NULL;
1360
1351 if (!xs_is_null(limit_s)) 1361 if (!xs_is_null(limit_s))
1352 limit = atoi(limit_s); 1362 limit = atoi(limit_s);
1353 1363
@@ -1371,7 +1381,7 @@ xs_list *mastoapi_timeline(snac *user, const xs_dict *args, const char *index_fn
1371 /* only return entries older that max_id */ 1381 /* only return entries older that max_id */
1372 if (max_id) { 1382 if (max_id) {
1373 if (strcmp(md5, MID_TO_MD5(max_id)) == 0) { 1383 if (strcmp(md5, MID_TO_MD5(max_id)) == 0) {
1374 max_id = NULL; 1384 max_id = xs_free(max_id);
1375 if (ascending) 1385 if (ascending)
1376 break; 1386 break;
1377 } 1387 }
@@ -1384,7 +1394,7 @@ xs_list *mastoapi_timeline(snac *user, const xs_dict *args, const char *index_fn
1384 if (strcmp(md5, MID_TO_MD5(since_id)) == 0) { 1394 if (strcmp(md5, MID_TO_MD5(since_id)) == 0) {
1385 if (!ascending) 1395 if (!ascending)
1386 break; 1396 break;
1387 since_id = NULL; 1397 since_id = xs_free(since_id);
1388 } 1398 }
1389 if (ascending) 1399 if (ascending)
1390 continue; 1400 continue;
@@ -1637,7 +1647,7 @@ int mastoapi_get_handler(const xs_dict *req, const char *q_path,
1637 const char *aq = xs_dict_get(args, "q"); 1647 const char *aq = xs_dict_get(args, "q");
1638 1648
1639 if (!xs_is_null(aq)) { 1649 if (!xs_is_null(aq)) {
1640 xs *q = xs_tolower_i(xs_dup(aq)); 1650 xs *q = xs_utf8_to_lower(aq);
1641 out = xs_list_new(); 1651 out = xs_list_new();
1642 xs *wing = following_list(&snac1); 1652 xs *wing = following_list(&snac1);
1643 xs *wers = follower_list(&snac1); 1653 xs *wers = follower_list(&snac1);
@@ -1780,7 +1790,7 @@ int mastoapi_get_handler(const xs_dict *req, const char *q_path,
1780 } 1790 }
1781 else 1791 else
1782 if (strcmp(opt, "statuses") == 0) { 1792 if (strcmp(opt, "statuses") == 0) {
1783 /* we don't serve statuses of others; return the empty list */ 1793 /* we don't serve statuses of others; return the empty list */
1784 out = xs_list_new(); 1794 out = xs_list_new();
1785 } 1795 }
1786 else 1796 else
@@ -1999,7 +2009,7 @@ int mastoapi_get_handler(const xs_dict *req, const char *q_path,
1999 } 2009 }
2000 else 2010 else
2001 if (strcmp(cmd, "/v2/filters") == 0) { /** **/ 2011 if (strcmp(cmd, "/v2/filters") == 0) { /** **/
2002 /* snac will never have filters 2012 /* snac will never have filters
2003 * but still, without a v2 endpoint a short delay is introduced 2013 * but still, without a v2 endpoint a short delay is introduced
2004 * in some apps */ 2014 * in some apps */
2005 *body = xs_dup("[]"); 2015 *body = xs_dup("[]");
@@ -2331,19 +2341,36 @@ int mastoapi_get_handler(const xs_dict *req, const char *q_path,
2331 status = HTTP_STATUS_OK; 2341 status = HTTP_STATUS_OK;
2332 } 2342 }
2333 else 2343 else
2344 if (strcmp(cmd, "/v1/instance/extended_description") == 0) { /** **/
2345 xs *d = xs_dict_new();
2346 xs *greeting = xs_fmt("%s/greeting.html", srv_basedir);
2347 time_t t = mtime(greeting);
2348 xs *updated_at = xs_str_iso_date(t);
2349 xs *content = xs_replace(snac_blurb, "%host%", xs_dict_get(srv_config, "host"));
2350
2351 d = xs_dict_set(d, "updated_at", updated_at);
2352 d = xs_dict_set(d, "content", content);
2353
2354 *body = xs_json_dumps(d, 4);
2355 *ctype = "application/json";
2356 status = HTTP_STATUS_OK;
2357 }
2358 else
2334 if (xs_startswith(cmd, "/v1/statuses/")) { /** **/ 2359 if (xs_startswith(cmd, "/v1/statuses/")) { /** **/
2335 /* information about a status */ 2360 /* information about a status */
2336 if (logged_in) { 2361 if (logged_in) {
2337 xs *l = xs_split(cmd, "/"); 2362 xs *l = xs_split(cmd, "/");
2338 const char *id = xs_list_get(l, 3); 2363 const char *oid = xs_list_get(l, 3);
2339 const char *op = xs_list_get(l, 4); 2364 const char *op = xs_list_get(l, 4);
2340 2365
2341 if (!xs_is_null(id)) { 2366 if (!xs_is_null(oid)) {
2342 xs *msg = NULL; 2367 xs *msg = NULL;
2343 xs *out = NULL; 2368 xs *out = NULL;
2344 2369
2345 /* skip the 'fake' part of the id */ 2370 /* skip the 'fake' part of the id */
2346 id = MID_TO_MD5(id); 2371 oid = MID_TO_MD5(oid);
2372
2373 xs *id = xs_tolower_i(xs_dup(oid));
2347 2374
2348 if (valid_status(object_get_by_md5(id, &msg))) { 2375 if (valid_status(object_get_by_md5(id, &msg))) {
2349 if (op == NULL) { 2376 if (op == NULL) {
@@ -2459,7 +2486,7 @@ int mastoapi_get_handler(const xs_dict *req, const char *q_path,
2459 if (logged_in) { 2486 if (logged_in) {
2460 const xs_list *timeline = xs_dict_get(args, "timeline[]"); 2487 const xs_list *timeline = xs_dict_get(args, "timeline[]");
2461 xs_str *json = NULL; 2488 xs_str *json = NULL;
2462 if (!xs_is_null(timeline)) 2489 if (!xs_is_null(timeline))
2463 json = xs_json_dumps(markers_get(&snac1, timeline), 4); 2490 json = xs_json_dumps(markers_get(&snac1, timeline), 4);
2464 2491
2465 if (!xs_is_null(json)) 2492 if (!xs_is_null(json))
@@ -2475,6 +2502,40 @@ int mastoapi_get_handler(const xs_dict *req, const char *q_path,
2475 } 2502 }
2476 else 2503 else
2477 if (strcmp(cmd, "/v1/followed_tags") == 0) { /** **/ 2504 if (strcmp(cmd, "/v1/followed_tags") == 0) { /** **/
2505 if (logged_in) {
2506 xs *r = xs_list_new();
2507 const xs_list *followed_hashtags = xs_dict_get_def(snac1.config,
2508 "followed_hashtags", xs_stock(XSTYPE_LIST));
2509 const char *hashtag;
2510
2511 xs_list_foreach(followed_hashtags, hashtag) {
2512 if (*hashtag == '#') {
2513 xs *d = xs_dict_new();
2514 xs *s = xs_fmt("%s?t=%s", srv_baseurl, hashtag + 1);
2515
2516 d = xs_dict_set(d, "name", hashtag + 1);
2517 d = xs_dict_set(d, "url", s);
2518 d = xs_dict_set(d, "history", xs_stock(XSTYPE_LIST));
2519
2520 r = xs_list_append(r, d);
2521 }
2522 }
2523
2524 *body = xs_json_dumps(r, 4);
2525 *ctype = "application/json";
2526 status = HTTP_STATUS_OK;
2527 }
2528 else
2529 status = HTTP_STATUS_UNAUTHORIZED;
2530 }
2531 else
2532 if (strcmp(cmd, "/v1/blocks") == 0) { /** **/
2533 *body = xs_dup("[]");
2534 *ctype = "application/json";
2535 status = HTTP_STATUS_OK;
2536 }
2537 else
2538 if (strcmp(cmd, "/v1/mutes") == 0) { /** **/
2478 *body = xs_dup("[]"); 2539 *body = xs_dup("[]");
2479 *ctype = "application/json"; 2540 *ctype = "application/json";
2480 status = HTTP_STATUS_OK; 2541 status = HTTP_STATUS_OK;
@@ -2507,9 +2568,7 @@ int mastoapi_get_handler(const xs_dict *req, const char *q_path,
2507 /* reply something only for offset 0; otherwise, 2568 /* reply something only for offset 0; otherwise,
2508 apps like Tusky keep asking again and again */ 2569 apps like Tusky keep asking again and again */
2509 if (xs_startswith(q, "https://")) { 2570 if (xs_startswith(q, "https://")) {
2510 xs *md5 = xs_md5_hex(q, strlen(q)); 2571 if (!timeline_here(&snac1, q)) {
2511
2512 if (!timeline_here(&snac1, md5)) {
2513 xs *object = NULL; 2572 xs *object = NULL;
2514 int status; 2573 int status;
2515 2574
@@ -2979,8 +3038,10 @@ int mastoapi_post_handler(const xs_dict *req, const char *q_path,
2979 3038
2980 if (*fn != '\0') { 3039 if (*fn != '\0') {
2981 char *ext = strrchr(fn, '.'); 3040 char *ext = strrchr(fn, '.');
2982 xs *hash = xs_md5_hex(fn, strlen(fn)); 3041 char rnd[32];
2983 xs *id = xs_fmt("%s%s", hash, ext); 3042 xs_rnd_buf(rnd, sizeof(rnd));
3043 xs *hash = xs_md5_hex(rnd, sizeof(rnd));
3044 xs *id = xs_fmt("post-%s%s", hash, ext ? ext : "");
2984 xs *url = xs_fmt("%s/s/%s", snac.actor, id); 3045 xs *url = xs_fmt("%s/s/%s", snac.actor, id);
2985 int fo = xs_number_get(xs_list_get(file, 1)); 3046 int fo = xs_number_get(xs_list_get(file, 1));
2986 int fs = xs_number_get(xs_list_get(file, 2)); 3047 int fs = xs_number_get(xs_list_get(file, 2));
@@ -3227,7 +3288,7 @@ int mastoapi_post_handler(const xs_dict *req, const char *q_path,
3227 if (!xs_is_null(home)) 3288 if (!xs_is_null(home))
3228 home_marker = xs_dict_get(home, "last_read_id"); 3289 home_marker = xs_dict_get(home, "last_read_id");
3229 } 3290 }
3230 3291
3231 const xs_str *notify_marker = xs_dict_get(args, "notifications[last_read_id]"); 3292 const xs_str *notify_marker = xs_dict_get(args, "notifications[last_read_id]");
3232 if (xs_is_null(notify_marker)) { 3293 if (xs_is_null(notify_marker)) {
3233 const xs_dict *notify = xs_dict_get(args, "notifications"); 3294 const xs_dict *notify = xs_dict_get(args, "notifications");
@@ -3296,6 +3357,54 @@ int mastoapi_post_handler(const xs_dict *req, const char *q_path,
3296 } 3357 }
3297 } 3358 }
3298 else 3359 else
3360 if (xs_startswith(cmd, "/v1/tags/")) { /** **/
3361 if (logged_in) {
3362 xs *l = xs_split(cmd, "/");
3363 const char *i_tag = xs_list_get(l, 3);
3364 const char *cmd = xs_list_get(l, 4);
3365
3366 status = HTTP_STATUS_UNPROCESSABLE_CONTENT;
3367
3368 if (xs_is_string(i_tag) && xs_is_string(cmd)) {
3369 int ok = 0;
3370
3371 xs *tag = xs_fmt("#%s", i_tag);
3372 xs *followed_hashtags = xs_dup(xs_dict_get_def(snac.config,
3373 "followed_hashtags", xs_stock(XSTYPE_LIST)));
3374
3375 if (strcmp(cmd, "follow") == 0) {
3376 followed_hashtags = xs_list_append(followed_hashtags, tag);
3377 ok = 1;
3378 }
3379 else
3380 if (strcmp(cmd, "unfollow") == 0) {
3381 int off = xs_list_in(followed_hashtags, tag);
3382
3383 if (off != -1)
3384 followed_hashtags = xs_list_del(followed_hashtags, off);
3385
3386 ok = 1;
3387 }
3388
3389 if (ok) {
3390 /* update */
3391 xs_dict_set(snac.config, "followed_hashtags", followed_hashtags);
3392 user_persist(&snac, 0);
3393
3394 xs *d = xs_dict_new();
3395 xs *s = xs_fmt("%s?t=%s", srv_baseurl, i_tag);
3396 d = xs_dict_set(d, "name", i_tag);
3397 d = xs_dict_set(d, "url", s);
3398 d = xs_dict_set(d, "history", xs_stock(XSTYPE_LIST));
3399
3400 *body = xs_json_dumps(d, 4);
3401 *ctype = "application/json";
3402 status = HTTP_STATUS_OK;
3403 }
3404 }
3405 }
3406 }
3407 else
3299 status = HTTP_STATUS_UNPROCESSABLE_CONTENT; 3408 status = HTTP_STATUS_UNPROCESSABLE_CONTENT;
3300 3409
3301 /* user cleanup */ 3410 /* user cleanup */
diff --git a/po/ru.po b/po/ru.po
index 4d5d9b4..f95b0d1 100644
--- a/po/ru.po
+++ b/po/ru.po
@@ -126,7 +126,7 @@ msgstr "люди"
126 126
127#: html.c:936 127#: html.c:936
128msgid "instance" 128msgid "instance"
129msgstr "ÑкземплÑÑ€" 129msgstr "Ñервер"
130 130
131#: html.c:945 131#: html.c:945
132msgid "" 132msgid ""
@@ -679,12 +679,12 @@ msgstr "Ðичего не найдено Ð´Ð»Ñ '%s'"
679 679
680#: html.c:3929 680#: html.c:3929
681msgid "Showing instance timeline" 681msgid "Showing instance timeline"
682msgstr "Показываем ленту инÑÑанÑии" 682msgstr "Показываем ленту ÑеÑвеÑа"
683 683
684#: html.c:4012 684#: html.c:4012
685#, c-format 685#, c-format
686msgid "Showing timeline for list '%s'" 686msgid "Showing timeline for list '%s'"
687msgstr "Показываем Ð»ÐµÐ½Ñ‚Ñ Ð¸Ð½Ñтанции Ð´Ð»Ñ ÑпиÑка '%s'" 687msgstr "Показываем ленту Ð´Ð»Ñ ÑпиÑка '%s'"
688 688
689#: httpd.c:250 689#: httpd.c:250
690#, c-format 690#, c-format
@@ -693,7 +693,7 @@ msgstr "Результаты поиÑка Ð´Ð»Ñ Ñ‚ÐµÐ³Ð° #%s"
693 693
694#: httpd.c:259 694#: httpd.c:259
695msgid "Recent posts by users in this instance" 695msgid "Recent posts by users in this instance"
696msgstr "ПоÑледние ÑÐ¾Ð¾Ð±Ñ‰ÐµÐ½Ð¸Ñ Ð½Ð° Ñтой инÑÑанÑии" 696msgstr "ПоÑледние ÑÐ¾Ð¾Ð±Ñ‰ÐµÐ½Ð¸Ñ Ð½Ð° Ñтом ÑеÑвеÑе"
697 697
698#: html.c:1603 698#: html.c:1603
699msgid "Blocked hashtags..." 699msgid "Blocked hashtags..."
diff --git a/po/uk.po b/po/uk.po
new file mode 100644
index 0000000..fd59678
--- /dev/null
+++ b/po/uk.po
@@ -0,0 +1,768 @@
1# snac message translation file
2#
3msgid ""
4msgstr ""
5"Project-Id-Version: snac\n"
6"Last-Translator: grunfink\n"
7"Language: uk\n"
8"Content-Type: text/plain; charset=UTF-8\n"
9"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && "
10"n%10<=4 && (n%100<12 || n%100>14) ? 1 : 2);\n"
11"POT-Creation-Date: \n"
12"PO-Revision-Date: \n"
13"Language-Team: \n"
14"MIME-Version: 1.0\n"
15"Content-Transfer-Encoding: 8bit\n"
16"X-Generator: Poedit 3.0\n"
17
18#: html.c:384
19msgid "Sensitive content: "
20msgstr "Делікатний вміÑÑ‚: "
21
22#: html.c:392
23msgid "Sensitive content description"
24msgstr "ÐžÐ¿Ð¸Ñ Ð´ÐµÐ»Ñ–ÐºÐ°Ñ‚Ð½Ð¾Ð³Ð¾ вміÑту"
25
26#: html.c:405
27msgid "Only for mentioned people: "
28msgstr "Тільки Ð´Ð»Ñ Ð·Ð°Ð·Ð½Ð°Ñ‡ÐµÐ½Ð¸Ñ… оÑіб: "
29
30#: html.c:428
31msgid "Reply to (URL): "
32msgstr "ВідповіÑти до (URL): "
33
34#: html.c:437
35msgid "Don't send, but store as a draft"
36msgstr "Ðе надÑилати, але зберігти Ñк чернетку"
37
38#: html.c:438
39msgid "Draft:"
40msgstr "Чернетка:"
41
42#: html.c:494
43msgid "Attachments..."
44msgstr "ВкладеннÑ..."
45
46#: html.c:517
47msgid "File:"
48msgstr "Файл:"
49
50#: html.c:521
51msgid "Clear this field to delete the attachment"
52msgstr "ОчиÑтіть це поле, щоб видалити вкладеннÑ"
53
54#: html.c:530 html.c:555
55msgid "Attachment description"
56msgstr "ÐžÐ¿Ð¸Ñ Ð²ÐºÐ»Ð°Ð´ÐµÐ½Ð½Ñ"
57
58#: html.c:566
59msgid "Poll..."
60msgstr "ОпитуваннÑ..."
61
62#: html.c:568
63msgid "Poll options (one per line, up to 8):"
64msgstr "Варіанти відповідей (по одному в Ñ€Ñдку, до 8):"
65
66#: html.c:580
67msgid "One choice"
68msgstr "Єдиний вибір"
69
70#: html.c:583
71msgid "Multiple choices"
72msgstr "Декілька варіантів вибору"
73
74#: html.c:589
75msgid "End in 5 minutes"
76msgstr "Ð—Ð°ÐºÑ–Ð½Ñ‡ÐµÐ½Ð½Ñ Ñ‡ÐµÑ€ÐµÐ· 5 хвилин"
77
78#: html.c:593
79msgid "End in 1 hour"
80msgstr "Ð—Ð°ÐºÑ–Ð½Ñ‡ÐµÐ½Ð½Ñ Ñ‡ÐµÑ€ÐµÐ· 1 годину"
81
82#: html.c:596
83msgid "End in 1 day"
84msgstr "Ð—Ð°ÐºÑ–Ð½Ñ‡ÐµÐ½Ð½Ñ Ñ‡ÐµÑ€ÐµÐ· 1 день"
85
86#: html.c:604
87msgid "Post"
88msgstr "ÐадіÑлати"
89
90#: html.c:701 html.c:708
91msgid "Site description"
92msgstr "ÐžÐ¿Ð¸Ñ Ñайту"
93
94#: html.c:719
95msgid "Admin email"
96msgstr "Пошта админа"
97
98#: html.c:732
99msgid "Admin account"
100msgstr "Обліковий Ð·Ð°Ð¿Ð¸Ñ Ð°Ð´Ð¼Ñ–Ð½Ð°"
101
102#: html.c:800 html.c:1136
103#, c-format
104msgid "%d following, %d followers"
105msgstr "%d підпиÑок, %d підпиÑників"
106
107#: html.c:890
108msgid "RSS"
109msgstr "RSS"
110
111#: html.c:895 html.c:923
112msgid "private"
113msgstr "оÑобиÑте"
114
115#: html.c:919
116msgid "public"
117msgstr "публічне"
118
119#: html.c:927
120msgid "notifications"
121msgstr "повідомленнÑ"
122
123#: html.c:932
124msgid "people"
125msgstr "люди"
126
127#: html.c:936
128msgid "instance"
129msgstr "Ñервер"
130
131#: html.c:945
132msgid ""
133"Search posts by URL or content (regular expression), @user@host accounts, or "
134"#tag"
135msgstr ""
136"Шукати допиÑи за URL або вміÑтом (регулÑрний вираз), акаунтами @user@host "
137"або #тегом"
138
139#: html.c:946
140msgid "Content search"
141msgstr "Пошук за вміÑтом"
142
143#: html.c:1068
144msgid "verified link"
145msgstr "перевірене поÑиланнÑ"
146
147#: html.c:1125 html.c:2540 html.c:2553 html.c:2562
148msgid "Location: "
149msgstr "МіÑце знаходженнÑ: "
150
151#: html.c:1161
152msgid "New Post..."
153msgstr "Ðовий допиÑ..."
154
155#: html.c:1163
156msgid "What's on your mind?"
157msgstr "Що у Ð²Ð°Ñ Ð½Ð° думці?"
158
159#: html.c:1172
160msgid "Operations..."
161msgstr "Дії..."
162
163#: html.c:1187 html.c:1788 html.c:3193 html.c:4578
164msgid "Follow"
165msgstr "ПідпиÑатиÑÑ"
166
167#: html.c:1189
168msgid "(by URL or user@host)"
169msgstr "(за URL або user@host)"
170
171#: html.c:1204 html.c:1764 html.c:4527
172msgid "Boost"
173msgstr "ПроÑувати"
174
175#: html.c:1206 html.c:1223
176msgid "(by URL)"
177msgstr "(за URL)"
178
179#: html.c:1221 html.c:1743 html.c:4518
180msgid "Like"
181msgstr "Вподобайка"
182
183#: html.c:1347
184msgid "User Settings..."
185msgstr "ÐÐ°Ð»Ð°ÑˆÑ‚ÑƒÐ²Ð°Ð½Ð½Ñ ÐºÐ¾Ñ€Ð¸Ñтувача..."
186
187#: html.c:1356
188msgid "Display name:"
189msgstr "Видиме ім'Ñ:"
190
191#: html.c:1362
192msgid "Your name"
193msgstr "Ваше ім'Ñ"
194
195#: html.c:1364
196msgid "Avatar: "
197msgstr "Ðватар: "
198
199#: html.c:1372
200msgid "Delete current avatar"
201msgstr "Видалити поточний аватар"
202
203#: html.c:1374
204msgid "Header image (banner): "
205msgstr "Ð—Ð¾Ð±Ñ€Ð°Ð¶ÐµÐ½Ð½Ñ Ð·Ð°Ð³Ð¾Ð»Ð¾Ð²ÐºÐ° (банер): "
206
207#: html.c:1382
208msgid "Delete current header image"
209msgstr "Видалити поточне Ð·Ð¾Ð±Ñ€Ð°Ð¶ÐµÐ½Ð½Ñ Ð·Ð°Ð³Ð¾Ð»Ð¾Ð²ÐºÐ°"
210
211#: html.c:1384
212msgid "Bio:"
213msgstr "Про Ñебе:"
214
215#: html.c:1390
216msgid "Write about yourself here..."
217msgstr "Ðапишіть про Ñебе тут..."
218
219#: html.c:1399
220msgid "Always show sensitive content"
221msgstr "Завжди показувати делікатний вміÑÑ‚"
222
223#: html.c:1401
224msgid "Email address for notifications:"
225msgstr "Поштова адреÑа Ð´Ð»Ñ Ð¿Ð¾Ð²Ñ–Ð´Ð¾Ð¼Ð»ÐµÐ½ÑŒ:"
226
227#: html.c:1409
228msgid "Telegram notifications (bot key and chat id):"
229msgstr "ÐŸÐ¾Ð²Ñ–Ð´Ð¾Ð¼Ð»ÐµÐ½Ð½Ñ Ð² Telegram (ключ бота та id чату):"
230
231#: html.c:1423
232msgid "ntfy notifications (ntfy server and token):"
233msgstr "ÐŸÐ¾Ð²Ñ–Ð´Ð¾Ð¼Ð»ÐµÐ½Ð½Ñ Ð² ntfy (Ñервер та токен ntfy):"
234
235#: html.c:1437
236msgid "Maximum days to keep posts (0: server settings):"
237msgstr "МакÑимальний Ñ‡Ð°Ñ Ð´Ð»Ñ Ð·Ð±ÐµÑ€Ñ–Ð³Ð°Ð½Ð½Ñ Ð´Ð¾Ð¿Ð¸Ñів (0: Ð½Ð°Ð»Ð°ÑˆÑ‚ÑƒÐ²Ð°Ð½Ð½Ñ Ñервера):"
238
239#: html.c:1451
240msgid "Drop direct messages from people you don't follow"
241msgstr "ВідхилÑти оÑобиÑті Ð¿Ð¾Ð²Ñ–Ð´Ð¾Ð¼Ð»ÐµÐ½Ð½Ñ Ð²Ñ–Ð´ незнайомців"
242
243#: html.c:1460
244msgid "This account is a bot"
245msgstr "Цей акаунт є ботом"
246
247#: html.c:1469
248msgid "Auto-boost all mentions to this account"
249msgstr "Ðвтоматично проÑувати вÑÑ– згадки цього акаунта"
250
251#: html.c:1478
252msgid "This account is private (posts are not shown through the web)"
253msgstr "Це закритий акаунт (допиÑи не показуютьÑÑ Ð² мережі)"
254
255#: html.c:1488
256msgid "Collapse top threads by default"
257msgstr "Згорнути потоки за замовчуваннÑм"
258
259#: html.c:1497
260msgid "Follow requests must be approved"
261msgstr "Запити на підпиÑку мають бути Ñхвалені"
262
263#: html.c:1506
264msgid "Publish follower and following metrics"
265msgstr "Публікувати метрики підпиÑок та підпиÑників"
266
267#: html.c:1508
268msgid "Current location:"
269msgstr "Поточне міÑцезнаходженнÑ:"
270
271#: html.c:1522
272msgid "Profile metadata (key=value pairs in each line):"
273msgstr "Метадані профілю (пари ключ=Ð·Ð½Ð°Ñ‡ÐµÐ½Ð½Ñ Ð² кожному Ñ€Ñдку):"
274
275#: html.c:1533
276msgid "Web interface language:"
277msgstr "Мова інтерфейÑу:"
278
279#: html.c:1543
280msgid "New password:"
281msgstr "Ðовий пароль:"
282
283#: html.c:1550
284msgid "Repeat new password:"
285msgstr "Повторити новий пароль:"
286
287#: html.c:1560
288msgid "Update user info"
289msgstr "ÐžÐ½Ð¾Ð²Ð»ÐµÐ½Ð½Ñ Ð´Ð°Ð½Ð¸Ñ… кориÑтувача"
290
291#: html.c:1571
292msgid "Followed hashtags..."
293msgstr "ПідпиÑані хештеги..."
294
295#: html.c:1573 html.c:1605
296msgid "One hashtag per line"
297msgstr "Один хештег на Ñ€Ñдок"
298
299#: html.c:1594 html.c:1626
300msgid "Update hashtags"
301msgstr "Оновити хештеги"
302
303#: html.c:1743
304msgid "Say you like this post"
305msgstr "Позначте Ð´Ð¾Ð¿Ð¸Ñ Ñк вподобаний"
306
307#: html.c:1748 html.c:4536
308msgid "Unlike"
309msgstr "Більше не подобаєтьÑÑ"
310
311#: html.c:1748
312msgid "Nah don't like it that much"
313msgstr "Мені це не дуже подобаєтьÑÑ"
314
315#: html.c:1754 html.c:4673
316msgid "Unpin"
317msgstr "Відкріпити"
318
319#: html.c:1754
320msgid "Unpin this post from your timeline"
321msgstr "Відкріпити цей Ð´Ð¾Ð¿Ð¸Ñ Ð·Ñ– влаÑної Ñтрічки"
322
323#: html.c:1757 html.c:4668
324msgid "Pin"
325msgstr "Закріпити"
326
327#: html.c:1757
328msgid "Pin this post to the top of your timeline"
329msgstr "Закріпити цей Ð´Ð¾Ð¿Ð¸Ñ Ð´Ð¾ влаÑної Ñтрічки"
330
331#: html.c:1764
332msgid "Announce this post to your followers"
333msgstr "ПоділитиÑÑ Ñ†Ð¸Ð¼ допиÑом зі Ñвоїми підпиÑниками"
334
335#: html.c:1769 html.c:4544
336msgid "Unboost"
337msgstr "СкаÑувати проÑуваннÑ"
338
339#: html.c:1769
340msgid "I regret I boosted this"
341msgstr "Я шкодую, що проÑував це"
342
343#: html.c:1775 html.c:4683
344msgid "Unbookmark"
345msgstr "Видалити з закладок"
346
347#: html.c:1775
348msgid "Delete this post from your bookmarks"
349msgstr "Видалити цей Ð´Ð¾Ð¿Ð¸Ñ Ð· закладок"
350
351#: html.c:1778 html.c:4678
352msgid "Bookmark"
353msgstr "Додати в закладки"
354
355#: html.c:1778
356msgid "Add this post to your bookmarks"
357msgstr "Додайте цей Ð´Ð¾Ð¿Ð¸Ñ Ð² закладки"
358
359#: html.c:1784 html.c:3179 html.c:3367 html.c:4591
360msgid "Unfollow"
361msgstr "ВідпиÑатиÑÑ"
362
363#: html.c:1784 html.c:3180
364msgid "Stop following this user's activity"
365msgstr "ВідпиÑатиÑÑ Ð²Ñ–Ð´ цього кориÑтувача"
366
367#: html.c:1788 html.c:3194
368msgid "Start following this user's activity"
369msgstr "ПідпиÑатиÑÑ Ð´Ð¾ цього кориÑтувача"
370
371#: html.c:1794 html.c:4621
372msgid "Unfollow Group"
373msgstr "ВідпиÑатиÑÑ Ð²Ñ–Ð´ групи"
374
375#: html.c:1795
376msgid "Stop following this group or channel"
377msgstr "ВідпиÑатиÑÑ Ð²Ñ–Ð´ групи чи канала"
378
379#: html.c:1799 html.c:4608
380msgid "Follow Group"
381msgstr "ПідпиÑатиÑÑ Ð½Ð° групу"
382
383#: html.c:1800
384msgid "Start following this group or channel"
385msgstr "ПідпиÑатиÑÑ Ð½Ð° групу чи канал"
386
387#: html.c:1805 html.c:3216 html.c:4552
388msgid "MUTE"
389msgstr "Заглушити"
390
391#: html.c:1806
392msgid "Block any activity from this user forever"
393msgstr "Ðазавжди заблокувати активніÑть цього кориÑтувача"
394
395#: html.c:1811 html.c:3198 html.c:4638
396msgid "Delete"
397msgstr "Видалити"
398
399#: html.c:1811
400msgid "Delete this post"
401msgstr "Видалити цей допиÑ"
402
403#: html.c:1814 html.c:4560
404msgid "Hide"
405msgstr "Приховати"
406
407#: html.c:1814
408msgid "Hide this post and its children"
409msgstr "Приховати цей Ð´Ð¾Ð¿Ð¸Ñ Ñ€Ð°Ð·Ð¾Ð¼ з обговореннÑм"
410
411#: html.c:1845
412msgid "Edit..."
413msgstr "Редагувати..."
414
415#: html.c:1865
416msgid "Reply..."
417msgstr "ВідповіÑти..."
418
419#: html.c:1916
420msgid "Truncated (too deep)"
421msgstr "Обрізано (занадто багато)"
422
423#: html.c:1925
424msgid "follows you"
425msgstr "підпиÑан на ваÑ"
426
427#: html.c:1988
428msgid "Pinned"
429msgstr "Закріплено"
430
431#: html.c:1996
432msgid "Bookmarked"
433msgstr "Додано до закладок"
434
435#: html.c:2004
436msgid "Poll"
437msgstr "ОпитуваннÑ"
438
439#: html.c:2011
440msgid "Voted"
441msgstr "ПроголоÑовано"
442
443#: html.c:2020
444msgid "Event"
445msgstr "ПодіÑ"
446
447#: html.c:2052 html.c:2081
448msgid "boosted"
449msgstr "проÑунуто"
450
451#: html.c:2097
452msgid "in reply to"
453msgstr "у відповідь на"
454
455#: html.c:2148
456msgid " [SENSITIVE CONTENT]"
457msgstr " [ДЕЛІКÐТÐИЙ ВМІСТ]"
458
459#: html.c:2325
460msgid "Vote"
461msgstr "ГолоÑ"
462
463#: html.c:2335
464msgid "Closed"
465msgstr "Завершено"
466
467#: html.c:2360
468msgid "Closes in"
469msgstr "ЗавершуєтьÑÑ Ñ‡ÐµÑ€ÐµÐ·"
470
471#: html.c:2441
472msgid "Video"
473msgstr "Відео"
474
475#: html.c:2456
476msgid "Audio"
477msgstr "Ðудіо"
478
479#: html.c:2484
480msgid "Attachment"
481msgstr "ВкладеннÑ"
482
483#: html.c:2498
484msgid "Alt..."
485msgstr "ОпиÑ..."
486
487#: html.c:2511
488msgid "Source channel or community"
489msgstr "Вихідний канал або Ñпільнота"
490
491#: html.c:2605
492msgid "Time: "
493msgstr "ЧаÑ: "
494
495#: html.c:2686
496msgid "Older..."
497msgstr "Раніше..."
498
499#: html.c:2788
500msgid "about this site"
501msgstr "про цей Ñайт"
502
503#: html.c:2790
504msgid "powered by "
505msgstr "на базі "
506
507#: html.c:2855
508msgid "Dismiss"
509msgstr "Відхилити"
510
511#: html.c:2872
512#, c-format
513msgid "Timeline for list '%s'"
514msgstr "Стрічки Ð´Ð»Ñ ÑпиÑку '%s'"
515
516#: html.c:2891 html.c:3944
517msgid "Pinned posts"
518msgstr "Закріплені допиÑи"
519
520#: html.c:2903 html.c:3959
521msgid "Bookmarked posts"
522msgstr "ДопиÑи у закладках"
523
524#: html.c:2915 html.c:3974
525msgid "Post drafts"
526msgstr "Чернетки допиÑів"
527
528#: html.c:2986
529msgid "No more unseen posts"
530msgstr "УÑе переглÑнуто"
531
532#: html.c:2990 html.c:3090
533msgid "Back to top"
534msgstr "ПовернутиÑÑ Ð´Ð¾ початку"
535
536#: html.c:3043
537msgid "History"
538msgstr "ІÑторіÑ"
539
540#: html.c:3095 html.c:3515
541msgid "More..."
542msgstr "Більше..."
543
544#: html.c:3184 html.c:4574
545msgid "Unlimit"
546msgstr "Без обмеженнÑ"
547
548#: html.c:3185
549msgid "Allow announces (boosts) from this user"
550msgstr "Дозволити проÑÑƒÐ²Ð°Ð½Ð½Ñ Ð²Ñ–Ð´ цього кориÑтувача"
551
552#: html.c:3188 html.c:4570
553msgid "Limit"
554msgstr "Обмежити"
555
556#: html.c:3189
557msgid "Block announces (boosts) from this user"
558msgstr "Заборонити проÑÑƒÐ²Ð°Ð½Ð½Ñ Ð²Ñ–Ð´ цього кориÑтувача"
559
560#: html.c:3198
561msgid "Delete this user"
562msgstr "Видалити кориÑтувача"
563
564#: html.c:3203 html.c:4688
565msgid "Approve"
566msgstr "Підтвердити"
567
568#: html.c:3204
569msgid "Approve this follow request"
570msgstr "Підтвердити цей запит на підпиÑку"
571
572#: html.c:3207 html.c:4712
573msgid "Discard"
574msgstr "Відхилити"
575
576#: html.c:3207
577msgid "Discard this follow request"
578msgstr "Відхилити цей запит на підпиÑку"
579
580#: html.c:3212 html.c:4556
581msgid "Unmute"
582msgstr "СкаÑувати глушіннÑ"
583
584#: html.c:3213
585msgid "Stop blocking activities from this user"
586msgstr "Припинити Ð³Ð»ÑƒÑˆÑ–Ð½Ð½Ñ Ð´Ñ–Ð¹ цього кориÑтувача"
587
588#: html.c:3217
589msgid "Block any activity from this user"
590msgstr "Заглушити вÑÑ– дії цього кориÑтувача"
591
592#: html.c:3225
593msgid "Direct Message..."
594msgstr "ОÑобиÑте повідомленнÑ..."
595
596#: html.c:3260
597msgid "Pending follow confirmations"
598msgstr "Запити на підпиÑку очікують на розглÑд"
599
600#: html.c:3264
601msgid "People you follow"
602msgstr "Ваші підпиÑки"
603
604#: html.c:3265
605msgid "People that follow you"
606msgstr "Ваші підпиÑники"
607
608#: html.c:3304
609msgid "Clear all"
610msgstr "ОчиÑтити вÑе"
611
612#: html.c:3361
613msgid "Mention"
614msgstr "Згадка"
615
616#: html.c:3364
617msgid "Finished poll"
618msgstr "Завершене опитуваннÑ"
619
620#: html.c:3379
621msgid "Follow Request"
622msgstr "Запит на підпиÑку"
623
624#: html.c:3462
625msgid "Context"
626msgstr "КонтекÑÑ‚"
627
628#: html.c:3473
629msgid "New"
630msgstr "Ðове"
631
632#: html.c:3488
633msgid "Already seen"
634msgstr "Вже переглÑнуто"
635
636#: html.c:3503
637msgid "None"
638msgstr "Ðема"
639
640#: html.c:3769
641#, c-format
642msgid "Search results for account %s"
643msgstr "Результати пошуку Ð´Ð»Ñ Ð¾Ð±Ð»Ñ–ÐºÐ¾Ð²Ð¾Ð³Ð¾ запиÑу %s"
644
645#: html.c:3776
646#, c-format
647msgid "Account %s not found"
648msgstr "Обліковий Ð·Ð°Ð¿Ð¸Ñ %s не знайдений"
649
650#: html.c:3807
651#, c-format
652msgid "Search results for tag %s"
653msgstr "Результати пошуку тега %s"
654
655#: html.c:3807
656#, c-format
657msgid "Nothing found for tag %s"
658msgstr "Ðічого не знайдено за тегом %s"
659
660#: html.c:3823
661#, c-format
662msgid "Search results for '%s' (may be more)"
663msgstr "Результати пошуку Ð´Ð»Ñ '%s' (можливо Ñ” більше)"
664
665#: html.c:3826
666#, c-format
667msgid "Search results for '%s'"
668msgstr "Результати пошуку Ð´Ð»Ñ '%s'"
669
670#: html.c:3829
671#, c-format
672msgid "No more matches for '%s'"
673msgstr "Ðемає більше збігів Ð´Ð»Ñ '%s'"
674
675#: html.c:3831
676#, c-format
677msgid "Nothing found for '%s'"
678msgstr "Ðічого не знайдено Ð´Ð»Ñ '%s'"
679
680#: html.c:3929
681msgid "Showing instance timeline"
682msgstr "Показуємо Ñтрічку Ñервера"
683
684#: html.c:4012
685#, c-format
686msgid "Showing timeline for list '%s'"
687msgstr "Показуємо Ñтрічку Ð´Ð»Ñ ÑпиÑку '%s'"
688
689#: httpd.c:250
690#, c-format
691msgid "Search results for tag #%s"
692msgstr "Результати пошуку Ð´Ð»Ñ Ñ‚ÐµÐ³Ð° #%s"
693
694#: httpd.c:259
695msgid "Recent posts by users in this instance"
696msgstr "ОÑтанні Ð¿Ð¾Ð²Ñ–Ð´Ð¾Ð¼Ð»ÐµÐ½Ð½Ñ Ð½Ð° цьому Ñервері"
697
698#: html.c:1603
699msgid "Blocked hashtags..."
700msgstr "Заблоковані теги..."
701
702#: html.c:432
703msgid "Optional URL to reply to"
704msgstr "Ðеобов'Ñзковий URL Ð´Ð»Ñ Ð²Ñ–Ð´Ð¿Ð¾Ð²Ñ–Ð´Ñ–"
705
706#: html.c:575
707msgid ""
708"Option 1...\n"
709"Option 2...\n"
710"Option 3...\n"
711"..."
712msgstr ""
713"Варіант 1...\n"
714"Варіант 2...\n"
715"Варіант 3...\n"
716"..."
717
718#: html.c:1415
719msgid "Bot API key"
720msgstr "Ключ API Ð´Ð»Ñ Ð±Ð¾Ñ‚Ð°"
721
722#: html.c:1421
723msgid "Chat id"
724msgstr "Id чату"
725
726#: html.c:1429
727msgid "ntfy server - full URL (example: https://ntfy.sh/YourTopic)"
728msgstr "повна URL Ñервера ntfy (наприклад https://ntfy.sh/YourTopic)"
729
730#: html.c:1435
731msgid "ntfy token - if needed"
732msgstr "токен ntfy - Ñкщо потрібно"
733
734#: html.c:2892
735msgid "pinned"
736msgstr "закріплено"
737
738#: html.c:2904
739msgid "bookmarks"
740msgstr "закладки"
741
742#: html.c:2916
743msgid "drafts"
744msgstr "чернетки"
745
746#: html.c:464
747msgid "Scheduled post..."
748msgstr "Запланувати..."
749
750msgid "Post date and time:"
751msgstr "Ð§Ð°Ñ Ð´Ð¾Ð¿Ð¸Ñу:"
752
753#: html.c:2927 html.c:3989
754msgid "Scheduled posts"
755msgstr "Заплановані допиÑи"
756
757#: html.c:2928
758msgid "scheduled posts"
759msgstr "заплановані допиÑи"
760
761#: html.c:458
762#, c-format
763msgid "Post date and time (timezone: %s):"
764msgstr "Дата та Ñ‡Ð°Ñ Ð´Ð¾Ð¿Ð¸Ñу (чаÑовий поÑÑ: %s):"
765
766#: html.c:1538
767msgid "Time zone:"
768msgstr "ЧаÑовий поÑÑ:"
diff --git a/rss.c b/rss.c
new file mode 100644
index 0000000..f72dac4
--- /dev/null
+++ b/rss.c
@@ -0,0 +1,274 @@
1/* snac - A simple, minimalistic ActivityPub instance */
2/* copyright (c) 2025 grunfink et al. / MIT license */
3
4#include "xs.h"
5#include "xs_html.h"
6#include "xs_regex.h"
7#include "xs_time.h"
8#include "xs_match.h"
9#include "xs_curl.h"
10#include "xs_openssl.h"
11#include "xs_json.h"
12
13#include "snac.h"
14
15xs_str *rss_from_timeline(snac *user, const xs_list *timeline,
16 const char *title, const char *link, const char *desc)
17/* converts a timeline to rss */
18{
19 xs_html *rss = xs_html_tag("rss",
20 xs_html_attr("xmlns:content", "http:/" "/purl.org/rss/1.0/modules/content/"),
21 xs_html_attr("version", "2.0"),
22 xs_html_attr("xmlns:atom", "http:/" "/www.w3.org/2005/Atom"));
23
24 xs_html *channel = xs_html_tag("channel",
25 xs_html_tag("title",
26 xs_html_text(title)),
27 xs_html_tag("language",
28 xs_html_text("en")),
29 xs_html_tag("link",
30 xs_html_text(link)),
31 xs_html_sctag("atom:link",
32 xs_html_attr("href", link),
33 xs_html_attr("rel", "self"),
34 xs_html_attr("type", "application/rss+xml")),
35 xs_html_tag("generator",
36 xs_html_text(USER_AGENT)),
37 xs_html_tag("description",
38 xs_html_text(desc)));
39
40 xs_html_add(rss, channel);
41
42 int cnt = 0;
43 const char *v;
44
45 xs_list_foreach(timeline, v) {
46 xs *msg = NULL;
47
48 if (user) {
49 if (!valid_status(timeline_get_by_md5(user, v, &msg)))
50 continue;
51 }
52 else {
53 if (!valid_status(object_get_by_md5(v, &msg)))
54 continue;
55 }
56
57 const char *id = xs_dict_get(msg, "id");
58 const char *content = xs_dict_get(msg, "content");
59 const char *published = xs_dict_get(msg, "published");
60
61 if (user && !xs_startswith(id, user->actor))
62 continue;
63
64 if (!id || !content || !published)
65 continue;
66
67 /* create a title with the first line of the content */
68 xs *title = xs_replace(content, "<br>", "\n");
69 title = xs_regex_replace_i(title, "<[^>]+>", " ");
70 title = xs_regex_replace_i(title, "&[^;]+;", " ");
71 int i;
72
73 for (i = 0; title[i] && title[i] != '\n' && i < 50; i++);
74
75 if (title[i] != '\0') {
76 title[i] = '\0';
77 title = xs_str_cat(title, "...");
78 }
79
80 title = xs_strip_i(title);
81
82 /* convert the date */
83 time_t t = xs_parse_iso_date(published, 0);
84 xs *rss_date = xs_str_utctime(t, "%a, %d %b %Y %T +0000");
85
86 /* if it's the first one, add it to the header */
87 if (cnt == 0)
88 xs_html_add(channel,
89 xs_html_tag("lastBuildDate",
90 xs_html_text(rss_date)));
91
92 xs_html_add(channel,
93 xs_html_tag("item",
94 xs_html_tag("title",
95 xs_html_text(title)),
96 xs_html_tag("link",
97 xs_html_text(id)),
98 xs_html_tag("guid",
99 xs_html_text(id)),
100 xs_html_tag("pubDate",
101 xs_html_text(rss_date)),
102 xs_html_tag("description",
103 xs_html_text(content))));
104
105 cnt++;
106 }
107
108 return xs_html_render_s(rss, "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
109}
110
111
112void rss_to_timeline(snac *user, const char *url)
113/* reads an RSS and inserts all ActivityPub posts into the user's timeline */
114{
115 if (!xs_startswith(url, "https:/") && !xs_startswith(url, "http:/"))
116 return;
117
118 xs *hdrs = xs_dict_new();
119 hdrs = xs_dict_set(hdrs, "accept", "application/rss+xml");
120 hdrs = xs_dict_set(hdrs, "user-agent", USER_AGENT);
121
122 /* get the RSS metadata */
123 xs *md5 = xs_md5_hex(url, strlen(url));
124 xs *rss_md_fn = xs_fmt("%s/rss", user->basedir);
125 mkdirx(rss_md_fn);
126 rss_md_fn = xs_str_cat(rss_md_fn, "/", md5, ".json");
127
128 xs *rss_md = NULL;
129 const char *etag = NULL;
130
131 FILE *f;
132 if ((f = fopen(rss_md_fn, "r")) != NULL) {
133 rss_md = xs_json_load(f);
134 fclose(f);
135
136 etag = xs_dict_get(rss_md, "etag");
137
138 if (xs_is_string(etag))
139 hdrs = xs_dict_set(hdrs, "if-none-match", etag);
140 }
141
142 if (rss_md == NULL)
143 rss_md = xs_dict_new();
144
145 xs *payload = NULL;
146 int status;
147 int p_size;
148
149 xs *rsp = xs_http_request("GET", url, hdrs, NULL, 0, &status, &payload, &p_size, 0);
150
151 snac_log(user, xs_fmt("parsing RSS %s %d", url, status));
152
153 if (!valid_status(status) || !xs_is_string(payload))
154 return;
155
156 /* not an RSS? done */
157 const char *ctype = xs_dict_get(rsp, "content-type");
158 if (!xs_is_string(ctype) || xs_str_in(ctype, "application/rss+xml") == -1)
159 return;
160
161 /* yes, parsing is done with regexes (now I have two problems blah blah blah) */
162 xs *links = xs_regex_select(payload, "<link>[^<]+</link>");
163 const char *link;
164
165 xs_list_foreach(links, link) {
166 xs *l = xs_replace(link, "<link>", "");
167 char *p = strchr(l, '<');
168
169 if (p == NULL)
170 continue;
171 *p = '\0';
172
173 /* skip this same URL */
174 if (strcmp(l, url) == 0)
175 continue;
176
177 /* skip crap */
178 if (!xs_startswith(l, "https:/") && !xs_startswith(l, "http:/"))
179 continue;
180
181 snac_debug(user, 1, xs_fmt("RSS link: %s", l));
182
183 if (timeline_here(user, l)) {
184 snac_debug(user, 1, xs_fmt("RSS entry already in timeline %s", l));
185 continue;
186 }
187
188 /* special trick for Mastodon: convert from the alternate format */
189 if (strchr(l, '@') != NULL) {
190 xs *l2 = xs_split(l, "/");
191
192 if (xs_list_len(l2) == 5) {
193 const char *uid = xs_list_get(l2, 3);
194 if (*uid == '@') {
195 xs *guessed_id = xs_fmt("https:/" "/%s/users/%s/statuses/%s",
196 xs_list_get(l2, 2), uid + 1, xs_list_get(l2, -1));
197
198 if (timeline_here(user, guessed_id)) {
199 snac_debug(user, 1, xs_fmt("RSS entry already in timeline (alt) %s", guessed_id));
200 continue;
201 }
202 }
203 }
204 }
205
206 xs *obj = NULL;
207
208 if (!valid_status(object_get(l, &obj))) {
209 /* object is not here: bring it */
210 if (!valid_status(activitypub_request(user, l, &obj)))
211 continue;
212 }
213
214 if (xs_is_dict(obj)) {
215 const char *id = xs_dict_get(obj, "id");
216 const char *type = xs_dict_get(obj, "type");
217 const char *attr_to = get_atto(obj);
218
219 if (!xs_is_string(id) || !xs_is_string(type) || !xs_is_string(attr_to))
220 continue;
221
222 if (!xs_match(type, POSTLIKE_OBJECT_TYPE))
223 continue;
224
225 if (timeline_here(user, id)) {
226 snac_debug(user, 1, xs_fmt("RSS entry already in timeline (id) %s", id));
227 continue;
228 }
229
230 enqueue_actor_refresh(user, attr_to, 0);
231
232 timeline_add(user, id, obj);
233
234 snac_log(user, xs_fmt("new '%s' (RSS) %s %s", type, attr_to, id));
235 }
236 }
237
238 /* update the RSS metadata */
239 etag = xs_dict_get(rsp, "etag");
240
241 if (xs_is_string(etag)) {
242 rss_md = xs_dict_set(rss_md, "etag", etag);
243 rss_md = xs_dict_set(rss_md, "url", url);
244 if ((f = fopen(rss_md_fn, "w")) != NULL) {
245 xs_json_dump(rss_md, 4, f);
246 fclose(f);
247 }
248 }
249}
250
251
252void rss_poll_hashtags(void)
253/* parses all RSS from all users */
254{
255 xs *list = user_list();
256 const char *uid;
257
258 xs_list_foreach(list, uid) {
259 snac user;
260
261 if (user_open(&user, uid)) {
262 const xs_list *rss = xs_dict_get(user.config, "followed_hashtags");
263
264 if (xs_is_list(rss)) {
265 const char *url;
266
267 xs_list_foreach(rss, url)
268 rss_to_timeline(&user, url);
269 }
270
271 user_free(&user);
272 }
273 }
274}
diff --git a/sandbox.c b/sandbox.c
index 1ea9c1c..c6cfdcb 100644
--- a/sandbox.c
+++ b/sandbox.c
@@ -13,6 +13,14 @@ void sbox_enter(const char *basedir)
13 return; 13 return;
14 } 14 }
15 15
16 int smail;
17 const char *url = xs_dict_get(srv_config, "smtp_url");
18
19 if (xs_is_string(url) && *url)
20 smail = 0;
21 else
22 smail = !xs_is_true(xs_dict_get(srv_config, "disable_email_notifications"));
23
16 srv_debug(1, xs_fmt("Calling unveil()")); 24 srv_debug(1, xs_fmt("Calling unveil()"));
17 unveil(basedir, "rwc"); 25 unveil(basedir, "rwc");
18 unveil("/tmp", "rwc"); 26 unveil("/tmp", "rwc");
@@ -25,6 +33,9 @@ void sbox_enter(const char *basedir)
25 if (*address == '/') 33 if (*address == '/')
26 unveil(address, "rwc"); 34 unveil(address, "rwc");
27 35
36 if (smail)
37 unveil("/usr/sbin/sendmail", "x");
38
28 unveil(NULL, NULL); 39 unveil(NULL, NULL);
29 40
30 srv_debug(1, xs_fmt("Calling pledge()")); 41 srv_debug(1, xs_fmt("Calling pledge()"));
@@ -34,6 +45,9 @@ void sbox_enter(const char *basedir)
34 if (*address == '/') 45 if (*address == '/')
35 p = xs_str_cat(p, " unix"); 46 p = xs_str_cat(p, " unix");
36 47
48 if (smail)
49 p = xs_str_cat(p, " exec");
50
37 pledge(p, NULL); 51 pledge(p, NULL);
38} 52}
39 53
diff --git a/snac.h b/snac.h
index 256731f..e4e190d 100644
--- a/snac.h
+++ b/snac.h
@@ -1,7 +1,7 @@
1/* snac - A simple, minimalistic ActivityPub instance */ 1/* snac - A simple, minimalistic ActivityPub instance */
2/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */ 2/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */
3 3
4#define VERSION "2.77" 4#define VERSION "2.81-dev"
5 5
6#define USER_AGENT "snac/" VERSION 6#define USER_AGENT "snac/" VERSION
7 7
@@ -164,7 +164,8 @@ int pending_count(snac *user);
164 164
165double timeline_mtime(snac *snac); 165double timeline_mtime(snac *snac);
166int timeline_touch(snac *snac); 166int timeline_touch(snac *snac);
167int timeline_here(snac *snac, const char *md5); 167int timeline_here_by_md5(snac *snac, const char *md5);
168int timeline_here(snac *snac, const char *id);
168int timeline_get_by_md5(snac *snac, const char *md5, xs_dict **msg); 169int timeline_get_by_md5(snac *snac, const char *md5, xs_dict **msg);
169int timeline_del(snac *snac, const char *id); 170int timeline_del(snac *snac, const char *id);
170xs_str *user_index_fn(snac *user, const char *idx_name); 171xs_str *user_index_fn(snac *user, const char *idx_name);
@@ -293,6 +294,8 @@ void enqueue_object_request(snac *user, const char *id, int forward_secs);
293void enqueue_verify_links(snac *user); 294void enqueue_verify_links(snac *user);
294void enqueue_actor_refresh(snac *user, const char *actor, int forward_secs); 295void enqueue_actor_refresh(snac *user, const char *actor, int forward_secs);
295void enqueue_webmention(const xs_dict *msg); 296void enqueue_webmention(const xs_dict *msg);
297void enqueue_notify_webhook(snac *user, const xs_dict *noti, int retries);
298
296int was_question_voted(snac *user, const char *id); 299int was_question_voted(snac *user, const char *id);
297 300
298xs_list *user_queue(snac *snac); 301xs_list *user_queue(snac *snac);
@@ -322,7 +325,7 @@ void httpd(void);
322int webfinger_request_signed(snac *snac, const char *qs, xs_str **actor, xs_str **user); 325int webfinger_request_signed(snac *snac, const char *qs, xs_str **actor, xs_str **user);
323int webfinger_request(const char *qs, xs_str **actor, xs_str **user); 326int webfinger_request(const char *qs, xs_str **actor, xs_str **user);
324int webfinger_request_fake(const char *qs, xs_str **actor, xs_str **user); 327int webfinger_request_fake(const char *qs, xs_str **actor, xs_str **user);
325int webfinger_get_handler(xs_dict *req, const char *q_path, 328int webfinger_get_handler(const xs_dict *req, const char *q_path,
326 xs_val **body, int *b_size, char **ctype); 329 xs_val **body, int *b_size, char **ctype);
327 330
328const char *default_avatar_base64(void); 331const char *default_avatar_base64(void);
@@ -394,8 +397,6 @@ int html_get_handler(const xs_dict *req, const char *q_path,
394int html_post_handler(const xs_dict *req, const char *q_path, 397int html_post_handler(const xs_dict *req, const char *q_path,
395 char *payload, int p_size, 398 char *payload, int p_size,
396 char **body, int *b_size, char **ctype); 399 char **body, int *b_size, char **ctype);
397xs_str *timeline_to_rss(snac *user, const xs_list *timeline,
398 const char *title, const char *link, const char *desc);
399 400
400int write_default_css(void); 401int write_default_css(void);
401int snac_init(const char *_basedir); 402int snac_init(const char *_basedir);
@@ -433,6 +434,8 @@ void mastoapi_purge(void);
433void verify_links(snac *user); 434void verify_links(snac *user);
434 435
435void export_csv(snac *user); 436void export_csv(snac *user);
437void export_posts(snac *user);
438
436int migrate_account(snac *user); 439int migrate_account(snac *user);
437 440
438void import_blocked_accounts_csv(snac *user, const char *fn); 441void import_blocked_accounts_csv(snac *user, const char *fn);
@@ -461,3 +464,8 @@ int badlogin_check(const char *user, const char *addr);
461void badlogin_inc(const char *user, const char *addr); 464void badlogin_inc(const char *user, const char *addr);
462 465
463const char *lang_str(const char *str, const snac *user); 466const char *lang_str(const char *str, const snac *user);
467
468xs_str *rss_from_timeline(snac *user, const xs_list *timeline,
469 const char *title, const char *link, const char *desc);
470void rss_to_timeline(snac *user, const char *url);
471void rss_poll_hashtags(void);
diff --git a/utils.c b/utils.c
index d50707a..8db20bd 100644
--- a/utils.c
+++ b/utils.c
@@ -45,6 +45,7 @@ static const char *default_srv_config = "{"
45static const char *default_css = 45static const char *default_css =
46 "body { max-width: 48em; margin: auto; line-height: 1.5; padding: 0.8em; word-wrap: break-word; }\n" 46 "body { max-width: 48em; margin: auto; line-height: 1.5; padding: 0.8em; word-wrap: break-word; }\n"
47 "pre { overflow-x: scroll; }\n" 47 "pre { overflow-x: scroll; }\n"
48 "blockquote { font-style: italic; }\n"
48 ".snac-embedded-video, img { max-width: 100% }\n" 49 ".snac-embedded-video, img { max-width: 100% }\n"
49 ".snac-origin { font-size: 85% }\n" 50 ".snac-origin { font-size: 85% }\n"
50 ".snac-score { float: right; font-size: 85% }\n" 51 ".snac-score { float: right; font-size: 85% }\n"
@@ -488,6 +489,26 @@ void verify_links(snac *user)
488 489
489 int c = 0; 490 int c = 0;
490 while (metadata && xs_dict_next(metadata, &k, &v, &c)) { 491 while (metadata && xs_dict_next(metadata, &k, &v, &c)) {
492 xs *wfinger = NULL;
493 const char *ov = NULL;
494
495 /* is it an account handle? */
496 if (*v == '@' && strchr(v + 1, '@')) {
497 /* resolve it via webfinger */
498 if (valid_status(webfinger_request(v, &wfinger, NULL)) && xs_is_string(wfinger)) {
499 ov = v;
500 v = wfinger;
501
502 /* store the alias */
503 if (user->links == NULL)
504 user->links = xs_dict_new();
505
506 user->links = xs_dict_set(user->links, ov, v);
507
508 changed++;
509 }
510 }
511
491 /* not an https link? skip */ 512 /* not an https link? skip */
492 if (!xs_startswith(v, "https:/" "/")) 513 if (!xs_startswith(v, "https:/" "/"))
493 continue; 514 continue;
@@ -705,6 +726,61 @@ void export_csv(snac *user)
705} 726}
706 727
707 728
729void export_posts(snac *user)
730/* exports all posts to an OrderedCollection */
731{
732 xs *ifn = xs_fmt("%s/public.idx", user->basedir);
733 xs *index = index_list(ifn, XS_ALL);
734 xs *ofn = xs_fmt("%s/export/outbox.json", user->basedir);
735 FILE *f;
736
737 if ((f = fopen(ofn, "w")) == NULL) {
738 snac_log(user, xs_fmt("Cannot create file %s", ofn));
739 return;
740 }
741
742 int cnt = 0;
743
744 /* raw output */
745 fprintf(f, "{\"@context\": \"https:/" "/www.w3.org/ns/activitystreams\",");
746 fprintf(f, "\"id\": \"outbox.json\",");
747 fprintf(f, "\"type\": \"OrderedCollection\",");
748 fprintf(f, "\"orderedItems\": [");
749
750 const char *md5;
751
752 snac_log(user, xs_fmt("Creating %s...", ofn));
753
754 xs_list_foreach(index, md5) {
755 xs *obj = NULL;
756
757 if (!valid_status(object_get_by_md5(md5, &obj)))
758 continue;
759
760 const char *type = xs_dict_get(obj, "type");
761
762 if (!xs_is_string(type) || strcmp(type, "Note"))
763 continue;
764
765 const char *atto = get_atto(obj);
766
767 if (!xs_is_string(atto) || strcmp(atto, user->actor))
768 continue;
769
770 if (cnt)
771 fprintf(f, ",");
772
773 xs *c_msg = msg_create(user, obj);
774 xs_json_dump(c_msg, 0, f);
775 cnt++;
776 }
777
778 fprintf(f, "], \"totalItems\": %d}", cnt);
779
780 fclose(f);
781}
782
783
708void import_blocked_accounts_csv(snac *user, const char *ifn) 784void import_blocked_accounts_csv(snac *user, const char *ifn)
709/* imports a Mastodon CSV file of blocked accounts */ 785/* imports a Mastodon CSV file of blocked accounts */
710{ 786{
diff --git a/webfinger.c b/webfinger.c
index 5db9a97..46b7edb 100644
--- a/webfinger.c
+++ b/webfinger.c
@@ -93,7 +93,7 @@ int webfinger_request_signed(snac *snac, const char *qs, xs_str **actor, xs_str
93 if (user != NULL) { 93 if (user != NULL) {
94 const char *subject = xs_dict_get(obj, "subject"); 94 const char *subject = xs_dict_get(obj, "subject");
95 95
96 if (subject) 96 if (subject && xs_startswith(subject, "acct:"))
97 *user = xs_replace_n(subject, "acct:", "", 1); 97 *user = xs_replace_n(subject, "acct:", "", 1);
98 } 98 }
99 99
@@ -152,7 +152,7 @@ int webfinger_request_fake(const char *qs, xs_str **actor, xs_str **user)
152} 152}
153 153
154 154
155int webfinger_get_handler(xs_dict *req, const char *q_path, 155int webfinger_get_handler(const xs_dict *req, const char *q_path,
156 xs_val **body, int *b_size, char **ctype) 156 xs_val **body, int *b_size, char **ctype)
157/* serves webfinger queries */ 157/* serves webfinger queries */
158{ 158{
diff --git a/xs.h b/xs.h
index ab5a264..54b913e 100644
--- a/xs.h
+++ b/xs.h
@@ -90,6 +90,7 @@ xs_str *xs_rstrip_chars_i(xs_str *str, const char *chars);
90xs_str *xs_strip_chars_i(xs_str *str, const char *chars); 90xs_str *xs_strip_chars_i(xs_str *str, const char *chars);
91#define xs_strip_i(str) xs_strip_chars_i(str, " \r\n\t\v\f") 91#define xs_strip_i(str) xs_strip_chars_i(str, " \r\n\t\v\f")
92xs_str *xs_tolower_i(xs_str *str); 92xs_str *xs_tolower_i(xs_str *str);
93xs_str *xs_toupper_i(xs_str *str);
93 94
94xs_list *xs_list_new(void); 95xs_list *xs_list_new(void);
95xs_list *xs_list_append_m(xs_list *list, const char *mem, int dsz); 96xs_list *xs_list_append_m(xs_list *list, const char *mem, int dsz);
@@ -692,6 +693,20 @@ xs_str *xs_tolower_i(xs_str *str)
692} 693}
693 694
694 695
696xs_str *xs_toupper_i(xs_str *str)
697/* convert to lowercase */
698{
699 XS_ASSERT_TYPE(str, XSTYPE_STRING);
700
701 int n;
702
703 for (n = 0; str[n]; n++)
704 str[n] = toupper(str[n]);
705
706 return str;
707}
708
709
695/** lists **/ 710/** lists **/
696 711
697xs_list *xs_list_new(void) 712xs_list *xs_list_new(void)
diff --git a/xs_fcgi.h b/xs_fcgi.h
index 0b53dac..b3cb892 100644
--- a/xs_fcgi.h
+++ b/xs_fcgi.h
@@ -13,7 +13,7 @@
13#define _XS_FCGI_H 13#define _XS_FCGI_H
14 14
15 xs_dict *xs_fcgi_request(FILE *f, xs_str **payload, int *p_size, int *id); 15 xs_dict *xs_fcgi_request(FILE *f, xs_str **payload, int *p_size, int *id);
16 void xs_fcgi_response(FILE *f, int status, xs_dict *headers, xs_str *body, int b_size, int id); 16 void xs_fcgi_response(FILE *f, int status, const xs_dict *headers, const xs_str *body, int b_size, int id);
17 17
18 18
19#ifdef XS_IMPLEMENTATION 19#ifdef XS_IMPLEMENTATION
@@ -290,7 +290,7 @@ end:
290} 290}
291 291
292 292
293void xs_fcgi_response(FILE *f, int status, xs_dict *headers, xs_str *body, int b_size, int fcgi_id) 293void xs_fcgi_response(FILE *f, int status, const xs_dict *headers, const xs_str *body, int b_size, int fcgi_id)
294/* writes an FCGI response */ 294/* writes an FCGI response */
295{ 295{
296 struct fcgi_record_header hdr = {0}; 296 struct fcgi_record_header hdr = {0};
diff --git a/xs_httpd.h b/xs_httpd.h
index 4cc8263..57759c4 100644
--- a/xs_httpd.h
+++ b/xs_httpd.h
@@ -5,7 +5,8 @@
5#define _XS_HTTPD_H 5#define _XS_HTTPD_H
6 6
7xs_dict *xs_httpd_request(FILE *f, xs_str **payload, int *p_size); 7xs_dict *xs_httpd_request(FILE *f, xs_str **payload, int *p_size);
8void xs_httpd_response(FILE *f, int status, const char *status_text, xs_dict *headers, xs_str *body, int b_size); 8void xs_httpd_response(FILE *f, int status, const char *status_text,
9 const xs_dict *headers, const xs_val *body, int b_size);
9 10
10 11
11#ifdef XS_IMPLEMENTATION 12#ifdef XS_IMPLEMENTATION
@@ -109,16 +110,15 @@ xs_dict *xs_httpd_request(FILE *f, xs_str **payload, int *p_size)
109} 110}
110 111
111 112
112void xs_httpd_response(FILE *f, int status, const char *status_text, xs_dict *headers, xs_str *body, int b_size) 113void xs_httpd_response(FILE *f, int status, const char *status_text,
114 const xs_dict *headers, const xs_val *body, int b_size)
113/* sends an httpd response */ 115/* sends an httpd response */
114{ 116{
115 xs *proto; 117 fprintf(f, "HTTP/1.1 %d %s\r\n", status, status_text ? status_text : "");
118
116 const xs_str *k; 119 const xs_str *k;
117 const xs_val *v; 120 const xs_val *v;
118 121
119 proto = xs_fmt("HTTP/1.1 %d %s", status, status_text);
120 fprintf(f, "%s\r\n", proto);
121
122 xs_dict_foreach(headers, k, v) { 122 xs_dict_foreach(headers, k, v) {
123 fprintf(f, "%s: %s\r\n", k, v); 123 fprintf(f, "%s: %s\r\n", k, v);
124 } 124 }
diff --git a/xs_json.h b/xs_json.h
index 8b449a9..07800fa 100644
--- a/xs_json.h
+++ b/xs_json.h
@@ -8,6 +8,7 @@
8#define MAX_JSON_DEPTH 32 8#define MAX_JSON_DEPTH 32
9#endif 9#endif
10 10
11void xs_json_dump_value(const xs_val *data, int level, int indent, FILE *f);
11int xs_json_dump(const xs_val *data, int indent, FILE *f); 12int xs_json_dump(const xs_val *data, int indent, FILE *f);
12xs_str *xs_json_dumps(const xs_val *data, int indent); 13xs_str *xs_json_dumps(const xs_val *data, int indent);
13 14
@@ -19,8 +20,8 @@ xs_val *xs_json_loads_full(const xs_str *json, int maxdepth);
19xstype xs_json_load_type(FILE *f); 20xstype xs_json_load_type(FILE *f);
20int xs_json_load_array_iter(FILE *f, xs_val **value, xstype *pt, int *c); 21int xs_json_load_array_iter(FILE *f, xs_val **value, xstype *pt, int *c);
21int xs_json_load_object_iter(FILE *f, xs_str **key, xs_val **value, xstype *pt, int *c); 22int xs_json_load_object_iter(FILE *f, xs_str **key, xs_val **value, xstype *pt, int *c);
22xs_list *xs_json_load_array(FILE *f, int maxdepth); 23int xs_json_load_array(FILE *f, int maxdepth, xs_list **l);
23xs_dict *xs_json_load_object(FILE *f, int maxdepth); 24int xs_json_load_object(FILE *f, int maxdepth, xs_dict **d);
24 25
25 26
26#ifdef XS_IMPLEMENTATION 27#ifdef XS_IMPLEMENTATION
@@ -77,7 +78,7 @@ static void _xs_json_indent(int level, int indent, FILE *f)
77} 78}
78 79
79 80
80static void _xs_json_dump(const xs_val *data, int level, int indent, FILE *f) 81void xs_json_dump_value(const xs_val *data, int level, int indent, FILE *f)
81/* dumps partial data as JSON */ 82/* dumps partial data as JSON */
82{ 83{
83 int c = 0; 84 int c = 0;
@@ -108,7 +109,7 @@ static void _xs_json_dump(const xs_val *data, int level, int indent, FILE *f)
108 fputc(',', f); 109 fputc(',', f);
109 110
110 _xs_json_indent(level + 1, indent, f); 111 _xs_json_indent(level + 1, indent, f);
111 _xs_json_dump(v, level + 1, indent, f); 112 xs_json_dump_value(v, level + 1, indent, f);
112 113
113 c++; 114 c++;
114 } 115 }
@@ -135,7 +136,7 @@ static void _xs_json_dump(const xs_val *data, int level, int indent, FILE *f)
135 if (indent) 136 if (indent)
136 fputc(' ', f); 137 fputc(' ', f);
137 138
138 _xs_json_dump(v, level + 1, indent, f); 139 xs_json_dump_value(v, level + 1, indent, f);
139 140
140 c++; 141 c++;
141 } 142 }
@@ -154,6 +155,20 @@ static void _xs_json_dump(const xs_val *data, int level, int indent, FILE *f)
154} 155}
155 156
156 157
158int xs_json_dump(const xs_val *data, int indent, FILE *f)
159/* dumps data into a file as JSON */
160{
161 xstype t = xs_type(data);
162
163 if (t == XSTYPE_LIST || t == XSTYPE_DICT) {
164 xs_json_dump_value(data, 0, indent, f);
165 return 1;
166 }
167
168 return 0;
169}
170
171
157xs_str *xs_json_dumps(const xs_val *data, int indent) 172xs_str *xs_json_dumps(const xs_val *data, int indent)
158/* dumps data as a JSON string */ 173/* dumps data as a JSON string */
159{ 174{
@@ -173,20 +188,6 @@ xs_str *xs_json_dumps(const xs_val *data, int indent)
173} 188}
174 189
175 190
176int xs_json_dump(const xs_val *data, int indent, FILE *f)
177/* dumps data into a file as JSON */
178{
179 xstype t = xs_type(data);
180
181 if (t == XSTYPE_LIST || t == XSTYPE_DICT) {
182 _xs_json_dump(data, 0, indent, f);
183 return 1;
184 }
185
186 return 0;
187}
188
189
190/** JSON loads **/ 191/** JSON loads **/
191 192
192typedef enum { 193typedef enum {
@@ -370,6 +371,8 @@ int xs_json_load_array_iter(FILE *f, xs_val **value, xstype *pt, int *c)
370 else 371 else
371 return -1; 372 return -1;
372 } 373 }
374 else
375 *pt = xs_type(*value);
373 376
374 *c = *c + 1; 377 *c = *c + 1;
375 378
@@ -377,43 +380,51 @@ int xs_json_load_array_iter(FILE *f, xs_val **value, xstype *pt, int *c)
377} 380}
378 381
379 382
380xs_list *xs_json_load_array(FILE *f, int maxdepth) 383int xs_json_load_array(FILE *f, int maxdepth, xs_list **l)
381/* loads a full JSON array (after the initial OBRACK) */ 384/* loads a full JSON array (after the initial OBRACK) */
385/* l can be NULL for the content to be dropped */
382{ 386{
383 xstype t; 387 xstype t;
384 xs_list *l = xs_list_new(); 388 int r = 0;
385 int c = 0; 389 int c = 0;
386 390
387 for (;;) { 391 for (;;) {
388 xs *v = NULL; 392 xs *v = NULL;
389 int r = xs_json_load_array_iter(f, &v, &t, &c); 393 r = xs_json_load_array_iter(f, &v, &t, &c);
390
391 if (r == -1)
392 l = xs_free(l);
393 394
394 if (r == 1) { 395 if (r == 1) {
395 /* partial load? */ 396 /* partial load? */
396 if (v == NULL && maxdepth != 0) { 397 if (v == NULL && maxdepth != 0) {
397 if (t == XSTYPE_LIST) 398 if (t == XSTYPE_LIST) {
398 v = xs_json_load_array(f, maxdepth - 1); 399 if (l)
400 v = xs_list_new();
401
402 r = xs_json_load_array(f, maxdepth - 1, &v);
403 }
399 else 404 else
400 if (t == XSTYPE_DICT) 405 if (t == XSTYPE_DICT) {
401 v = xs_json_load_object(f, maxdepth - 1); 406 if (l)
407 v = xs_dict_new();
408
409 r = xs_json_load_object(f, maxdepth - 1, &v);
410 }
402 } 411 }
403 412
404 /* still null? fail */ 413 /* error? */
405 if (v == NULL) { 414 if (r < 0)
406 l = xs_free(l);
407 break; 415 break;
408 }
409 416
410 l = xs_list_append(l, v); 417 if (l)
418 *l = xs_list_append(*l, v);
411 } 419 }
412 else 420 else
413 break; 421 break;
414 } 422 }
415 423
416 return l; 424 if (r < 0 && l)
425 *l = xs_free(*l);
426
427 return r;
417} 428}
418 429
419 430
@@ -458,6 +469,8 @@ int xs_json_load_object_iter(FILE *f, xs_str **key, xs_val **value, xstype *pt,
458 else 469 else
459 return -1; 470 return -1;
460 } 471 }
472 else
473 *pt = xs_type(*value);
461 474
462 *c = *c + 1; 475 *c = *c + 1;
463 476
@@ -465,59 +478,52 @@ int xs_json_load_object_iter(FILE *f, xs_str **key, xs_val **value, xstype *pt,
465} 478}
466 479
467 480
468xs_dict *xs_json_load_object(FILE *f, int maxdepth) 481int xs_json_load_object(FILE *f, int maxdepth, xs_dict **d)
469/* loads a full JSON object (after the initial OCURLY) */ 482/* loads a full JSON object (after the initial OCURLY) */
483/* d can be NULL for the content to be dropped */
470{ 484{
471 xstype t; 485 xstype t;
472 xs_dict *d = xs_dict_new(); 486 int r = 0;
473 int c = 0; 487 int c = 0;
474 488
475 for (;;) { 489 for (;;) {
476 xs *k = NULL; 490 xs *k = NULL;
477 xs *v = NULL; 491 xs *v = NULL;
478 int r = xs_json_load_object_iter(f, &k, &v, &t, &c); 492 r = xs_json_load_object_iter(f, &k, &v, &t, &c);
479
480 if (r == -1)
481 d = xs_free(d);
482 493
483 if (r == 1) { 494 if (r == 1) {
484 /* partial load? */ 495 /* partial load? */
485 if (v == NULL && maxdepth != 0) { 496 if (v == NULL && maxdepth != 0) {
486 if (t == XSTYPE_LIST) 497 if (t == XSTYPE_LIST) {
487 v = xs_json_load_array(f, maxdepth - 1); 498 if (d)
499 v = xs_list_new();
500
501 r = xs_json_load_array(f, maxdepth - 1, &v);
502 }
488 else 503 else
489 if (t == XSTYPE_DICT) 504 if (t == XSTYPE_DICT) {
490 v = xs_json_load_object(f, maxdepth - 1); 505 if (d)
506 v = xs_dict_new();
507
508 r = xs_json_load_object(f, maxdepth - 1, &v);
509 }
491 } 510 }
492 511
493 /* still null? fail */ 512 /* error? */
494 if (v == NULL) { 513 if (r < 0)
495 d = xs_free(d);
496 break; 514 break;
497 }
498 515
499 d = xs_dict_append(d, k, v); 516 if (d)
517 *d = xs_dict_append(*d, k, v);
500 } 518 }
501 else 519 else
502 break; 520 break;
503 } 521 }
504 522
505 return d; 523 if (r < 0 && d)
506} 524 *d = xs_free(*d);
507
508
509xs_val *xs_json_loads_full(const xs_str *json, int maxdepth)
510/* loads a string in JSON format and converts to a multiple data */
511{
512 FILE *f;
513 xs_val *v = NULL;
514
515 if ((f = fmemopen((char *)json, strlen(json), "r")) != NULL) {
516 v = xs_json_load_full(f, maxdepth);
517 fclose(f);
518 }
519 525
520 return v; 526 return r;
521} 527}
522 528
523 529
@@ -545,11 +551,30 @@ xs_val *xs_json_load_full(FILE *f, int maxdepth)
545 xs_val *v = NULL; 551 xs_val *v = NULL;
546 xstype t = xs_json_load_type(f); 552 xstype t = xs_json_load_type(f);
547 553
548 if (t == XSTYPE_LIST) 554 if (t == XSTYPE_LIST) {
549 v = xs_json_load_array(f, maxdepth); 555 v = xs_list_new();
556 xs_json_load_array(f, maxdepth, &v);
557 }
550 else 558 else
551 if (t == XSTYPE_DICT) 559 if (t == XSTYPE_DICT) {
552 v = xs_json_load_object(f, maxdepth); 560 v = xs_dict_new();
561 xs_json_load_object(f, maxdepth, &v);
562 }
563
564 return v;
565}
566
567
568xs_val *xs_json_loads_full(const xs_str *json, int maxdepth)
569/* loads a string in JSON format and converts to a multiple data */
570{
571 FILE *f;
572 xs_val *v = NULL;
573
574 if ((f = fmemopen((char *)json, strlen(json), "r")) != NULL) {
575 v = xs_json_load_full(f, maxdepth);
576 fclose(f);
577 }
553 578
554 return v; 579 return v;
555} 580}
diff --git a/xs_version.h b/xs_version.h
index c7789a7..466535b 100644
--- a/xs_version.h
+++ b/xs_version.h
@@ -1 +1 @@
/* 871d420cef893b6efe32869407294baf084ce3ab 2025-05-04T11:01:01+02:00 */ /* 401d229ffbec89b4a5cf97793926b7afb84a4f26 2025-07-08T15:44:54+02:00 */
diff --git a/xs_webmention.h b/xs_webmention.h
index 8415629..e177573 100644
--- a/xs_webmention.h
+++ b/xs_webmention.h
@@ -5,6 +5,7 @@
5#define _XS_WEBMENTION_H 5#define _XS_WEBMENTION_H
6 6
7int xs_webmention_send(const char *source, const char *target, const char *user_agent); 7int xs_webmention_send(const char *source, const char *target, const char *user_agent);
8int xs_webmention_hook(const char *source, const char *target, const char *user_agent);
8 9
9 10
10#ifdef XS_IMPLEMENTATION 11#ifdef XS_IMPLEMENTATION
@@ -118,6 +119,47 @@ int xs_webmention_send(const char *source, const char *target, const char *user_
118} 119}
119 120
120 121
122int xs_webmention_hook(const char *source, const char *target, const char *user_agent)
123/* a Webmention has been received for a target that is ours; check if the source
124 really contains a link to our target */
125{
126 int status = 0;
127
128 xs *ua = xs_fmt("%s (Webmention)", user_agent ? user_agent : "xs_webmention");
129 xs *headers = xs_dict_new();
130 headers = xs_dict_set(headers, "accept", "text/html");
131 headers = xs_dict_set(headers, "user-agent", ua);
132
133 xs *g_req = NULL;
134 xs *payload = NULL;
135 int p_size = 0;
136
137 g_req = xs_http_request("GET", source, headers, NULL, 0, &status, &payload, &p_size, 0);
138
139 if (status < 200 || status > 299)
140 return -1;
141
142 if (!xs_is_string(payload))
143 return -2;
144
145 /* note: a "rogue" webmention can include a link to our target in commented-out HTML code */
146
147 xs *links = xs_regex_select(payload, "<(a +|link +)[^>]+>");
148 const char *link;
149
150 status = 0;
151 xs_list_foreach(links, link) {
152 /* if the link contains our target, it's valid */
153 if (xs_str_in(link, target) != -1) {
154 status = 1;
155 break;
156 }
157 }
158
159 return status;
160}
161
162
121#endif /* XS_IMPLEMENTATION */ 163#endif /* XS_IMPLEMENTATION */
122 164
123#endif /* _XS_WEBMENTION_H */ 165#endif /* _XS_WEBMENTION_H */