commit 3554a73
Merge pull request 'master' (#9) from grunfink/snac2:master into master Reviewed-on: https://codeberg.org/zen/snac2/pulls/9
28 files changed,
+2079,
-366
M
Makefile
+9,
-7
1@@ -5,7 +5,7 @@ CFLAGS?=-g -Wall -Wextra -pedantic
2 all: snac
3
4 snac: snac.o main.o sandbox.o data.o http.o httpd.o webfinger.o \
5- activitypub.o html.o utils.o format.o upgrade.o mastoapi.o
6+ activitypub.o html.o utils.o format.o upgrade.o mastoapi.o rss.o
7 $(CC) $(CFLAGS) -L$(PREFIX)/lib *.o -lcurl -lcrypto $(LDFLAGS) -pthread -o $@
8
9 test: tests/smtp
10@@ -48,25 +48,27 @@ update-po:
11
12 activitypub.o: activitypub.c xs.h xs_json.h xs_curl.h xs_mime.h \
13 xs_openssl.h xs_regex.h xs_time.h xs_set.h xs_match.h xs_unicode.h \
14- snac.h http_codes.h
15+ xs_webmention.h snac.h http_codes.h
16 data.o: data.c xs.h xs_hex.h xs_io.h xs_json.h xs_openssl.h xs_glob.h \
17 xs_set.h xs_time.h xs_regex.h xs_match.h xs_unicode.h xs_random.h \
18 xs_po.h snac.h http_codes.h
19 format.o: format.c xs.h xs_regex.h xs_mime.h xs_html.h xs_json.h \
20- xs_time.h xs_match.h snac.h http_codes.h
21+ xs_time.h xs_match.h xs_unicode.h snac.h http_codes.h
22 html.o: html.c xs.h xs_io.h xs_json.h xs_regex.h xs_set.h xs_openssl.h \
23 xs_time.h xs_mime.h xs_match.h xs_html.h xs_curl.h xs_unicode.h xs_url.h \
24 xs_random.h snac.h http_codes.h
25 http.o: http.c xs.h xs_io.h xs_openssl.h xs_curl.h xs_time.h xs_json.h \
26 snac.h http_codes.h
27 httpd.o: httpd.c xs.h xs_io.h xs_json.h xs_socket.h xs_unix_socket.h \
28- xs_httpd.h xs_mime.h xs_time.h xs_openssl.h xs_fcgi.h xs_html.h snac.h \
29- http_codes.h
30+ xs_httpd.h xs_mime.h xs_time.h xs_openssl.h xs_fcgi.h xs_html.h \
31+ xs_webmention.h snac.h http_codes.h
32 main.o: main.c xs.h xs_io.h xs_json.h xs_time.h xs_openssl.h xs_match.h \
33- snac.h http_codes.h
34+ xs_random.h snac.h http_codes.h
35 mastoapi.o: mastoapi.c xs.h xs_hex.h xs_openssl.h xs_json.h xs_io.h \
36 xs_time.h xs_glob.h xs_set.h xs_random.h xs_url.h xs_mime.h xs_match.h \
37- snac.h http_codes.h
38+ xs_unicode.h snac.h http_codes.h
39+rss.o: rss.c xs.h xs_html.h xs_regex.h xs_time.h xs_match.h xs_curl.h \
40+ xs_openssl.h xs_json.h snac.h http_codes.h
41 sandbox.o: sandbox.c xs.h snac.h http_codes.h
42 snac.o: snac.c xs.h xs_hex.h xs_io.h xs_unicode_tbl.h xs_unicode.h \
43 xs_json.h xs_curl.h xs_openssl.h xs_socket.h xs_unix_socket.h xs_url.h \
+9,
-7
1@@ -6,7 +6,7 @@ LDFLAGS=-lrt
2 all: snac
3
4 snac: snac.o main.o sandbox.o data.o http.o httpd.o webfinger.o \
5- activitypub.o html.o utils.o format.o upgrade.o mastoapi.o
6+ activitypub.o html.o utils.o format.o upgrade.o mastoapi.o rss.o
7 $(CC) $(CFLAGS) -L/usr/pkg/lib *.o -lcurl -lcrypto -pthread $(LDFLAGS) -Wl,-rpath,/usr/lib -Wl,-rpath,/usr/pkg/lib -o $@
8
9
10@@ -37,25 +37,27 @@ uninstall:
11
12 activitypub.o: activitypub.c xs.h xs_json.h xs_curl.h xs_mime.h \
13 xs_openssl.h xs_regex.h xs_time.h xs_set.h xs_match.h xs_unicode.h \
14- snac.h http_codes.h
15+ xs_webmention.h snac.h http_codes.h
16 data.o: data.c xs.h xs_hex.h xs_io.h xs_json.h xs_openssl.h xs_glob.h \
17 xs_set.h xs_time.h xs_regex.h xs_match.h xs_unicode.h xs_random.h \
18 xs_po.h snac.h http_codes.h
19 format.o: format.c xs.h xs_regex.h xs_mime.h xs_html.h xs_json.h \
20- xs_time.h xs_match.h snac.h http_codes.h
21+ xs_time.h xs_match.h xs_unicode.h snac.h http_codes.h
22 html.o: html.c xs.h xs_io.h xs_json.h xs_regex.h xs_set.h xs_openssl.h \
23 xs_time.h xs_mime.h xs_match.h xs_html.h xs_curl.h xs_unicode.h xs_url.h \
24 xs_random.h snac.h http_codes.h
25 http.o: http.c xs.h xs_io.h xs_openssl.h xs_curl.h xs_time.h xs_json.h \
26 snac.h http_codes.h
27 httpd.o: httpd.c xs.h xs_io.h xs_json.h xs_socket.h xs_unix_socket.h \
28- xs_httpd.h xs_mime.h xs_time.h xs_openssl.h xs_fcgi.h xs_html.h snac.h \
29- http_codes.h
30+ xs_httpd.h xs_mime.h xs_time.h xs_openssl.h xs_fcgi.h xs_html.h \
31+ xs_webmention.h snac.h http_codes.h
32 main.o: main.c xs.h xs_io.h xs_json.h xs_time.h xs_openssl.h xs_match.h \
33- snac.h http_codes.h
34+ xs_random.h snac.h http_codes.h
35 mastoapi.o: mastoapi.c xs.h xs_hex.h xs_openssl.h xs_json.h xs_io.h \
36 xs_time.h xs_glob.h xs_set.h xs_random.h xs_url.h xs_mime.h xs_match.h \
37- snac.h http_codes.h
38+ xs_unicode.h snac.h http_codes.h
39+rss.o: rss.c xs.h xs_html.h xs_regex.h xs_time.h xs_match.h xs_curl.h \
40+ xs_openssl.h xs_json.h snac.h http_codes.h
41 sandbox.o: sandbox.c xs.h snac.h http_codes.h
42 snac.o: snac.c xs.h xs_hex.h xs_io.h xs_unicode_tbl.h xs_unicode.h \
43 xs_json.h xs_curl.h xs_openssl.h xs_socket.h xs_unix_socket.h xs_url.h \
+45,
-1
1@@ -2,11 +2,55 @@
2
3 ## UNRELEASED
4
5+Included 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+
7+Added Ukrainian translation (contributed by wincentbalin).
8+
9+## 2.80
10+
11+Mastodon API: fixed a regression (introduced in previous version in the "boosts disappear in Tusky" fix) that interrupted timelines.
12+
13+## 2.79
14+
15+Added 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+
17+Fixed regression while sending email via pipe on OpenBSD.
18+
19+Fixed Markdown parsing when the URL has parenthesis.
20+
21+Always show the 'pending follow confirmations' section if there are any (even if the toggle is off).
22+
23+If a metadata value is an account handler, it's also tried to be validated (rel="me" links).
24+
25+Another search by URL tweak (this time for Pixelfed links).
26+
27+Mastodon API: fixed a bug that made some boosts disappear after being shown in apps like Tusky, added followed hashtags maintenance, other minor changes.
28+
29+Renamed command-line actions `create_list` to `list_create` and `delete_list` to `list_remove`.
30+
31+The default favicon URL can be changed from the server configuration.
32+
33+New command-line option `export_posts`, to export all posts by a user in a JSON format compatible with the one generated by Mastodon.
34+
35+The 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+
39+Hashtag following also allow URLs to RSS feeds of ActivityPub objects (like e.g. https://mastodon.social/tags/ThankYouTuesday).
40+
41+Users 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+
43 The number of pending follow confirmations is shown next to the "people" link.
44
45 Faster performance metrics (contributed by dandelions).
46
47-Mastodon API: added follow confirmation endpoints.
48+Improved lowercasing in hashtags (contributed by postscriptum).
49+
50+A search-by-url tweak for implementations that return 200 for invalid webfinger queries (e.g. piefed).
51+
52+Mastodon API: added follow confirmation endpoints, fixed collisions in attachment file names.
53+
54+Fixed potential crashes in attachment uploads.
55
56 ## 2.77 "Ugly Links Everywhere"
57
M
TODO.md
+4,
-0
1@@ -14,6 +14,8 @@ Important: deleting a follower should do more that just delete the object, see h
2
3 ## Wishlist
4
5+The local purge should generate `Delete` activities for local posts.
6+
7 Add account reporting.
8
9 Add a list option to hide member posts from the main timeline, see https://codeberg.org/grunfink/snac2/issues/383
10@@ -22,6 +24,8 @@ The instance timeline should also show boosts from users.
11
12 Mastoapi: implement /v1/conversations.
13
14+Track "BadgeFed - ActivityPub Badges" https://github.com/tryvocalcat/badgefed
15+
16 Track 'Event' data types standardization; how to add plan-to-attend and similar activities (more info: https://event-federation.eu/). Friendica interacts with events via activities `Accept` (will go), `TentativeAccept` (will try to go) or `Reject` (cannot go) (`object` field as id, not object). `Undo` for any of these activities cancel (`object` as an object, not id).
17
18 Implement "FEP-3b86: Activity Intents" https://codeberg.org/fediverse/fep/src/branch/main/fep/3b86/fep-3b86.md
+38,
-1
1@@ -903,7 +903,7 @@ xs_str *process_tags(snac *snac, const char *content, xs_list **tag)
2 if (*v == '#') {
3 /* hashtag */
4 xs *d = xs_dict_new();
5- xs *n = xs_tolower_i(xs_dup(v));
6+ xs *n = xs_utf8_to_lower(v);
7 xs *h = xs_fmt("%s?t=%s", srv_baseurl, n + 1);
8 xs *l = xs_fmt("<a href=\"%s\" class=\"mention hashtag\" rel=\"tag\">%s</a>", h, v);
9
10@@ -2765,6 +2765,39 @@ void process_user_queue_item(snac *user, xs_dict *q_item)
11 snac_log(user, xs_fmt("actor_refresh %s %d", actor, status));
12 }
13 }
14+ else
15+ if (strcmp(type, "notify_webhook") == 0) {
16+ const char *webhook = xs_dict_get(user->config, "notify_webhook");
17+
18+ if (xs_is_string(webhook) && xs_match(webhook, "https://*|http://*")) { /** **/
19+ const xs_dict *msg = xs_dict_get(q_item, "message");
20+ int retries = xs_number_get(xs_dict_get(q_item, "retries"));
21+
22+ xs *hdrs = xs_dict_new();
23+
24+ hdrs = xs_dict_set(hdrs, "content-type", "application/json");
25+ hdrs = xs_dict_set(hdrs, "user-agent", USER_AGENT);
26+
27+ xs *body = xs_json_dumps(msg, 4);
28+
29+ int status;
30+ xs *rsp = xs_http_request("POST", webhook, hdrs, body, strlen(body), &status, NULL, NULL, 0);
31+
32+ snac_debug(user, 0, xs_fmt("webhook post %s %d", webhook, status));
33+
34+ if (!valid_status(status)) {
35+ retries++;
36+
37+ if (retries > queue_retry_max)
38+ snac_debug(user, 0, xs_fmt("webhook post giving up %s", webhook));
39+ else {
40+ snac_debug(user, 0, xs_fmt("webhook post requeue %s %d", webhook, retries));
41+
42+ enqueue_notify_webhook(user, msg, retries);
43+ }
44+ }
45+ }
46+ }
47 else
48 snac_log(user, xs_fmt("unexpected user q_item type '%s'", type));
49 }
50@@ -3049,6 +3082,10 @@ void process_queue_item(xs_dict *q_item)
51 }
52 }
53 }
54+ else
55+ if (strcmp(type, "rss_hashtag_poll") == 0) {
56+ rss_poll_hashtags();
57+ }
58 else
59 srv_log(xs_fmt("unexpected q_item type '%s'", type));
60 }
M
data.c
+61,
-9
1@@ -333,6 +333,7 @@ int user_open_by_md5(snac *snac, const char *md5)
2 return 0;
3 }
4
5+
6 int user_persist(snac *snac, int publish)
7 /* store user */
8 {
9@@ -348,7 +349,7 @@ int user_persist(snac *snac, int publish)
10
11 if (old != NULL) {
12 int nw = 0;
13- const char *fields[] = { "header", "avatar", "name", "bio",
14+ const char *fields[] = { "header", "avatar", "name", "bio", "alias", "alias_raw",
15 "metadata", "latitude", "longitude", NULL };
16
17 for (int n = 0; fields[n]; n++) {
18@@ -1391,7 +1392,7 @@ xs_str *timeline_fn_by_md5(snac *snac, const char *md5)
19 }
20
21
22-int timeline_here(snac *snac, const char *md5)
23+int timeline_here_by_md5(snac *snac, const char *md5)
24 /* checks if an object is in the user cache */
25 {
26 xs *fn = timeline_fn_by_md5(snac, md5);
27@@ -1400,6 +1401,14 @@ int timeline_here(snac *snac, const char *md5)
28 }
29
30
31+int timeline_here(snac *user, const char *id)
32+{
33+ xs *md5 = xs_md5_hex(id, strlen(id));
34+
35+ return timeline_here_by_md5(user, md5);
36+}
37+
38+
39 int timeline_get_by_md5(snac *snac, const char *md5, xs_dict **msg)
40 /* gets a message from the timeline */
41 {
42@@ -1515,7 +1524,7 @@ xs_list *timeline_top_level(snac *snac, const xs_list *list)
43 break;
44
45 /* well, there is a parent... but is it here? */
46- if (!timeline_here(snac, line2))
47+ if (!timeline_here_by_md5(snac, line2))
48 break;
49
50 /* it's here! try again with its own parent */
51@@ -2217,7 +2226,7 @@ void tag_index(const char *id, const xs_dict *obj)
52 if (*name == '\0')
53 continue;
54
55- name = xs_tolower_i((xs_str *)name);
56+ name = xs_utf8_to_lower((xs_str *)name);
57
58 xs *md5_tag = xs_md5_hex(name, strlen(name));
59 xs *tag_dir = xs_fmt("%s/%c%c", g_tag_dir, md5_tag[0], md5_tag[1]);
60@@ -2235,7 +2244,7 @@ void tag_index(const char *id, const xs_dict *obj)
61 fclose(f);
62 }
63
64- srv_debug(0, xs_fmt("tagged %s #%s (#%s)", id, name, md5_tag));
65+ srv_debug(1, xs_fmt("tagged %s #%s (#%s)", id, name, md5_tag));
66 }
67 }
68 }
69@@ -2247,7 +2256,7 @@ xs_str *tag_fn(const char *tag)
70 if (*tag == '#')
71 tag++;
72
73- xs *lw_tag = xs_tolower_i(xs_dup(tag));
74+ xs *lw_tag = xs_utf8_to_lower(tag);
75 xs *md5 = xs_md5_hex(lw_tag, strlen(lw_tag));
76
77 return xs_fmt("%s/tag/%c%c/%s.idx", srv_basedir, md5[0], md5[1], md5);
78@@ -2832,9 +2841,9 @@ int content_match(const char *file, const xs_dict *msg)
79 srv_debug(1, xs_fmt("content_match: loading regexes from %s", fn));
80
81 /* massage content (strip HTML tags, etc.) */
82- xs *c = xs_regex_replace(v, "<[^>]+>", " ");
83- c = xs_regex_replace_i(c, " {2,}", " ");
84- c = xs_tolower_i(c);
85+ xs *c1 = xs_regex_replace(v, "<[^>]+>", " ");
86+ c1 = xs_regex_replace_i(c1, " {2,}", " ");
87+ xs *c = xs_utf8_to_lower(c1);
88
89 while (!r && !feof(f)) {
90 xs *rx = xs_strip_i(xs_readline(f));
91@@ -3158,6 +3167,9 @@ void notify_add(snac *snac, const char *type, const char *utype,
92
93 pthread_mutex_unlock(&data_mutex);
94 }
95+
96+ if (!xs_is_true(xs_dict_get(srv_config, "disable_notify_webhook")))
97+ enqueue_notify_webhook(snac, noti, 0);
98 }
99
100
101@@ -3513,6 +3525,46 @@ void enqueue_webmention(const xs_dict *msg)
102 }
103
104
105+void enqueue_notify_webhook(snac *user, const xs_dict *noti, int retries)
106+/* enqueues a notification webhook */
107+{
108+ const char *webhook = xs_dict_get(user->config, "notify_webhook");
109+
110+ if (xs_is_string(webhook) && xs_match(webhook, "https://*|http://*")) { /** **/
111+ xs *msg = xs_dup(noti);
112+
113+ /* add more data */
114+ msg = xs_dict_set(msg, "target", user->actor);
115+ msg = xs_dict_set(msg, "uid", user->uid);
116+ msg = xs_dict_set(msg, "basedir", srv_basedir);
117+ msg = xs_dict_set(msg, "baseurl", srv_baseurl);
118+
119+ xs *actor_obj = NULL;
120+
121+ if (valid_status(object_get(xs_dict_get(noti, "actor"), &actor_obj)) && actor_obj)
122+ msg = xs_dict_set(msg, "account", actor_obj);
123+
124+ /* if this post is a reply, also add the inReplyTo object */
125+ const char *in_reply_to = xs_dict_get_path(msg, "msg.object.inReplyTo");
126+
127+ if (xs_is_string(in_reply_to)) {
128+ xs *irt_obj = NULL;
129+
130+ if (valid_status(object_get(in_reply_to, &irt_obj)))
131+ msg = xs_dict_set(msg, "reply", irt_obj);
132+ }
133+
134+ xs *qmsg = _new_qmsg("notify_webhook", msg, retries);
135+ const char *ntid = xs_dict_get(qmsg, "ntid");
136+ xs *fn = xs_fmt("%s/queue/%s.json", user->basedir, ntid);
137+
138+ qmsg = _enqueue_put(fn, qmsg);
139+
140+ snac_debug(user, 1, xs_fmt("notify_webhook"));
141+ }
142+}
143+
144+
145 int was_question_voted(snac *user, const char *id)
146 /* returns true if the user voted in this poll */
147 {
+82,
-17
1@@ -24,9 +24,9 @@ For file and data formats, see
2 .Ss Web Interface
3 The web interface provided by
4 .Nm
5-is split in two data streams: the public timeline and the
6-private timeline. There are no other feeds like the server-scoped
7-or the federated firehoses provided by other similar ActivityPub
8+is split in three data streams: the public timeline, the private
9+timeline and the instance timeline. There are no other feeds like
10+the federated firehoses provided by other similar ActivityPub
11 implementations like Mastodon or Pleroma.
12 .Pp
13 The public timeline, also called the local timeline, is what an
14@@ -67,9 +67,23 @@ sent to those people you mention in the post body.
15 If you fill this optional text field with the URL of another one's
16 post, your text will be considered as a reply to it, not a
17 standalone one.
18+.It Draft
19+If you set this checkbox, your text will not be sent when you
20+push the Post button, but stored for later modification in
21+the "Drafts" section.
22+.It Scheduled post...
23+This dropdown menu allows setting a date and time for the
24+post publication.
25+.It Attachments...
26+This dropdown menu allows uploading media attachments (images,
27+audio, video, etc.) to your post.
28+.It Poll...
29+this dropdown menu gives access to the voting options, that
30+will make your post a poll. You can set the options to be
31+voted, if it's a multiple choice poll and the due date.
32 .El
33 .Pp
34-More options are hidden under a toggle control. They are the
35+More options are hidden under dropdown menus. They are the
36 following:
37 .Bl -tag -offset indent
38 .It Follow (by URL or user@host)
39@@ -85,7 +99,9 @@ liked.
40 This option opens the user setup dialog.
41 .It Followed hashtags...
42 Enter here the list of hashtags you want to follow, one
43-per line, with or without the # symbol.
44+per line, with or without the # symbol. Since version 2.78,
45+URLs to RSS feeds of ActivityPub objects are also allowed
46+(like e.g. https://mastodon.social/tags/bloomscrolling).
47 .It Blocked hashtags...
48 Enter here the list of hashtags you want to block, one
49 per line, with or without the # symbol.
50@@ -125,6 +141,10 @@ standard ntfy.sh server), fill the two provided
51 fields (ntfy server/topic and, if protected, the token).
52 You need to refer to the https://ntfy.sh web site for
53 more information on this process.
54+.It Notify webhook
55+If this is set to an URL, an HTTP POST will be sent to it
56+whenever a new notification happens (see the 'Webhook for
57+notifications' section below for more information).
58 .It Maximum days to keep posts
59 This numeric value specifies the number of days to pass before
60 posts (yours and others') will be purged. This value overrides
61@@ -264,7 +284,7 @@ Requests an object and dumps it to stdout. This is a very low
62 level command that is not very useful to you.
63 .It Cm announce Ar basedir Ar uid Ar url
64 Announces (boosts) a post via its URL.
65-.It Cm note Ar basedir Ar uid Ar text Op file file ...
66+.It Cm note Ar basedir Ar uid Ar text Op file file ... Op -r inReplyTo
67 Enqueues a Create + Note message to all followers. If the
68 .Ar text
69 argument is -e, the external editor defined by the EDITOR
70@@ -272,10 +292,11 @@ environment variable will be invoked to prepare a message; if
71 it's - (a lonely hyphen), the post content will be read from stdin.
72 The rest of command line arguments are treated as media files to be
73 attached to the post. The LANG environment variable (if defined) is used
74-as the post language.
75-.It Cm note_unlisted Ar basedir Ar uid Ar text Op file file ...
76+as the post language. An optional URL to a Fediverse post, prefixed by -r,
77+can be specified for this note to be a reply to.
78+.It Cm note_unlisted Ar basedir Ar uid Ar text Op file file ... Op -r inReplyTo
79 Like the previous one, but creates an "unlisted" (or "quiet public") post.
80-.It Cm note_mention Ar basedir Ar uid Ar text Op file file ...
81+.It Cm note_mention Ar basedir Ar uid Ar text Op file file ... Op -r inReplyTo
82 Like the previous one, but creates a post only for accounts mentioned
83 in the post body.
84 .It Cm block Ar basedir Ar instance_url
85@@ -285,9 +306,9 @@ blocked without further inspection.
86 .It Cm unblock Ar basedir Ar instance_url
87 Unblocks a previously blocked instance.
88 .It Cm verify_links Ar basedir Ar uid
89-Verifies all links stored as metadata for the given user. This verification
90-is done by downloading the link content and searching for a link back to
91-the
92+Verifies all links or account handles stored as metadata for the given user.
93+This verification is done by downloading the link content and searching for
94+a link back to the
95 .Nm
96 user url that also contains a rel="me" attribute. These links are specially
97 marked as verified in the user's public timeline and also via the Mastodon API.
98@@ -300,6 +321,13 @@ subdirectory inside the user directory:
99 .Pa blocked_accounts.csv ,
100 .Pa lists.csv , and
101 .Pa following_accounts.csv .
102+.It Cm export_posts Ar basedir Ar uid
103+Exports all posts written by the user to the file
104+.Pa outbox.json
105+inside the
106+.Pa export/
107+subdirectory inside the user directory. The format is compatible with the
108+one generated by the equivalent option in Mastodon.
109 .It Cm alias Ar basedir Ar uid Ar "@account@remotehost"
110 Sets an account as an alias of this one. This is a necessary step to migrate
111 an account to a remote Mastodon instance (see
112@@ -354,10 +382,10 @@ subdirectory of a user's directory inside the server base directory.
113 Prints the name of the user created lists.
114 .It Cm list_members Ar basedir Ar uid Ar name
115 Prints the list of actors in the named list.
116-.It Cm create_list Ar basedir Ar uid Ar name
117+.It Cm list_create Ar basedir Ar uid Ar name
118 Creates a new list.
119-.It Cm delete_list Ar basedir Ar uid Ar name
120-Deletes an existing list.
121+.It Cm list_remove Ar basedir Ar uid Ar name
122+Removes an existing list.
123 .It Cm list_add Ar basedir Ar uid Ar name Ar account
124 Adds an account (by its @name@host handle or actor URL) to a list.
125 .It Cm list_del Ar basedir Ar uid Ar name Ar actor_url
126@@ -408,6 +436,43 @@ You can obtain an API Token by connecting to the following URL:
127 https://$SNAC_HOST/oauth/x-snac-get-token
128 .Ed
129 .Pp
130+.Ss Webhook for notifications
131+Since version 2.78, users can set the URL to a webhook that will receive
132+an HTTP POST with every notification (in JSON format). This can be used to
133+implement some automation whenever a new activity happens, like autorepliers,
134+chatbots, interactive textual games or whatever. The
135+.Pa examples/
136+subdirectory contains a tiny Python program that implements an auto-follower
137+for every new follow. The JSON notification object includes the following data:
138+.Bl -tag -offset indent
139+.It id
140+a unique notification identifier
141+.It actor
142+the origin actor id
143+.It target
144+the target actor id
145+.It date
146+the notification date
147+.It msg
148+the full ActivityPub action JSON object
149+.It objid
150+the object identifier (extracted from msg, may be null)
151+.It type
152+the action type (extracted from msg)
153+.It utype
154+the action subtype (extracted from msg, may be null)
155+.It uid
156+the user identifier (account name)
157+.It basedir
158+the server base directory
159+.It baseurl
160+the server base URL
161+.It account
162+the origin actor object
163+.It reply
164+the activity this post is a reply to (may not exist)
165+.El
166+.Pp
167 .Sh ENVIRONMENT
168 .Bl -tag -width Ds
169 .It SNAC_BASEDIR
170@@ -422,9 +487,9 @@ Overrides the debugging level from the server 'dbglevel' configuration
171 variable. Set it to an integer value. The higher, the deeper in meaningless
172 verbiage you'll find yourself into.
173 .It Ev EDITOR
174-The user-preferred interactive text editor to prepare messages.
175+The user-preferred interactive text editor to prepare notes.
176 .It Ev LANG
177-The language of the post when sending messages.
178+The language of the post when sending notes from the command line.
179 .El
180 .Sh SEE ALSO
181 .Xr snac 5 ,
+10,
-0
1@@ -207,6 +207,8 @@ fields are set (see below), they are also shown.
2 The email address of the instance administrator (optional).
3 .It Ic admin_account
4 The user name of the instance administrator (optional).
5+.It Ic title
6+The name of the instance (optional).
7 .It Ic short_description
8 A textual short description about the instance (optional).
9 .It Ic short_description_raw
10@@ -277,6 +279,14 @@ the usual one, like in smtp://mail.example.com:587.
11 .It Ic smtp_user
12 .It Ic smtp_password
13 To be filled if the SMTP server defined by the previous directive needs credentials.
14+.It Ic rss_hashtag_poll_hours
15+The periodic number of hours hashtag RSS are polled (default: 4). It has a minimum
16+value of 1 to avoid hammering servers.
17+.It Ic disable_notify_webhook
18+Since version 2.78, users can set a webhook URL to receive notifications. Set this
19+to true if you don't want your users to have this privilege.
20+.It Ic favicon_url
21+The URL to a favicon. If it's not set, the default one is used instead.
22 .El
23 .Pp
24 You must restart the server to make effective these changes.
+1,
-0
1@@ -1,5 +1,6 @@
2 body { max-width: 48em; margin: auto; line-height: 1.5; padding: 0.8em; word-wrap: break-word; }
3 pre { overflow-x: scroll; }
4+blockquote { font-style: italic; }
5 .snac-embedded-video, img { max-width: 100% }
6 .snac-origin { font-size: 85% }
7 .snac-score { float: right; font-size: 85% }
+55,
-0
1@@ -0,0 +1,55 @@
2+#!/usr/bin/env python3
3+
4+# This is an example of a snac webhook that automatically follows all new followers.
5+
6+# To use it, configure the user webhook to be http://localhost:12345, and run this program.
7+
8+# copyright (C) 2025 grunfink et al. / MIT license
9+
10+from http.server import BaseHTTPRequestHandler, HTTPServer
11+import time
12+import json
13+import os
14+
15+host_name = "localhost"
16+server_port = 12345
17+
18+class SnacAutoResponderServer(BaseHTTPRequestHandler):
19+
20+ def do_POST(self):
21+ self.send_response(200)
22+ self.end_headers()
23+
24+ content_type = self.headers["content-type"]
25+ content_length = int(self.headers["content-length"])
26+ payload = self.rfile.read(content_length).decode("utf-8")
27+
28+ if content_type == "application/json":
29+ try:
30+ noti = json.loads(payload)
31+
32+ type = noti["type"]
33+
34+ if type == "Follow":
35+ actor = noti["actor"]
36+ uid = noti["uid"]
37+ basedir = noti["basedir"]
38+
39+ cmd = "snac follow %s %s %s" % (basedir, uid, actor)
40+
41+ os.system(cmd)
42+
43+ except:
44+ print("Error parsing notification")
45+
46+if __name__ == "__main__":
47+ webServer = HTTPServer((host_name, server_port), SnacAutoResponderServer)
48+ print("Webhook started http://%s:%s" % (host_name, server_port))
49+
50+ try:
51+ webServer.serve_forever()
52+ except KeyboardInterrupt:
53+ pass
54+
55+ webServer.server_close()
56+ print("Webhook stopped.")
M
format.c
+18,
-14
1@@ -8,6 +8,7 @@
2 #include "xs_json.h"
3 #include "xs_time.h"
4 #include "xs_match.h"
5+#include "xs_unicode.h"
6
7 #include "snac.h"
8
9@@ -95,8 +96,8 @@ static xs_str *format_line(const char *line, xs_list **attach)
10 "~~[^~]+~~" "|"
11 "\\*\\*?\\*?[^\\*]+\\*?\\*?\\*" "|"
12 "__[^_]+__" "|" //anzu
13- "!\\[[^]]+\\]\\([^\\)]+\\)" "|"
14- "\\[[^]]+\\]\\([^\\)]+\\)" "|"
15+ "!\\[[^]]+\\]\\([^\\)]+\\)\\)?" "|"
16+ "\\[[^]]+\\]\\([^\\)]+\\)\\)?" "|"
17 "[a-z]+:/" "/" NOSPACE "|"
18 "(mailto|xmpp):[^@[:space:]]+@" NOSPACE
19 ")");
20@@ -148,14 +149,15 @@ static xs_str *format_line(const char *line, xs_list **attach)
21 else
22 if (*v == '[') {
23 /* markdown-like links [label](url) */
24- xs *w = xs_strip_chars_i(
25- xs_replace_i(xs_replace(v, "#", "#"), "@", "@"),
26- ";
29
30 if (xs_list_len(l) == 2) {
31- const char *name = xs_list_get(l, 0);
32- const char *url = xs_list_get(l, 1);
33+ xs *name = xs_dup(xs_list_get(l, 0));
34+ xs *url = xs_dup(xs_list_get(l, 1));
35+
36+ name = xs_crop_i(name, 1, 0);
37+ url = xs_crop_i(url, 0, -1);
38
39 xs *link = xs_fmt("<a href=\"%s\">%s</a>", url, name);
40
41@@ -167,15 +169,17 @@ static xs_str *format_line(const char *line, xs_list **attach)
42 else
43 if (*v == '!') {
44 /* markdown-like images  */
45- xs *w = xs_strip_chars_i(
46- xs_replace_i(xs_replace(v, "#", "#"), "@", "@"),
47- ";
50
51 if (xs_list_len(l) == 2) {
52- const char *alt_text = xs_list_get(l, 0);
53- const char *img_url = xs_list_get(l, 1);
54- const char *mime = xs_mime_by_ext(img_url);
55+ xs *alt_text = xs_dup(xs_list_get(l, 0));
56+ xs *img_url = xs_dup(xs_list_get(l, 1));
57+
58+ alt_text = xs_crop_i(alt_text, 2, 0);
59+ img_url = xs_crop_i(img_url, 0, -1);
60+
61+ const char *mime = xs_mime_by_ext(img_url);
62
63 if (attach != NULL && xs_startswith(mime, "image/")) {
64 const xs_dict *ad;
65@@ -443,7 +447,7 @@ xs_str *sanitize(const char *content)
66 if (n & 0x1) {
67 xs *s1 = xs_strip_i(xs_crop_i(xs_dup(v), v[1] == '/' ? 2 : 1, -1));
68 xs *l1 = xs_split_n(s1, " ", 1);
69- xs *tag = xs_tolower_i(xs_dup(xs_list_get(l1, 0)));
70+ xs *tag = xs_utf8_to_lower(xs_list_get(l1, 0));
71 xs *s2 = NULL;
72 int i;
73
M
html.c
+94,
-130
1@@ -636,13 +636,18 @@ static xs_html *html_base_head(void)
2 xs_html_attr("content", USER_AGENT)));
3
4 /* add server CSS and favicon */
5- xs *f;
6- f = xs_fmt("%s/favicon.ico", srv_baseurl);
7+ xs *f = NULL;
8+ const char *favicon = xs_dict_get(srv_config, "favicon_url");
9+
10+ if (xs_is_string(favicon))
11+ f = xs_dup(favicon);
12+ else
13+ f = xs_fmt("%s/favicon.ico", srv_baseurl);
14+
15 const xs_list *p = xs_dict_get(srv_config, "cssurls");
16 const char *v;
17- int c = 0;
18
19- while (xs_list_next(p, &v, &c)) {
20+ xs_list_foreach(p, v) {
21 xs_html_add(head,
22 xs_html_sctag("link",
23 xs_html_attr("rel", "stylesheet"),
24@@ -863,6 +868,14 @@ xs_html *html_user_head(snac *user, const char *desc, const char *url)
25 xs_html_attr("type", "application/activity+json"),
26 xs_html_attr("href", url ? url : user->actor)));
27
28+ /* webmention hook */
29+ xs *wbh = xs_fmt("%s/webmention-hook", srv_baseurl);
30+
31+ xs_html_add(head,
32+ xs_html_sctag("link",
33+ xs_html_attr("rel", "webmention"),
34+ xs_html_attr("href", wbh)));
35+
36 return head;
37 }
38
39@@ -1077,10 +1090,17 @@ static xs_html *html_user_body(snac *user, int read_only)
40 while (xs_dict_next(metadata, &k, &v, &c)) {
41 xs_html *value;
42
43- if (xs_startswith(v, "https:/") || xs_startswith(v, "http:/")) {
44+ if (xs_startswith(v, "https:/") || xs_startswith(v, "http:/") || *v == '@') {
45 /* is this link validated? */
46 xs *verified_link = NULL;
47 const xs_number *val_time = xs_dict_get(val_links, v);
48+ const char *url = NULL;
49+
50+ if (xs_is_string(val_time)) {
51+ /* resolve again, as it may be an account handle */
52+ url = val_time;
53+ val_time = xs_dict_get(val_links, val_time);
54+ }
55
56 if (xs_type(val_time) == XSTYPE_NUMBER) {
57 time_t t = xs_number_get(val_time);
58@@ -1098,13 +1118,13 @@ static xs_html *html_user_body(snac *user, int read_only)
59 xs_html_tag("a",
60 xs_html_attr("rel", "me"),
61 xs_html_attr("target", "_blank"),
62- xs_html_attr("href", v),
63+ xs_html_attr("href", url ? url : v),
64 xs_html_text(v)));
65 }
66 else {
67 value = xs_html_tag("a",
68 xs_html_attr("rel", "me"),
69- xs_html_attr("href", v),
70+ xs_html_attr("href", url ? url : v),
71 xs_html_text(v));
72 }
73 }
74@@ -1290,6 +1310,7 @@ xs_html *html_top_controls(snac *user)
75 const xs_val *show_foll = xs_dict_get(user->config, "show_contact_metrics");
76 const char *latitude = xs_dict_get_def(user->config, "latitude", "");
77 const char *longitude = xs_dict_get_def(user->config, "longitude", "");
78+ const char *webhook = xs_dict_get_def(user->config, "notify_webhook", "");
79
80 xs *metadata = NULL;
81 const xs_dict *md = xs_dict_get(user->config, "metadata");
82@@ -1451,6 +1472,14 @@ xs_html *html_top_controls(snac *user)
83 xs_html_attr("name", "ntfy_token"),
84 xs_html_attr("value", ntfy_token),
85 xs_html_attr("placeholder", L("ntfy token - if needed")))),
86+ xs_html_tag("p",
87+ xs_html_text(L("Notify webhook:")),
88+ xs_html_sctag("br", NULL),
89+ xs_html_sctag("input",
90+ xs_html_attr("type", "url"),
91+ xs_html_attr("name", "notify_webhook"),
92+ xs_html_attr("value", webhook),
93+ xs_html_attr("placeholder", L("http://example.com/webhook")))),
94 xs_html_tag("p",
95 xs_html_text(L("Maximum days to keep posts (0: server settings):")),
96 xs_html_sctag("br", NULL),
97@@ -1601,7 +1630,8 @@ xs_html *html_top_controls(snac *user)
98 xs_html_attr("name", "followed_hashtags"),
99 xs_html_attr("cols", "40"),
100 xs_html_attr("rows", "4"),
101- xs_html_attr("placeholder", "#cats\n#windowfriday\n#classicalmusic"),
102+ xs_html_attr("placeholder", "#cats\n#windowfriday\n#classicalmusic\nhttps:/"
103+ "/mastodon.social/tags/dogs"),
104 xs_html_text(followed_hashtags)),
105
106 xs_html_tag("br", NULL),
107@@ -1978,8 +2008,13 @@ xs_html *html_entry(snac *user, xs_dict *msg, int read_only,
108 }
109
110 if ((user == NULL || strcmp(actor, user->actor) != 0)
111- && !valid_status(actor_get(actor, NULL)))
112+ && !valid_status(actor_get(actor, NULL))) {
113+
114+ if (user)
115+ enqueue_actor_refresh(user, actor, 0);
116+
117 return NULL;
118+ }
119
120 /** html_entry top tag **/
121 xs_html *entry_top = xs_html_tag("div", NULL);
122@@ -2115,9 +2150,7 @@ xs_html *html_entry(snac *user, xs_dict *msg, int read_only,
123 const char *parent = get_in_reply_to(msg);
124
125 if (!xs_is_null(parent) && *parent) {
126- xs *md5 = xs_md5_hex(parent, strlen(parent));
127-
128- if (!timeline_here(user, md5)) {
129+ if (!timeline_here(user, parent)) {
130 xs_html_add(post_header,
131 xs_html_tag("div",
132 xs_html_attr("class", "snac-origin"),
133@@ -2419,6 +2452,9 @@ xs_html *html_entry(snac *user, xs_dict *msg, int read_only,
134 const char *o_href = xs_dict_get(a, "href");
135 const char *name = xs_dict_get(a, "name");
136
137+ if (!xs_is_string(type) || !xs_is_string(o_href))
138+ continue;
139+
140 /* if this URL is already in the post content, skip */
141 if (content && xs_str_in(content, o_href) != -1)
142 continue;
143@@ -3284,12 +3320,12 @@ xs_str *html_people(snac *user)
144
145 xs *wing = following_list(user);
146 xs *wers = follower_list(user);
147+ xs *pending = pending_list(user);
148
149 xs_html *lists = xs_html_tag("div",
150 xs_html_attr("class", "snac-posts"));
151
152- if (xs_is_true(xs_dict_get(user->config, "approve_followers"))) {
153- xs *pending = pending_list(user);
154+ if (xs_list_len(pending) || xs_is_true(xs_dict_get(user->config, "approve_followers"))) {
155 xs_html_add(lists,
156 html_people_list(user, pending, L("Pending follow confirmations"), "p", proxy));
157 }
158@@ -3384,13 +3420,15 @@ xs_str *html_notifications(snac *user, int skip, int show)
159
160 const char *actor_id = xs_dict_get(noti, "actor");
161 xs *actor = NULL;
162+ xs *a_name = NULL;
163
164- if (!valid_status(actor_get(actor_id, &actor)))
165- continue;
166+ if (valid_status(actor_get(actor_id, &actor)))
167+ a_name = actor_name(actor, proxy);
168+ else
169+ a_name = xs_dup(actor_id);
170
171- xs *a_name = actor_name(actor, proxy);
172- xs *label_sanatized = sanitize(type);
173- const char *label = label_sanatized;
174+ xs *label_sanitized = sanitize(type);
175+ const char *label = label_sanitized;
176
177 if (strcmp(type, "Create") == 0)
178 label = L("Mention");
179@@ -3462,10 +3500,11 @@ xs_str *html_notifications(snac *user, int skip, int show)
180 html_label);
181
182 if (strcmp(type, "Follow") == 0 || strcmp(utype, "Follow") == 0 || strcmp(type, "Block") == 0) {
183- xs_html_add(entry,
184- xs_html_tag("div",
185- xs_html_attr("class", "snac-post"),
186- html_actor_icon(user, actor, NULL, NULL, NULL, 0, 0, proxy, NULL, NULL)));
187+ if (actor)
188+ xs_html_add(entry,
189+ xs_html_tag("div",
190+ xs_html_attr("class", "snac-post"),
191+ html_actor_icon(user, actor, NULL, NULL, NULL, 0, 0, proxy, NULL, NULL)));
192 }
193 else
194 if (strcmp(type, "Move") == 0) {
195@@ -3498,6 +3537,13 @@ xs_str *html_notifications(snac *user, int skip, int show)
196 xs_html_text(L("Context")))),
197 h);
198 }
199+ else
200+ xs_html_add(entry,
201+ xs_html_tag("p",
202+ xs_html_text(L("Location: ")),
203+ xs_html_tag("a",
204+ xs_html_attr("href", id),
205+ xs_html_text(id))));
206 }
207
208 if (strcmp(v, n_time) > 0) {
209@@ -3748,7 +3794,7 @@ int html_get_handler(const xs_dict *req, const char *q_path,
210 /* may by an actor; try a webfinger */
211 xs *actor_obj = NULL;
212
213- if (valid_status(webfinger_request(q, &actor_obj, &url_acct))) {
214+ if (valid_status(webfinger_request(q, &actor_obj, &url_acct)) && xs_is_string(url_acct)) {
215 /* it's an actor; do the dirty trick of changing q to the account name */
216 q = url_acct;
217 }
218@@ -3773,12 +3819,18 @@ int html_get_handler(const xs_dict *req, const char *q_path,
219 q = url_acct;
220
221 /* add the post to the timeline */
222- xs *md5 = xs_md5_hex(q, strlen(q));
223-
224- if (!timeline_here(&snac, md5))
225+ if (!timeline_here(&snac, q))
226 timeline_add(&snac, q, object);
227 }
228 }
229+ else {
230+ /* retry webfinger, this time with the 'official' id */
231+ const char *id = xs_dict_get(object, "id");
232+
233+ if (xs_is_string(id) && valid_status(webfinger_request(id, &actor_obj, &url_acct)) &&
234+ xs_is_string(url_acct))
235+ q = url_acct;
236+ }
237 }
238 }
239
240@@ -3917,7 +3969,7 @@ int html_get_handler(const xs_dict *req, const char *q_path,
241 xs *l = xs_split(p_path, "/");
242 const char *md5 = xs_list_get(l, -1);
243
244- if (md5 && *md5 && timeline_here(&snac, md5)) {
245+ if (md5 && *md5 && timeline_here_by_md5(&snac, md5)) {
246 xs *list0 = xs_list_append(xs_list_new(), md5);
247 xs *list = timeline_top_level(&snac, list0);
248
249@@ -4127,7 +4179,7 @@ int html_get_handler(const xs_dict *req, const char *q_path,
250 xs_dict_get(srv_config, "host"));
251 xs *rss_link = xs_fmt("%s.rss", snac.actor);
252
253- *body = timeline_to_rss(&snac, elems, rss_title, rss_link, bio);
254+ *body = rss_from_timeline(&snac, elems, rss_title, rss_link, bio);
255 *b_size = strlen(*body);
256 *ctype = "application/rss+xml; charset=utf-8";
257 status = HTTP_STATUS_OK;
258@@ -4365,7 +4417,7 @@ int html_post_handler(const xs_dict *req, const char *q_path,
259 xs_rnd_buf(rnd, sizeof(rnd));
260
261 const char *ext = strrchr(fn, '.');
262- xs *hash = xs_md5_hex(rnd, strlen(rnd));
263+ xs *hash = xs_md5_hex(rnd, sizeof(rnd));
264 xs *id = xs_fmt("post-%s%s", hash, ext ? ext : "");
265 xs *url = xs_fmt("%s/s/%s", snac.actor, id);
266 int fo = xs_number_get(xs_list_get(attach_file, 1));
267@@ -4825,6 +4877,8 @@ int html_post_handler(const xs_dict *req, const char *q_path,
268 snac.config = xs_dict_set(snac.config, "latitude", xs_dict_get_def(p_vars, "latitude", ""));
269 snac.config = xs_dict_set(snac.config, "longitude", xs_dict_get_def(p_vars, "longitude", ""));
270
271+ snac.config = xs_dict_set(snac.config, "notify_webhook", xs_dict_get_def(p_vars, "notify_webhook", ""));
272+
273 if ((v = xs_dict_get(p_vars, "metadata")) != NULL)
274 snac.config = xs_dict_set(snac.config, "metadata", v);
275
276@@ -4960,9 +5014,16 @@ int html_post_handler(const xs_dict *req, const char *q_path,
277 if (*s1 == '\0')
278 continue;
279
280- xs *s2 = xs_utf8_to_lower(s1);
281- if (*s2 != '#')
282- s2 = xs_str_prepend_i(s2, "#");
283+ xs *s2 = NULL;
284+
285+ if (xs_startswith(s1, "https:/"))
286+ s2 = xs_dup(s1);
287+ else {
288+ s2 = xs_utf8_to_lower(s1);
289+
290+ if (*s2 != '#')
291+ s2 = xs_str_prepend_i(s2, "#");
292+ }
293
294 new_hashtags = xs_list_append(new_hashtags, s2);
295 }
296@@ -5024,100 +5085,3 @@ int html_post_handler(const xs_dict *req, const char *q_path,
297
298 return status;
299 }
300-
301-
302-xs_str *timeline_to_rss(snac *user, const xs_list *timeline,
303- const char *title, const char *link, const char *desc)
304-/* converts a timeline to rss */
305-{
306- xs_html *rss = xs_html_tag("rss",
307- xs_html_attr("xmlns:content", "http:/" "/purl.org/rss/1.0/modules/content/"),
308- xs_html_attr("version", "2.0"),
309- xs_html_attr("xmlns:atom", "http:/" "/www.w3.org/2005/Atom"));
310-
311- xs_html *channel = xs_html_tag("channel",
312- xs_html_tag("title",
313- xs_html_text(title)),
314- xs_html_tag("language",
315- xs_html_text("en")),
316- xs_html_tag("link",
317- xs_html_text(link)),
318- xs_html_sctag("atom:link",
319- xs_html_attr("href", link),
320- xs_html_attr("rel", "self"),
321- xs_html_attr("type", "application/rss+xml")),
322- xs_html_tag("generator",
323- xs_html_text(USER_AGENT)),
324- xs_html_tag("description",
325- xs_html_text(desc)));
326-
327- xs_html_add(rss, channel);
328-
329- int cnt = 0;
330- const char *v;
331-
332- xs_list_foreach(timeline, v) {
333- xs *msg = NULL;
334-
335- if (user) {
336- if (!valid_status(timeline_get_by_md5(user, v, &msg)))
337- continue;
338- }
339- else {
340- if (!valid_status(object_get_by_md5(v, &msg)))
341- continue;
342- }
343-
344- const char *id = xs_dict_get(msg, "id");
345- const char *content = xs_dict_get(msg, "content");
346- const char *published = xs_dict_get(msg, "published");
347-
348- if (user && !xs_startswith(id, user->actor))
349- continue;
350-
351- if (!id || !content || !published)
352- continue;
353-
354- /* create a title with the first line of the content */
355- xs *title = xs_replace(content, "<br>", "\n");
356- title = xs_regex_replace_i(title, "<[^>]+>", " ");
357- title = xs_regex_replace_i(title, "&[^;]+;", " ");
358- int i;
359-
360- for (i = 0; title[i] && title[i] != '\n' && i < 50; i++);
361-
362- if (title[i] != '\0') {
363- title[i] = '\0';
364- title = xs_str_cat(title, "...");
365- }
366-
367- title = xs_strip_i(title);
368-
369- /* convert the date */
370- time_t t = xs_parse_iso_date(published, 0);
371- xs *rss_date = xs_str_utctime(t, "%a, %d %b %Y %T +0000");
372-
373- /* if it's the first one, add it to the header */
374- if (cnt == 0)
375- xs_html_add(channel,
376- xs_html_tag("lastBuildDate",
377- xs_html_text(rss_date)));
378-
379- xs_html_add(channel,
380- xs_html_tag("item",
381- xs_html_tag("title",
382- xs_html_text(title)),
383- xs_html_tag("link",
384- xs_html_text(id)),
385- xs_html_tag("guid",
386- xs_html_text(id)),
387- xs_html_tag("pubDate",
388- xs_html_text(rss_date)),
389- xs_html_tag("description",
390- xs_html_text(content))));
391-
392- cnt++;
393- }
394-
395- return xs_html_render_s(rss, "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
396-}
M
httpd.c
+106,
-14
1@@ -12,6 +12,7 @@
2 #include "xs_openssl.h"
3 #include "xs_fcgi.h"
4 #include "xs_html.h"
5+#include "xs_webmention.h"
6
7 #include "snac.h"
8
9@@ -65,7 +66,9 @@ const char *nodeinfo_2_0_template = ""
10 "\"services\":{\"outbound\":[],\"inbound\":[]},"
11 "\"usage\":{\"users\":{\"total\":%d,\"activeMonth\":%d,\"activeHalfyear\":%d},"
12 "\"localPosts\":%d},"
13- "\"openRegistrations\":false,\"metadata\":{}}";
14+ "\"openRegistrations\":false,\"metadata\":{"
15+ "\"nodeDescription\":\"%s\",\"nodeName\":\"%s\""
16+ "}}";
17
18 xs_str *nodeinfo_2_0(void)
19 /* builds a nodeinfo json object */
20@@ -98,7 +101,10 @@ xs_str *nodeinfo_2_0(void)
21 n_posts += index_len(pidxfn);
22 }
23
24- return xs_fmt(nodeinfo_2_0_template, n_utotal, n_umonth, n_uhyear, n_posts);
25+ const char *name = xs_dict_get_def(srv_config, "title", "");
26+ const char *desc = xs_dict_get_def(srv_config, "short_description", "");
27+
28+ return xs_fmt(nodeinfo_2_0_template, n_utotal, n_umonth, n_uhyear, n_posts, desc, name);
29 }
30
31
32@@ -244,7 +250,7 @@ int server_get_handler(xs_dict *req, const char *q_path,
33 if (!xs_is_null(accept) && strcmp(accept, "application/rss+xml") == 0) {
34 xs *link = xs_fmt("%s/?t=%s", srv_baseurl, t);
35
36- *body = timeline_to_rss(NULL, tl, link, link, link);
37+ *body = rss_from_timeline(NULL, tl, link, link, link);
38 *ctype = "application/rss+xml; charset=utf-8";
39 }
40 else {
41@@ -373,6 +379,68 @@ int server_get_handler(xs_dict *req, const char *q_path,
42 }
43
44
45+int server_post_handler(const xs_dict *req, const char *q_path,
46+ char *payload, int p_size,
47+ char **body, int *b_size, char **ctype)
48+{
49+ int status = 0;
50+
51+ (void)payload;
52+ (void)p_size;
53+ (void)body;
54+ (void)b_size;
55+ (void)ctype;
56+
57+ if (strcmp(q_path, "/webmention-hook") == 0) {
58+ status = HTTP_STATUS_BAD_REQUEST;
59+
60+ const xs_dict *p_vars = xs_dict_get(req, "p_vars");
61+
62+ if (!xs_is_dict(p_vars))
63+ return status;
64+
65+ const char *source = xs_dict_get(p_vars, "source");
66+ const char *target = xs_dict_get(p_vars, "target");
67+
68+ if (!xs_is_string(source) || !xs_is_string(target)) {
69+ srv_debug(1, xs_fmt("webmention-hook bad source or target"));
70+ return status;
71+ }
72+
73+ if (!xs_startswith(target, srv_baseurl)) {
74+ srv_debug(1, xs_fmt("webmention-hook unknown target %s", target));
75+ return status;
76+ }
77+
78+ /* get the user */
79+ xs *s1 = xs_replace(target, srv_baseurl, "");
80+
81+ xs *l1 = xs_split(s1, "/");
82+ const char *uid = xs_list_get(l1, 1);
83+ snac user;
84+
85+ if (!xs_is_string(uid) || !user_open(&user, uid)) {
86+ srv_debug(1, xs_fmt("webmention-hook cannot find user for %s", target));
87+ return status;
88+ }
89+
90+ int r = xs_webmention_hook(source, target, USER_AGENT);
91+
92+ if (r > 0) {
93+ notify_add(&user, "Webmention", NULL, source, target, xs_stock(XSTYPE_DICT));
94+ timeline_touch(&user);
95+ }
96+
97+ srv_log(xs_fmt("webmention-hook source=%s target=%s %d", source, target, r));
98+
99+ user_free(&user);
100+ status = HTTP_STATUS_OK;
101+ }
102+
103+ return status;
104+}
105+
106+
107 void httpd_connection(FILE *f)
108 /* the connection processor */
109 {
110@@ -444,6 +512,10 @@ void httpd_connection(FILE *f)
111 else
112 if (strcmp(method, "POST") == 0) {
113
114+ if (status == 0)
115+ status = server_post_handler(req, q_path,
116+ payload, p_size, &body, &b_size, &ctype);
117+
118 #ifndef NO_MASTODON_API
119 if (status == 0)
120 status = oauth_post_handler(req, q_path,
121@@ -705,34 +777,36 @@ static pthread_cond_t sleep_cond;
122 static void *background_thread(void *arg)
123 /* background thread (queue management and other things) */
124 {
125- time_t purge_time;
126+ time_t t, purge_time, rss_time;
127
128 (void)arg;
129
130+ t = time(NULL);
131+
132 /* first purge time */
133- purge_time = time(NULL) + 10 * 60;
134+ purge_time = t + 10 * 60;
135+
136+ /* first RSS polling time */
137+ rss_time = t + 15 * 60;
138
139 srv_log(xs_fmt("background thread started"));
140
141 while (p_state->srv_running) {
142- time_t t;
143 int cnt = 0;
144
145 p_state->th_state[0] = THST_QUEUE;
146
147 {
148 xs *list = user_list();
149- char *p;
150 const char *uid;
151
152 /* process queues for all users */
153- p = list;
154- while (xs_list_iter(&p, &uid)) {
155- snac snac;
156+ xs_list_foreach(list, uid) {
157+ snac user;
158
159- if (user_open(&snac, uid)) {
160- cnt += process_user_queue(&snac);
161- user_free(&snac);
162+ if (user_open(&user, uid)) {
163+ cnt += process_user_queue(&user);
164+ user_free(&user);
165 }
166 }
167 }
168@@ -740,8 +814,10 @@ static void *background_thread(void *arg)
169 /* global queue */
170 cnt += process_queue();
171
172+ t = time(NULL);
173+
174 /* time to purge? */
175- if ((t = time(NULL)) > purge_time) {
176+ if (t > purge_time) {
177 /* next purge time is tomorrow */
178 purge_time = t + 24 * 60 * 60;
179
180@@ -750,6 +826,22 @@ static void *background_thread(void *arg)
181 job_post(q_item, 0);
182 }
183
184+ /* time to poll the RSS? */
185+ if (t > rss_time) {
186+ /* next RSS poll time */
187+ int hours = xs_number_get(xs_dict_get_def(srv_config, "rss_hashtag_poll_hours", "4"));
188+
189+ /* don't hammer servers too much */
190+ if (hours < 1)
191+ hours = 1;
192+
193+ rss_time = t + 60 * 60 * hours;
194+
195+ xs *q_item = xs_dict_new();
196+ q_item = xs_dict_append(q_item, "type", "rss_hashtag_poll");
197+ job_post(q_item, 0);
198+ }
199+
200 if (cnt == 0) {
201 /* sleep 3 seconds */
202
M
main.c
+108,
-58
1@@ -7,64 +7,90 @@
2 #include "xs_time.h"
3 #include "xs_openssl.h"
4 #include "xs_match.h"
5+#include "xs_random.h"
6
7 #include "snac.h"
8
9 #include <sys/stat.h>
10 #include <sys/wait.h>
11
12-int usage(void)
13+int usage(const char *cmd)
14 {
15 printf("snac " VERSION " - A simple, minimalistic ActivityPub instance\n");
16 printf("Copyright (c) 2022 - 2025 grunfink et al. / MIT license\n");
17 printf("\n");
18- printf("Commands:\n");
19- printf("\n");
20- printf("init [{basedir}] Initializes the data storage\n");
21- printf("upgrade {basedir} Upgrade to a new version\n");
22- printf("adduser {basedir} [{uid}] Adds a new user\n");
23- printf("deluser {basedir} {uid} Deletes a user\n");
24- printf("httpd {basedir} Starts the HTTPD daemon\n");
25- printf("purge {basedir} Purges old data\n");
26- printf("state {basedir} Prints server state\n");
27- printf("webfinger {basedir} {account} Queries about an account (@user@host or actor url)\n");
28- printf("queue {basedir} {uid} Processes a user queue\n");
29- printf("follow {basedir} {uid} {actor} Follows an actor\n");
30- printf("unfollow {basedir} {uid} {actor} Unfollows an actor\n");
31- printf("request {basedir} {uid} {url} Requests an object\n");
32- printf("insert {basedir} {uid} {url} Requests an object and inserts it into the timeline\n");
33- printf("actor {basedir} [{uid}] {url} Requests an actor\n");
34- printf("note {basedir} {uid} {text} [files...] Sends a note with optional attachments\n");
35- printf("note_unlisted {basedir} {uid} {text} [files...] Sends an unlisted note with optional attachments\n");
36- printf("note_mention {basedir} {uid} {text} [files...] Sends a note only to mentioned accounts\n");
37- printf("boost|announce {basedir} {uid} {url} Boosts (announces) a post\n");
38- printf("unboost {basedir} {uid} {url} Unboosts a post\n");
39- printf("resetpwd {basedir} {uid} Resets the password of a user\n");
40- printf("ping {basedir} {uid} {actor} Pings an actor\n");
41- printf("webfinger_s {basedir} {uid} {account} Queries about an account (@user@host or actor url)\n");
42- printf("pin {basedir} {uid} {msg_url} Pins a message\n");
43- printf("unpin {basedir} {uid} {msg_url} Unpins a message\n");
44- printf("bookmark {basedir} {uid} {msg_url} Bookmarks a message\n");
45- printf("unbookmark {basedir} {uid} {msg_url} Unbookmarks a message\n");
46- printf("block {basedir} {instance_url} Blocks a full instance\n");
47- printf("unblock {basedir} {instance_url} Unblocks a full instance\n");
48- printf("limit {basedir} {uid} {actor} Limits an actor (drops their announces)\n");
49- printf("unlimit {basedir} {uid} {actor} Unlimits an actor\n");
50- printf("unmute {basedir} {uid} {actor} Unmutes a previously muted actor\n");
51- printf("verify_links {basedir} {uid} Verifies a user's links (in the metadata)\n");
52- printf("search {basedir} {uid} {regex} Searches posts by content\n");
53- printf("export_csv {basedir} {uid} Exports data as CSV files\n");
54- printf("alias {basedir} {uid} {account} Sets account (@user@host or actor url) as an alias\n");
55- printf("migrate {basedir} {uid} Migrates to the account defined as the alias\n");
56- printf("import_csv {basedir} {uid} Imports data from CSV files\n");
57- printf("import_list {basedir} {uid} {file} Imports a Mastodon CSV list file\n");
58- printf("import_block_list {basedir} {uid} {file} Imports a Mastodon CSV block list file\n");
59- printf("lists {basedir} {uid} Returns the names of the lists created by the user\n");
60- printf("list_members {basedir} {uid} {name} Returns the list of accounts inside a list\n");
61- printf("create_list {basedir} {uid} {name} Creates a new list\n");
62- printf("delete_list {basedir} {uid} {name} Deletes an existing list\n");
63- printf("list_add {basedir} {uid} {name} {acct} Adds an account (@user@host or actor url) to a list\n");
64- printf("list_del {basedir} {uid} {name} {actor} Deletes an actor URL from a list\n");
65+
66+ if (cmd == NULL) {
67+ printf("Commands:\n");
68+ printf("\n");
69+ }
70+
71+ const char *cmds =
72+ "init [{basedir}] Initializes the data storage\n"
73+ "upgrade {basedir} Upgrade to a new version\n"
74+ "adduser {basedir} [{uid}] Adds a new user\n"
75+ "deluser {basedir} {uid} Deletes a user\n"
76+ "httpd {basedir} Starts the HTTPD daemon\n"
77+ "purge {basedir} Purges old data\n"
78+ "state {basedir} Prints server state\n"
79+ "webfinger {basedir} {account} Queries about an account (@user@host or actor url)\n"
80+ "queue {basedir} {uid} Processes a user queue\n"
81+ "follow {basedir} {uid} {actor} Follows an actor\n"
82+ "unfollow {basedir} {uid} {actor} Unfollows an actor\n"
83+ "request {basedir} {uid} {url} Requests an object\n"
84+ "insert {basedir} {uid} {url} Requests an object and inserts it into the timeline\n"
85+ "actor {basedir} [{uid}] {url} Requests an actor\n"
86+ "note {basedir} {uid} {text} [files...] Sends a note with optional attachments\n"
87+ "note_unlisted {basedir} {uid} {text} [files...] Sends an unlisted note with optional attachments\n"
88+ "note_mention {basedir} {uid} {text} [files...] Sends a note only to mentioned accounts\n"
89+ "boost|announce {basedir} {uid} {url} Boosts (announces) a post\n"
90+ "unboost {basedir} {uid} {url} Unboosts a post\n"
91+ "resetpwd {basedir} {uid} Resets the password of a user\n"
92+ "ping {basedir} {uid} {actor} Pings an actor\n"
93+ "webfinger_s {basedir} {uid} {account} Queries about an account (@user@host or actor url)\n"
94+ "pin {basedir} {uid} {msg_url} Pins a message\n"
95+ "unpin {basedir} {uid} {msg_url} Unpins a message\n"
96+ "bookmark {basedir} {uid} {msg_url} Bookmarks a message\n"
97+ "unbookmark {basedir} {uid} {msg_url} Unbookmarks a message\n"
98+ "block {basedir} {instance_url} Blocks a full instance\n"
99+ "unblock {basedir} {instance_url} Unblocks a full instance\n"
100+ "limit {basedir} {uid} {actor} Limits an actor (drops their announces)\n"
101+ "unlimit {basedir} {uid} {actor} Unlimits an actor\n"
102+ "unmute {basedir} {uid} {actor} Unmutes a previously muted actor\n"
103+ "verify_links {basedir} {uid} Verifies a user's links (in the metadata)\n"
104+ "search {basedir} {uid} {regex} Searches posts by content\n"
105+ "export_csv {basedir} {uid} Exports followers, lists, MUTEd and bookmarks to CSV\n"
106+ "export_posts {basedir} {iod} Exports all posts to outbox.json\n"
107+ "alias {basedir} {uid} {account} Sets account (@user@host or actor url) as an alias\n"
108+ "migrate {basedir} {uid} Migrates to the account defined as the alias\n"
109+ "import_csv {basedir} {uid} Imports data from CSV files\n"
110+ "import_list {basedir} {uid} {file} Imports a Mastodon CSV list file\n"
111+ "import_block_list {basedir} {uid} {file} Imports a Mastodon CSV block list file\n"
112+ "lists {basedir} {uid} Returns the names of the lists created by the user\n"
113+ "list_members {basedir} {uid} {name} Returns the list of accounts inside a list\n"
114+ "list_create {basedir} {uid} {name} Creates a new list\n"
115+ "list_remove {basedir} {uid} {name} Removes an existing list\n"
116+ "list_add {basedir} {uid} {name} {acct} Adds an account (@user@host or actor url) to a list\n"
117+ "list_del {basedir} {uid} {name} {actor} Deletes an actor URL from a list\n";
118+
119+ if (cmd == NULL)
120+ printf("%s", cmds);
121+ else {
122+ /* only show help for the entered command */
123+ xs *l = xs_split(cmds, "\n");
124+ const char *v;
125+ int cnt = 0;
126+
127+ xs_list_foreach(l, v) {
128+ if (xs_str_in(v, cmd) != -1) {
129+ printf("%s\n", v);
130+ cnt++;
131+ }
132+ }
133+
134+ if (cnt == 0)
135+ printf("%s", cmds);
136+ }
137
138 return 1;
139 }
140@@ -94,7 +120,7 @@ int main(int argc, char *argv[])
141 umask(0007);
142
143 if ((cmd = GET_ARGV()) == NULL)
144- return usage();
145+ return usage(cmd);
146
147 if (strcmp(cmd, "init") == 0) { /** **/
148 /* initialize the data storage */
149@@ -106,7 +132,7 @@ int main(int argc, char *argv[])
150
151 if ((basedir = getenv("SNAC_BASEDIR")) == NULL) {
152 if ((basedir = GET_ARGV()) == NULL)
153- return usage();
154+ return usage(cmd);
155 }
156
157 if (strcmp(cmd, "upgrade") == 0) { /** **/
158@@ -143,6 +169,11 @@ int main(int argc, char *argv[])
159 return 0;
160 }
161
162+ if (strcmp(cmd, "poll_hashtag_rss") == 0) { /** **/
163+ rss_poll_hashtags();
164+ return 0;
165+ }
166+
167 if (strcmp(cmd, "state") == 0) { /** **/
168 xs *shm_name = NULL;
169 srv_state *p_state = srv_state_op(&shm_name, 1);
170@@ -167,7 +198,7 @@ int main(int argc, char *argv[])
171 }
172
173 if ((user = GET_ARGV()) == NULL)
174- return usage();
175+ return usage(cmd);
176
177 if (strcmp(cmd, "block") == 0) { /** **/
178 int ret = instance_block(user);
179@@ -279,6 +310,11 @@ int main(int argc, char *argv[])
180 return 0;
181 }
182
183+ if (strcmp(cmd, "export_posts") == 0) { /** **/
184+ export_posts(&snac);
185+ return 0;
186+ }
187+
188 if (strcmp(cmd, "import_csv") == 0) { /** **/
189 import_csv(&snac);
190 return 0;
191@@ -300,7 +336,7 @@ int main(int argc, char *argv[])
192 }
193
194 if ((url = GET_ARGV()) == NULL)
195- return usage();
196+ return usage(cmd);
197
198 if (strcmp(cmd, "list_members") == 0) { /** **/
199 xs *lid = list_maint(&snac, url, 4);
200@@ -323,7 +359,7 @@ int main(int argc, char *argv[])
201 return 0;
202 }
203
204- if (strcmp(cmd, "create_list") == 0) { /** **/
205+ if (strcmp(cmd, "list_create") == 0) { /** **/
206 xs *lid = list_maint(&snac, url, 4);
207
208 if (lid == NULL) {
209@@ -336,7 +372,7 @@ int main(int argc, char *argv[])
210 return 0;
211 }
212
213- if (strcmp(cmd, "delete_list") == 0) { /** **/
214+ if (strcmp(cmd, "list_remove") == 0) { /** **/
215 xs *lid = list_maint(&snac, url, 4);
216
217 if (lid != NULL) {
218@@ -744,12 +780,24 @@ int main(int argc, char *argv[])
219 xs *msg = NULL;
220 xs *c_msg = NULL;
221 xs *attl = xs_list_new();
222- char *fn = NULL;
223+ const char *fn = NULL;
224+ const char *in_reply_to = NULL;
225+ const char **next = NULL;
226
227 /* iterate possible attachments */
228 while ((fn = GET_ARGV())) {
229 FILE *f;
230
231+ if (next) {
232+ *next = fn;
233+ next = NULL;
234+ }
235+ else
236+ if (strcmp(fn, "-r") == 0) {
237+ /* next argument is an inReplyTo */
238+ next = &in_reply_to;
239+ }
240+ else
241 if ((f = fopen(fn, "rb")) != NULL) {
242 /* get the file size and content */
243 fseek(f, 0, SEEK_END);
244@@ -759,8 +807,10 @@ int main(int argc, char *argv[])
245 fclose(f);
246
247 char *ext = strrchr(fn, '.');
248- xs *hash = xs_md5_hex(fn, strlen(fn));
249- xs *id = xs_fmt("%s%s", hash, ext);
250+ char rnd[32];
251+ xs_rnd_buf(rnd, sizeof(rnd));
252+ xs *hash = xs_md5_hex(rnd, sizeof(rnd));
253+ xs *id = xs_fmt("post-%s%s", hash, ext ? ext : "");
254 xs *url = xs_fmt("%s/s/%s", snac.actor, id);
255
256 /* store */
257@@ -821,7 +871,7 @@ int main(int argc, char *argv[])
258 if (strcmp(cmd, "note_unlisted") == 0)
259 scope = 2;
260
261- msg = msg_note(&snac, content, NULL, NULL, attl, scope, getenv("LANG"), NULL);
262+ msg = msg_note(&snac, content, NULL, in_reply_to, attl, scope, getenv("LANG"), NULL);
263
264 c_msg = msg_create(&snac, msg);
265
+128,
-19
1@@ -15,6 +15,7 @@
2 #include "xs_url.h"
3 #include "xs_mime.h"
4 #include "xs_match.h"
5+#include "xs_unicode.h"
6
7 #include "snac.h"
8
9@@ -381,7 +382,7 @@ int oauth_post_handler(const xs_dict *req, const char *q_path,
10 }
11 }
12
13- /* no code?
14+ /* no code?
15 I'm not sure of the impacts of this right now, but Subway Tooter does not
16 provide a code so one must be generated */
17 if (xs_is_null(code)){
18@@ -1133,9 +1134,14 @@ xs_dict *mastoapi_status(snac *snac, const xs_dict *msg)
19 bst = xs_dict_set(bst, "content", "");
20 bst = xs_dict_set(bst, "reblog", st);
21
22+ xs *b_id = xs_toupper_i(xs_dup(xs_dict_get(st, "id")));
23+ bst = xs_dict_set(bst, "id", b_id);
24+
25 xs_free(st);
26 st = bst;
27 }
28+ else
29+ xs_free(bst);
30 }
31
32 return st;
33@@ -1338,9 +1344,9 @@ xs_list *mastoapi_timeline(snac *user, const xs_dict *args, const char *index_fn
34 if ((f = fopen(index_fn, "r")) == NULL)
35 return out;
36
37- const char *max_id = xs_dict_get(args, "max_id");
38- const char *since_id = xs_dict_get(args, "since_id");
39- const char *min_id = xs_dict_get(args, "min_id"); /* unsupported old-to-new navigation */
40+ const char *o_max_id = xs_dict_get(args, "max_id");
41+ const char *o_since_id = xs_dict_get(args, "since_id");
42+ const char *o_min_id = xs_dict_get(args, "min_id"); /* unsupported old-to-new navigation */
43 const char *limit_s = xs_dict_get(args, "limit");
44 int (*iterator)(FILE *, char *);
45 int initial_status = 0;
46@@ -1348,6 +1354,10 @@ xs_list *mastoapi_timeline(snac *user, const xs_dict *args, const char *index_fn
47 int limit = 0;
48 int cnt = 0;
49
50+ xs *max_id = o_max_id ? xs_tolower_i(xs_dup(o_max_id)) : NULL;
51+ xs *since_id = o_since_id ? xs_tolower_i(xs_dup(o_since_id)) : NULL;
52+ xs *min_id = o_min_id ? xs_tolower_i(xs_dup(o_min_id)) : NULL;
53+
54 if (!xs_is_null(limit_s))
55 limit = atoi(limit_s);
56
57@@ -1371,7 +1381,7 @@ xs_list *mastoapi_timeline(snac *user, const xs_dict *args, const char *index_fn
58 /* only return entries older that max_id */
59 if (max_id) {
60 if (strcmp(md5, MID_TO_MD5(max_id)) == 0) {
61- max_id = NULL;
62+ max_id = xs_free(max_id);
63 if (ascending)
64 break;
65 }
66@@ -1384,7 +1394,7 @@ xs_list *mastoapi_timeline(snac *user, const xs_dict *args, const char *index_fn
67 if (strcmp(md5, MID_TO_MD5(since_id)) == 0) {
68 if (!ascending)
69 break;
70- since_id = NULL;
71+ since_id = xs_free(since_id);
72 }
73 if (ascending)
74 continue;
75@@ -1637,7 +1647,7 @@ int mastoapi_get_handler(const xs_dict *req, const char *q_path,
76 const char *aq = xs_dict_get(args, "q");
77
78 if (!xs_is_null(aq)) {
79- xs *q = xs_tolower_i(xs_dup(aq));
80+ xs *q = xs_utf8_to_lower(aq);
81 out = xs_list_new();
82 xs *wing = following_list(&snac1);
83 xs *wers = follower_list(&snac1);
84@@ -1780,7 +1790,7 @@ int mastoapi_get_handler(const xs_dict *req, const char *q_path,
85 }
86 else
87 if (strcmp(opt, "statuses") == 0) {
88- /* we don't serve statuses of others; return the empty list */
89+ /* we don't serve statuses of others; return the empty list */
90 out = xs_list_new();
91 }
92 else
93@@ -1999,7 +2009,7 @@ int mastoapi_get_handler(const xs_dict *req, const char *q_path,
94 }
95 else
96 if (strcmp(cmd, "/v2/filters") == 0) { /** **/
97- /* snac will never have filters
98+ /* snac will never have filters
99 * but still, without a v2 endpoint a short delay is introduced
100 * in some apps */
101 *body = xs_dup("[]");
102@@ -2331,19 +2341,36 @@ int mastoapi_get_handler(const xs_dict *req, const char *q_path,
103 status = HTTP_STATUS_OK;
104 }
105 else
106+ if (strcmp(cmd, "/v1/instance/extended_description") == 0) { /** **/
107+ xs *d = xs_dict_new();
108+ xs *greeting = xs_fmt("%s/greeting.html", srv_basedir);
109+ time_t t = mtime(greeting);
110+ xs *updated_at = xs_str_iso_date(t);
111+ xs *content = xs_replace(snac_blurb, "%host%", xs_dict_get(srv_config, "host"));
112+
113+ d = xs_dict_set(d, "updated_at", updated_at);
114+ d = xs_dict_set(d, "content", content);
115+
116+ *body = xs_json_dumps(d, 4);
117+ *ctype = "application/json";
118+ status = HTTP_STATUS_OK;
119+ }
120+ else
121 if (xs_startswith(cmd, "/v1/statuses/")) { /** **/
122 /* information about a status */
123 if (logged_in) {
124 xs *l = xs_split(cmd, "/");
125- const char *id = xs_list_get(l, 3);
126+ const char *oid = xs_list_get(l, 3);
127 const char *op = xs_list_get(l, 4);
128
129- if (!xs_is_null(id)) {
130+ if (!xs_is_null(oid)) {
131 xs *msg = NULL;
132 xs *out = NULL;
133
134 /* skip the 'fake' part of the id */
135- id = MID_TO_MD5(id);
136+ oid = MID_TO_MD5(oid);
137+
138+ xs *id = xs_tolower_i(xs_dup(oid));
139
140 if (valid_status(object_get_by_md5(id, &msg))) {
141 if (op == NULL) {
142@@ -2459,7 +2486,7 @@ int mastoapi_get_handler(const xs_dict *req, const char *q_path,
143 if (logged_in) {
144 const xs_list *timeline = xs_dict_get(args, "timeline[]");
145 xs_str *json = NULL;
146- if (!xs_is_null(timeline))
147+ if (!xs_is_null(timeline))
148 json = xs_json_dumps(markers_get(&snac1, timeline), 4);
149
150 if (!xs_is_null(json))
151@@ -2475,6 +2502,40 @@ int mastoapi_get_handler(const xs_dict *req, const char *q_path,
152 }
153 else
154 if (strcmp(cmd, "/v1/followed_tags") == 0) { /** **/
155+ if (logged_in) {
156+ xs *r = xs_list_new();
157+ const xs_list *followed_hashtags = xs_dict_get_def(snac1.config,
158+ "followed_hashtags", xs_stock(XSTYPE_LIST));
159+ const char *hashtag;
160+
161+ xs_list_foreach(followed_hashtags, hashtag) {
162+ if (*hashtag == '#') {
163+ xs *d = xs_dict_new();
164+ xs *s = xs_fmt("%s?t=%s", srv_baseurl, hashtag + 1);
165+
166+ d = xs_dict_set(d, "name", hashtag + 1);
167+ d = xs_dict_set(d, "url", s);
168+ d = xs_dict_set(d, "history", xs_stock(XSTYPE_LIST));
169+
170+ r = xs_list_append(r, d);
171+ }
172+ }
173+
174+ *body = xs_json_dumps(r, 4);
175+ *ctype = "application/json";
176+ status = HTTP_STATUS_OK;
177+ }
178+ else
179+ status = HTTP_STATUS_UNAUTHORIZED;
180+ }
181+ else
182+ if (strcmp(cmd, "/v1/blocks") == 0) { /** **/
183+ *body = xs_dup("[]");
184+ *ctype = "application/json";
185+ status = HTTP_STATUS_OK;
186+ }
187+ else
188+ if (strcmp(cmd, "/v1/mutes") == 0) { /** **/
189 *body = xs_dup("[]");
190 *ctype = "application/json";
191 status = HTTP_STATUS_OK;
192@@ -2507,9 +2568,7 @@ int mastoapi_get_handler(const xs_dict *req, const char *q_path,
193 /* reply something only for offset 0; otherwise,
194 apps like Tusky keep asking again and again */
195 if (xs_startswith(q, "https://")) {
196- xs *md5 = xs_md5_hex(q, strlen(q));
197-
198- if (!timeline_here(&snac1, md5)) {
199+ if (!timeline_here(&snac1, q)) {
200 xs *object = NULL;
201 int status;
202
203@@ -2979,8 +3038,10 @@ int mastoapi_post_handler(const xs_dict *req, const char *q_path,
204
205 if (*fn != '\0') {
206 char *ext = strrchr(fn, '.');
207- xs *hash = xs_md5_hex(fn, strlen(fn));
208- xs *id = xs_fmt("%s%s", hash, ext);
209+ char rnd[32];
210+ xs_rnd_buf(rnd, sizeof(rnd));
211+ xs *hash = xs_md5_hex(rnd, sizeof(rnd));
212+ xs *id = xs_fmt("post-%s%s", hash, ext ? ext : "");
213 xs *url = xs_fmt("%s/s/%s", snac.actor, id);
214 int fo = xs_number_get(xs_list_get(file, 1));
215 int fs = xs_number_get(xs_list_get(file, 2));
216@@ -3227,7 +3288,7 @@ int mastoapi_post_handler(const xs_dict *req, const char *q_path,
217 if (!xs_is_null(home))
218 home_marker = xs_dict_get(home, "last_read_id");
219 }
220-
221+
222 const xs_str *notify_marker = xs_dict_get(args, "notifications[last_read_id]");
223 if (xs_is_null(notify_marker)) {
224 const xs_dict *notify = xs_dict_get(args, "notifications");
225@@ -3295,6 +3356,54 @@ int mastoapi_post_handler(const xs_dict *req, const char *q_path,
226 }
227 }
228 }
229+ else
230+ if (xs_startswith(cmd, "/v1/tags/")) { /** **/
231+ if (logged_in) {
232+ xs *l = xs_split(cmd, "/");
233+ const char *i_tag = xs_list_get(l, 3);
234+ const char *cmd = xs_list_get(l, 4);
235+
236+ status = HTTP_STATUS_UNPROCESSABLE_CONTENT;
237+
238+ if (xs_is_string(i_tag) && xs_is_string(cmd)) {
239+ int ok = 0;
240+
241+ xs *tag = xs_fmt("#%s", i_tag);
242+ xs *followed_hashtags = xs_dup(xs_dict_get_def(snac.config,
243+ "followed_hashtags", xs_stock(XSTYPE_LIST)));
244+
245+ if (strcmp(cmd, "follow") == 0) {
246+ followed_hashtags = xs_list_append(followed_hashtags, tag);
247+ ok = 1;
248+ }
249+ else
250+ if (strcmp(cmd, "unfollow") == 0) {
251+ int off = xs_list_in(followed_hashtags, tag);
252+
253+ if (off != -1)
254+ followed_hashtags = xs_list_del(followed_hashtags, off);
255+
256+ ok = 1;
257+ }
258+
259+ if (ok) {
260+ /* update */
261+ xs_dict_set(snac.config, "followed_hashtags", followed_hashtags);
262+ user_persist(&snac, 0);
263+
264+ xs *d = xs_dict_new();
265+ xs *s = xs_fmt("%s?t=%s", srv_baseurl, i_tag);
266+ d = xs_dict_set(d, "name", i_tag);
267+ d = xs_dict_set(d, "url", s);
268+ d = xs_dict_set(d, "history", xs_stock(XSTYPE_LIST));
269+
270+ *body = xs_json_dumps(d, 4);
271+ *ctype = "application/json";
272+ status = HTTP_STATUS_OK;
273+ }
274+ }
275+ }
276+ }
277 else
278 status = HTTP_STATUS_UNPROCESSABLE_CONTENT;
279
M
po/ru.po
+4,
-4
1@@ -126,7 +126,7 @@ msgstr "люди"
2
3 #: html.c:936
4 msgid "instance"
5-msgstr "экземпляр"
6+msgstr "сервер"
7
8 #: html.c:945
9 msgid ""
10@@ -679,12 +679,12 @@ msgstr "Ничего не найдено для '%s'"
11
12 #: html.c:3929
13 msgid "Showing instance timeline"
14-msgstr "Показываем ленту инстанции"
15+msgstr "Показываем ленту сервера"
16
17 #: html.c:4012
18 #, c-format
19 msgid "Showing timeline for list '%s'"
20-msgstr "Показываем ленты инстанции для списка '%s'"
21+msgstr "Показываем ленту для списка '%s'"
22
23 #: httpd.c:250
24 #, c-format
25@@ -693,7 +693,7 @@ msgstr "Результаты поиска для тега #%s"
26
27 #: httpd.c:259
28 msgid "Recent posts by users in this instance"
29-msgstr "Последние сообщения на этой инстанции"
30+msgstr "Последние сообщения на этом сервере"
31
32 #: html.c:1603
33 msgid "Blocked hashtags..."
A
po/uk.po
+768,
-0
1@@ -0,0 +1,768 @@
2+# snac message translation file
3+#
4+msgid ""
5+msgstr ""
6+"Project-Id-Version: snac\n"
7+"Last-Translator: grunfink\n"
8+"Language: uk\n"
9+"Content-Type: text/plain; charset=UTF-8\n"
10+"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && "
11+"n%10<=4 && (n%100<12 || n%100>14) ? 1 : 2);\n"
12+"POT-Creation-Date: \n"
13+"PO-Revision-Date: \n"
14+"Language-Team: \n"
15+"MIME-Version: 1.0\n"
16+"Content-Transfer-Encoding: 8bit\n"
17+"X-Generator: Poedit 3.0\n"
18+
19+#: html.c:384
20+msgid "Sensitive content: "
21+msgstr "Делікатний вміст: "
22+
23+#: html.c:392
24+msgid "Sensitive content description"
25+msgstr "Опис делікатного вмісту"
26+
27+#: html.c:405
28+msgid "Only for mentioned people: "
29+msgstr "Тільки для зазначених осіб: "
30+
31+#: html.c:428
32+msgid "Reply to (URL): "
33+msgstr "Відповісти до (URL): "
34+
35+#: html.c:437
36+msgid "Don't send, but store as a draft"
37+msgstr "Не надсилати, але зберігти як чернетку"
38+
39+#: html.c:438
40+msgid "Draft:"
41+msgstr "Чернетка:"
42+
43+#: html.c:494
44+msgid "Attachments..."
45+msgstr "Вкладення..."
46+
47+#: html.c:517
48+msgid "File:"
49+msgstr "Файл:"
50+
51+#: html.c:521
52+msgid "Clear this field to delete the attachment"
53+msgstr "Очистіть це поле, щоб видалити вкладення"
54+
55+#: html.c:530 html.c:555
56+msgid "Attachment description"
57+msgstr "Опис вкладення"
58+
59+#: html.c:566
60+msgid "Poll..."
61+msgstr "Опитування..."
62+
63+#: html.c:568
64+msgid "Poll options (one per line, up to 8):"
65+msgstr "Варіанти відповідей (по одному в рядку, до 8):"
66+
67+#: html.c:580
68+msgid "One choice"
69+msgstr "Єдиний вибір"
70+
71+#: html.c:583
72+msgid "Multiple choices"
73+msgstr "Декілька варіантів вибору"
74+
75+#: html.c:589
76+msgid "End in 5 minutes"
77+msgstr "Закінчення через 5 хвилин"
78+
79+#: html.c:593
80+msgid "End in 1 hour"
81+msgstr "Закінчення через 1 годину"
82+
83+#: html.c:596
84+msgid "End in 1 day"
85+msgstr "Закінчення через 1 день"
86+
87+#: html.c:604
88+msgid "Post"
89+msgstr "Надіслати"
90+
91+#: html.c:701 html.c:708
92+msgid "Site description"
93+msgstr "Опис сайту"
94+
95+#: html.c:719
96+msgid "Admin email"
97+msgstr "Пошта админа"
98+
99+#: html.c:732
100+msgid "Admin account"
101+msgstr "Обліковий запис адміна"
102+
103+#: html.c:800 html.c:1136
104+#, c-format
105+msgid "%d following, %d followers"
106+msgstr "%d підписок, %d підписників"
107+
108+#: html.c:890
109+msgid "RSS"
110+msgstr "RSS"
111+
112+#: html.c:895 html.c:923
113+msgid "private"
114+msgstr "особисте"
115+
116+#: html.c:919
117+msgid "public"
118+msgstr "публічне"
119+
120+#: html.c:927
121+msgid "notifications"
122+msgstr "повідомлення"
123+
124+#: html.c:932
125+msgid "people"
126+msgstr "люди"
127+
128+#: html.c:936
129+msgid "instance"
130+msgstr "сервер"
131+
132+#: html.c:945
133+msgid ""
134+"Search posts by URL or content (regular expression), @user@host accounts, or "
135+"#tag"
136+msgstr ""
137+"Шукати дописи за URL або вмістом (регулярний вираз), акаунтами @user@host "
138+"або #тегом"
139+
140+#: html.c:946
141+msgid "Content search"
142+msgstr "Пошук за вмістом"
143+
144+#: html.c:1068
145+msgid "verified link"
146+msgstr "перевірене посилання"
147+
148+#: html.c:1125 html.c:2540 html.c:2553 html.c:2562
149+msgid "Location: "
150+msgstr "Місце знаходження: "
151+
152+#: html.c:1161
153+msgid "New Post..."
154+msgstr "Новий допис..."
155+
156+#: html.c:1163
157+msgid "What's on your mind?"
158+msgstr "Що у вас на думці?"
159+
160+#: html.c:1172
161+msgid "Operations..."
162+msgstr "Дії..."
163+
164+#: html.c:1187 html.c:1788 html.c:3193 html.c:4578
165+msgid "Follow"
166+msgstr "Підписатися"
167+
168+#: html.c:1189
169+msgid "(by URL or user@host)"
170+msgstr "(за URL або user@host)"
171+
172+#: html.c:1204 html.c:1764 html.c:4527
173+msgid "Boost"
174+msgstr "Просувати"
175+
176+#: html.c:1206 html.c:1223
177+msgid "(by URL)"
178+msgstr "(за URL)"
179+
180+#: html.c:1221 html.c:1743 html.c:4518
181+msgid "Like"
182+msgstr "Вподобайка"
183+
184+#: html.c:1347
185+msgid "User Settings..."
186+msgstr "Налаштування користувача..."
187+
188+#: html.c:1356
189+msgid "Display name:"
190+msgstr "Видиме ім'я:"
191+
192+#: html.c:1362
193+msgid "Your name"
194+msgstr "Ваше ім'я"
195+
196+#: html.c:1364
197+msgid "Avatar: "
198+msgstr "Аватар: "
199+
200+#: html.c:1372
201+msgid "Delete current avatar"
202+msgstr "Видалити поточний аватар"
203+
204+#: html.c:1374
205+msgid "Header image (banner): "
206+msgstr "Зображення заголовка (банер): "
207+
208+#: html.c:1382
209+msgid "Delete current header image"
210+msgstr "Видалити поточне зображення заголовка"
211+
212+#: html.c:1384
213+msgid "Bio:"
214+msgstr "Про себе:"
215+
216+#: html.c:1390
217+msgid "Write about yourself here..."
218+msgstr "Напишіть про себе тут..."
219+
220+#: html.c:1399
221+msgid "Always show sensitive content"
222+msgstr "Завжди показувати делікатний вміст"
223+
224+#: html.c:1401
225+msgid "Email address for notifications:"
226+msgstr "Поштова адреса для повідомлень:"
227+
228+#: html.c:1409
229+msgid "Telegram notifications (bot key and chat id):"
230+msgstr "Повідомлення в Telegram (ключ бота та id чату):"
231+
232+#: html.c:1423
233+msgid "ntfy notifications (ntfy server and token):"
234+msgstr "Повідомлення в ntfy (сервер та токен ntfy):"
235+
236+#: html.c:1437
237+msgid "Maximum days to keep posts (0: server settings):"
238+msgstr "Максимальний час для зберігання дописів (0: налаштування сервера):"
239+
240+#: html.c:1451
241+msgid "Drop direct messages from people you don't follow"
242+msgstr "Відхиляти особисті повідомлення від незнайомців"
243+
244+#: html.c:1460
245+msgid "This account is a bot"
246+msgstr "Цей акаунт є ботом"
247+
248+#: html.c:1469
249+msgid "Auto-boost all mentions to this account"
250+msgstr "Автоматично просувати всі згадки цього акаунта"
251+
252+#: html.c:1478
253+msgid "This account is private (posts are not shown through the web)"
254+msgstr "Це закритий акаунт (дописи не показуються в мережі)"
255+
256+#: html.c:1488
257+msgid "Collapse top threads by default"
258+msgstr "Згорнути потоки за замовчуванням"
259+
260+#: html.c:1497
261+msgid "Follow requests must be approved"
262+msgstr "Запити на підписку мають бути схвалені"
263+
264+#: html.c:1506
265+msgid "Publish follower and following metrics"
266+msgstr "Публікувати метрики підписок та підписників"
267+
268+#: html.c:1508
269+msgid "Current location:"
270+msgstr "Поточне місцезнаходження:"
271+
272+#: html.c:1522
273+msgid "Profile metadata (key=value pairs in each line):"
274+msgstr "Метадані профілю (пари ключ=значення в кожному рядку):"
275+
276+#: html.c:1533
277+msgid "Web interface language:"
278+msgstr "Мова інтерфейсу:"
279+
280+#: html.c:1543
281+msgid "New password:"
282+msgstr "Новий пароль:"
283+
284+#: html.c:1550
285+msgid "Repeat new password:"
286+msgstr "Повторити новий пароль:"
287+
288+#: html.c:1560
289+msgid "Update user info"
290+msgstr "Оновлення даних користувача"
291+
292+#: html.c:1571
293+msgid "Followed hashtags..."
294+msgstr "Підписані хештеги..."
295+
296+#: html.c:1573 html.c:1605
297+msgid "One hashtag per line"
298+msgstr "Один хештег на рядок"
299+
300+#: html.c:1594 html.c:1626
301+msgid "Update hashtags"
302+msgstr "Оновити хештеги"
303+
304+#: html.c:1743
305+msgid "Say you like this post"
306+msgstr "Позначте допис як вподобаний"
307+
308+#: html.c:1748 html.c:4536
309+msgid "Unlike"
310+msgstr "Більше не подобається"
311+
312+#: html.c:1748
313+msgid "Nah don't like it that much"
314+msgstr "Мені це не дуже подобається"
315+
316+#: html.c:1754 html.c:4673
317+msgid "Unpin"
318+msgstr "Відкріпити"
319+
320+#: html.c:1754
321+msgid "Unpin this post from your timeline"
322+msgstr "Відкріпити цей допис зі власної стрічки"
323+
324+#: html.c:1757 html.c:4668
325+msgid "Pin"
326+msgstr "Закріпити"
327+
328+#: html.c:1757
329+msgid "Pin this post to the top of your timeline"
330+msgstr "Закріпити цей допис до власної стрічки"
331+
332+#: html.c:1764
333+msgid "Announce this post to your followers"
334+msgstr "Поділитися цим дописом зі своїми підписниками"
335+
336+#: html.c:1769 html.c:4544
337+msgid "Unboost"
338+msgstr "Скасувати просування"
339+
340+#: html.c:1769
341+msgid "I regret I boosted this"
342+msgstr "Я шкодую, що просував це"
343+
344+#: html.c:1775 html.c:4683
345+msgid "Unbookmark"
346+msgstr "Видалити з закладок"
347+
348+#: html.c:1775
349+msgid "Delete this post from your bookmarks"
350+msgstr "Видалити цей допис з закладок"
351+
352+#: html.c:1778 html.c:4678
353+msgid "Bookmark"
354+msgstr "Додати в закладки"
355+
356+#: html.c:1778
357+msgid "Add this post to your bookmarks"
358+msgstr "Додайте цей допис в закладки"
359+
360+#: html.c:1784 html.c:3179 html.c:3367 html.c:4591
361+msgid "Unfollow"
362+msgstr "Відписатися"
363+
364+#: html.c:1784 html.c:3180
365+msgid "Stop following this user's activity"
366+msgstr "Відписатися від цього користувача"
367+
368+#: html.c:1788 html.c:3194
369+msgid "Start following this user's activity"
370+msgstr "Підписатися до цього користувача"
371+
372+#: html.c:1794 html.c:4621
373+msgid "Unfollow Group"
374+msgstr "Відписатися від групи"
375+
376+#: html.c:1795
377+msgid "Stop following this group or channel"
378+msgstr "Відписатися від групи чи канала"
379+
380+#: html.c:1799 html.c:4608
381+msgid "Follow Group"
382+msgstr "Підписатися на групу"
383+
384+#: html.c:1800
385+msgid "Start following this group or channel"
386+msgstr "Підписатися на групу чи канал"
387+
388+#: html.c:1805 html.c:3216 html.c:4552
389+msgid "MUTE"
390+msgstr "Заглушити"
391+
392+#: html.c:1806
393+msgid "Block any activity from this user forever"
394+msgstr "Назавжди заблокувати активність цього користувача"
395+
396+#: html.c:1811 html.c:3198 html.c:4638
397+msgid "Delete"
398+msgstr "Видалити"
399+
400+#: html.c:1811
401+msgid "Delete this post"
402+msgstr "Видалити цей допис"
403+
404+#: html.c:1814 html.c:4560
405+msgid "Hide"
406+msgstr "Приховати"
407+
408+#: html.c:1814
409+msgid "Hide this post and its children"
410+msgstr "Приховати цей допис разом з обговоренням"
411+
412+#: html.c:1845
413+msgid "Edit..."
414+msgstr "Редагувати..."
415+
416+#: html.c:1865
417+msgid "Reply..."
418+msgstr "Відповісти..."
419+
420+#: html.c:1916
421+msgid "Truncated (too deep)"
422+msgstr "Обрізано (занадто багато)"
423+
424+#: html.c:1925
425+msgid "follows you"
426+msgstr "підписан на вас"
427+
428+#: html.c:1988
429+msgid "Pinned"
430+msgstr "Закріплено"
431+
432+#: html.c:1996
433+msgid "Bookmarked"
434+msgstr "Додано до закладок"
435+
436+#: html.c:2004
437+msgid "Poll"
438+msgstr "Опитування"
439+
440+#: html.c:2011
441+msgid "Voted"
442+msgstr "Проголосовано"
443+
444+#: html.c:2020
445+msgid "Event"
446+msgstr "Подія"
447+
448+#: html.c:2052 html.c:2081
449+msgid "boosted"
450+msgstr "просунуто"
451+
452+#: html.c:2097
453+msgid "in reply to"
454+msgstr "у відповідь на"
455+
456+#: html.c:2148
457+msgid " [SENSITIVE CONTENT]"
458+msgstr " [ДЕЛІКАТНИЙ ВМІСТ]"
459+
460+#: html.c:2325
461+msgid "Vote"
462+msgstr "Голос"
463+
464+#: html.c:2335
465+msgid "Closed"
466+msgstr "Завершено"
467+
468+#: html.c:2360
469+msgid "Closes in"
470+msgstr "Завершується через"
471+
472+#: html.c:2441
473+msgid "Video"
474+msgstr "Відео"
475+
476+#: html.c:2456
477+msgid "Audio"
478+msgstr "Аудіо"
479+
480+#: html.c:2484
481+msgid "Attachment"
482+msgstr "Вкладення"
483+
484+#: html.c:2498
485+msgid "Alt..."
486+msgstr "Опис..."
487+
488+#: html.c:2511
489+msgid "Source channel or community"
490+msgstr "Вихідний канал або спільнота"
491+
492+#: html.c:2605
493+msgid "Time: "
494+msgstr "Час: "
495+
496+#: html.c:2686
497+msgid "Older..."
498+msgstr "Раніше..."
499+
500+#: html.c:2788
501+msgid "about this site"
502+msgstr "про цей сайт"
503+
504+#: html.c:2790
505+msgid "powered by "
506+msgstr "на базі "
507+
508+#: html.c:2855
509+msgid "Dismiss"
510+msgstr "Відхилити"
511+
512+#: html.c:2872
513+#, c-format
514+msgid "Timeline for list '%s'"
515+msgstr "Стрічки для списку '%s'"
516+
517+#: html.c:2891 html.c:3944
518+msgid "Pinned posts"
519+msgstr "Закріплені дописи"
520+
521+#: html.c:2903 html.c:3959
522+msgid "Bookmarked posts"
523+msgstr "Дописи у закладках"
524+
525+#: html.c:2915 html.c:3974
526+msgid "Post drafts"
527+msgstr "Чернетки дописів"
528+
529+#: html.c:2986
530+msgid "No more unseen posts"
531+msgstr "Усе переглянуто"
532+
533+#: html.c:2990 html.c:3090
534+msgid "Back to top"
535+msgstr "Повернутися до початку"
536+
537+#: html.c:3043
538+msgid "History"
539+msgstr "Історія"
540+
541+#: html.c:3095 html.c:3515
542+msgid "More..."
543+msgstr "Більше..."
544+
545+#: html.c:3184 html.c:4574
546+msgid "Unlimit"
547+msgstr "Без обмеження"
548+
549+#: html.c:3185
550+msgid "Allow announces (boosts) from this user"
551+msgstr "Дозволити просування від цього користувача"
552+
553+#: html.c:3188 html.c:4570
554+msgid "Limit"
555+msgstr "Обмежити"
556+
557+#: html.c:3189
558+msgid "Block announces (boosts) from this user"
559+msgstr "Заборонити просування від цього користувача"
560+
561+#: html.c:3198
562+msgid "Delete this user"
563+msgstr "Видалити користувача"
564+
565+#: html.c:3203 html.c:4688
566+msgid "Approve"
567+msgstr "Підтвердити"
568+
569+#: html.c:3204
570+msgid "Approve this follow request"
571+msgstr "Підтвердити цей запит на підписку"
572+
573+#: html.c:3207 html.c:4712
574+msgid "Discard"
575+msgstr "Відхилити"
576+
577+#: html.c:3207
578+msgid "Discard this follow request"
579+msgstr "Відхилити цей запит на підписку"
580+
581+#: html.c:3212 html.c:4556
582+msgid "Unmute"
583+msgstr "Скасувати глушіння"
584+
585+#: html.c:3213
586+msgid "Stop blocking activities from this user"
587+msgstr "Припинити глушіння дій цього користувача"
588+
589+#: html.c:3217
590+msgid "Block any activity from this user"
591+msgstr "Заглушити всі дії цього користувача"
592+
593+#: html.c:3225
594+msgid "Direct Message..."
595+msgstr "Особисте повідомлення..."
596+
597+#: html.c:3260
598+msgid "Pending follow confirmations"
599+msgstr "Запити на підписку очікують на розгляд"
600+
601+#: html.c:3264
602+msgid "People you follow"
603+msgstr "Ваші підписки"
604+
605+#: html.c:3265
606+msgid "People that follow you"
607+msgstr "Ваші підписники"
608+
609+#: html.c:3304
610+msgid "Clear all"
611+msgstr "Очистити все"
612+
613+#: html.c:3361
614+msgid "Mention"
615+msgstr "Згадка"
616+
617+#: html.c:3364
618+msgid "Finished poll"
619+msgstr "Завершене опитування"
620+
621+#: html.c:3379
622+msgid "Follow Request"
623+msgstr "Запит на підписку"
624+
625+#: html.c:3462
626+msgid "Context"
627+msgstr "Контекст"
628+
629+#: html.c:3473
630+msgid "New"
631+msgstr "Нове"
632+
633+#: html.c:3488
634+msgid "Already seen"
635+msgstr "Вже переглянуто"
636+
637+#: html.c:3503
638+msgid "None"
639+msgstr "Нема"
640+
641+#: html.c:3769
642+#, c-format
643+msgid "Search results for account %s"
644+msgstr "Результати пошуку для облікового запису %s"
645+
646+#: html.c:3776
647+#, c-format
648+msgid "Account %s not found"
649+msgstr "Обліковий запис %s не знайдений"
650+
651+#: html.c:3807
652+#, c-format
653+msgid "Search results for tag %s"
654+msgstr "Результати пошуку тега %s"
655+
656+#: html.c:3807
657+#, c-format
658+msgid "Nothing found for tag %s"
659+msgstr "Нічого не знайдено за тегом %s"
660+
661+#: html.c:3823
662+#, c-format
663+msgid "Search results for '%s' (may be more)"
664+msgstr "Результати пошуку для '%s' (можливо є більше)"
665+
666+#: html.c:3826
667+#, c-format
668+msgid "Search results for '%s'"
669+msgstr "Результати пошуку для '%s'"
670+
671+#: html.c:3829
672+#, c-format
673+msgid "No more matches for '%s'"
674+msgstr "Немає більше збігів для '%s'"
675+
676+#: html.c:3831
677+#, c-format
678+msgid "Nothing found for '%s'"
679+msgstr "Нічого не знайдено для '%s'"
680+
681+#: html.c:3929
682+msgid "Showing instance timeline"
683+msgstr "Показуємо стрічку сервера"
684+
685+#: html.c:4012
686+#, c-format
687+msgid "Showing timeline for list '%s'"
688+msgstr "Показуємо стрічку для списку '%s'"
689+
690+#: httpd.c:250
691+#, c-format
692+msgid "Search results for tag #%s"
693+msgstr "Результати пошуку для тега #%s"
694+
695+#: httpd.c:259
696+msgid "Recent posts by users in this instance"
697+msgstr "Останні повідомлення на цьому сервері"
698+
699+#: html.c:1603
700+msgid "Blocked hashtags..."
701+msgstr "Заблоковані теги..."
702+
703+#: html.c:432
704+msgid "Optional URL to reply to"
705+msgstr "Необов'язковий URL для відповіді"
706+
707+#: html.c:575
708+msgid ""
709+"Option 1...\n"
710+"Option 2...\n"
711+"Option 3...\n"
712+"..."
713+msgstr ""
714+"Варіант 1...\n"
715+"Варіант 2...\n"
716+"Варіант 3...\n"
717+"..."
718+
719+#: html.c:1415
720+msgid "Bot API key"
721+msgstr "Ключ API для бота"
722+
723+#: html.c:1421
724+msgid "Chat id"
725+msgstr "Id чату"
726+
727+#: html.c:1429
728+msgid "ntfy server - full URL (example: https://ntfy.sh/YourTopic)"
729+msgstr "повна URL сервера ntfy (наприклад https://ntfy.sh/YourTopic)"
730+
731+#: html.c:1435
732+msgid "ntfy token - if needed"
733+msgstr "токен ntfy - якщо потрібно"
734+
735+#: html.c:2892
736+msgid "pinned"
737+msgstr "закріплено"
738+
739+#: html.c:2904
740+msgid "bookmarks"
741+msgstr "закладки"
742+
743+#: html.c:2916
744+msgid "drafts"
745+msgstr "чернетки"
746+
747+#: html.c:464
748+msgid "Scheduled post..."
749+msgstr "Запланувати..."
750+
751+msgid "Post date and time:"
752+msgstr "Час допису:"
753+
754+#: html.c:2927 html.c:3989
755+msgid "Scheduled posts"
756+msgstr "Заплановані дописи"
757+
758+#: html.c:2928
759+msgid "scheduled posts"
760+msgstr "заплановані дописи"
761+
762+#: html.c:458
763+#, c-format
764+msgid "Post date and time (timezone: %s):"
765+msgstr "Дата та час допису (часовий пояс: %s):"
766+
767+#: html.c:1538
768+msgid "Time zone:"
769+msgstr "Часовий пояс:"
A
rss.c
+274,
-0
1@@ -0,0 +1,274 @@
2+/* snac - A simple, minimalistic ActivityPub instance */
3+/* copyright (c) 2025 grunfink et al. / MIT license */
4+
5+#include "xs.h"
6+#include "xs_html.h"
7+#include "xs_regex.h"
8+#include "xs_time.h"
9+#include "xs_match.h"
10+#include "xs_curl.h"
11+#include "xs_openssl.h"
12+#include "xs_json.h"
13+
14+#include "snac.h"
15+
16+xs_str *rss_from_timeline(snac *user, const xs_list *timeline,
17+ const char *title, const char *link, const char *desc)
18+/* converts a timeline to rss */
19+{
20+ xs_html *rss = xs_html_tag("rss",
21+ xs_html_attr("xmlns:content", "http:/" "/purl.org/rss/1.0/modules/content/"),
22+ xs_html_attr("version", "2.0"),
23+ xs_html_attr("xmlns:atom", "http:/" "/www.w3.org/2005/Atom"));
24+
25+ xs_html *channel = xs_html_tag("channel",
26+ xs_html_tag("title",
27+ xs_html_text(title)),
28+ xs_html_tag("language",
29+ xs_html_text("en")),
30+ xs_html_tag("link",
31+ xs_html_text(link)),
32+ xs_html_sctag("atom:link",
33+ xs_html_attr("href", link),
34+ xs_html_attr("rel", "self"),
35+ xs_html_attr("type", "application/rss+xml")),
36+ xs_html_tag("generator",
37+ xs_html_text(USER_AGENT)),
38+ xs_html_tag("description",
39+ xs_html_text(desc)));
40+
41+ xs_html_add(rss, channel);
42+
43+ int cnt = 0;
44+ const char *v;
45+
46+ xs_list_foreach(timeline, v) {
47+ xs *msg = NULL;
48+
49+ if (user) {
50+ if (!valid_status(timeline_get_by_md5(user, v, &msg)))
51+ continue;
52+ }
53+ else {
54+ if (!valid_status(object_get_by_md5(v, &msg)))
55+ continue;
56+ }
57+
58+ const char *id = xs_dict_get(msg, "id");
59+ const char *content = xs_dict_get(msg, "content");
60+ const char *published = xs_dict_get(msg, "published");
61+
62+ if (user && !xs_startswith(id, user->actor))
63+ continue;
64+
65+ if (!id || !content || !published)
66+ continue;
67+
68+ /* create a title with the first line of the content */
69+ xs *title = xs_replace(content, "<br>", "\n");
70+ title = xs_regex_replace_i(title, "<[^>]+>", " ");
71+ title = xs_regex_replace_i(title, "&[^;]+;", " ");
72+ int i;
73+
74+ for (i = 0; title[i] && title[i] != '\n' && i < 50; i++);
75+
76+ if (title[i] != '\0') {
77+ title[i] = '\0';
78+ title = xs_str_cat(title, "...");
79+ }
80+
81+ title = xs_strip_i(title);
82+
83+ /* convert the date */
84+ time_t t = xs_parse_iso_date(published, 0);
85+ xs *rss_date = xs_str_utctime(t, "%a, %d %b %Y %T +0000");
86+
87+ /* if it's the first one, add it to the header */
88+ if (cnt == 0)
89+ xs_html_add(channel,
90+ xs_html_tag("lastBuildDate",
91+ xs_html_text(rss_date)));
92+
93+ xs_html_add(channel,
94+ xs_html_tag("item",
95+ xs_html_tag("title",
96+ xs_html_text(title)),
97+ xs_html_tag("link",
98+ xs_html_text(id)),
99+ xs_html_tag("guid",
100+ xs_html_text(id)),
101+ xs_html_tag("pubDate",
102+ xs_html_text(rss_date)),
103+ xs_html_tag("description",
104+ xs_html_text(content))));
105+
106+ cnt++;
107+ }
108+
109+ return xs_html_render_s(rss, "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
110+}
111+
112+
113+void rss_to_timeline(snac *user, const char *url)
114+/* reads an RSS and inserts all ActivityPub posts into the user's timeline */
115+{
116+ if (!xs_startswith(url, "https:/") && !xs_startswith(url, "http:/"))
117+ return;
118+
119+ xs *hdrs = xs_dict_new();
120+ hdrs = xs_dict_set(hdrs, "accept", "application/rss+xml");
121+ hdrs = xs_dict_set(hdrs, "user-agent", USER_AGENT);
122+
123+ /* get the RSS metadata */
124+ xs *md5 = xs_md5_hex(url, strlen(url));
125+ xs *rss_md_fn = xs_fmt("%s/rss", user->basedir);
126+ mkdirx(rss_md_fn);
127+ rss_md_fn = xs_str_cat(rss_md_fn, "/", md5, ".json");
128+
129+ xs *rss_md = NULL;
130+ const char *etag = NULL;
131+
132+ FILE *f;
133+ if ((f = fopen(rss_md_fn, "r")) != NULL) {
134+ rss_md = xs_json_load(f);
135+ fclose(f);
136+
137+ etag = xs_dict_get(rss_md, "etag");
138+
139+ if (xs_is_string(etag))
140+ hdrs = xs_dict_set(hdrs, "if-none-match", etag);
141+ }
142+
143+ if (rss_md == NULL)
144+ rss_md = xs_dict_new();
145+
146+ xs *payload = NULL;
147+ int status;
148+ int p_size;
149+
150+ xs *rsp = xs_http_request("GET", url, hdrs, NULL, 0, &status, &payload, &p_size, 0);
151+
152+ snac_log(user, xs_fmt("parsing RSS %s %d", url, status));
153+
154+ if (!valid_status(status) || !xs_is_string(payload))
155+ return;
156+
157+ /* not an RSS? done */
158+ const char *ctype = xs_dict_get(rsp, "content-type");
159+ if (!xs_is_string(ctype) || xs_str_in(ctype, "application/rss+xml") == -1)
160+ return;
161+
162+ /* yes, parsing is done with regexes (now I have two problems blah blah blah) */
163+ xs *links = xs_regex_select(payload, "<link>[^<]+</link>");
164+ const char *link;
165+
166+ xs_list_foreach(links, link) {
167+ xs *l = xs_replace(link, "<link>", "");
168+ char *p = strchr(l, '<');
169+
170+ if (p == NULL)
171+ continue;
172+ *p = '\0';
173+
174+ /* skip this same URL */
175+ if (strcmp(l, url) == 0)
176+ continue;
177+
178+ /* skip crap */
179+ if (!xs_startswith(l, "https:/") && !xs_startswith(l, "http:/"))
180+ continue;
181+
182+ snac_debug(user, 1, xs_fmt("RSS link: %s", l));
183+
184+ if (timeline_here(user, l)) {
185+ snac_debug(user, 1, xs_fmt("RSS entry already in timeline %s", l));
186+ continue;
187+ }
188+
189+ /* special trick for Mastodon: convert from the alternate format */
190+ if (strchr(l, '@') != NULL) {
191+ xs *l2 = xs_split(l, "/");
192+
193+ if (xs_list_len(l2) == 5) {
194+ const char *uid = xs_list_get(l2, 3);
195+ if (*uid == '@') {
196+ xs *guessed_id = xs_fmt("https:/" "/%s/users/%s/statuses/%s",
197+ xs_list_get(l2, 2), uid + 1, xs_list_get(l2, -1));
198+
199+ if (timeline_here(user, guessed_id)) {
200+ snac_debug(user, 1, xs_fmt("RSS entry already in timeline (alt) %s", guessed_id));
201+ continue;
202+ }
203+ }
204+ }
205+ }
206+
207+ xs *obj = NULL;
208+
209+ if (!valid_status(object_get(l, &obj))) {
210+ /* object is not here: bring it */
211+ if (!valid_status(activitypub_request(user, l, &obj)))
212+ continue;
213+ }
214+
215+ if (xs_is_dict(obj)) {
216+ const char *id = xs_dict_get(obj, "id");
217+ const char *type = xs_dict_get(obj, "type");
218+ const char *attr_to = get_atto(obj);
219+
220+ if (!xs_is_string(id) || !xs_is_string(type) || !xs_is_string(attr_to))
221+ continue;
222+
223+ if (!xs_match(type, POSTLIKE_OBJECT_TYPE))
224+ continue;
225+
226+ if (timeline_here(user, id)) {
227+ snac_debug(user, 1, xs_fmt("RSS entry already in timeline (id) %s", id));
228+ continue;
229+ }
230+
231+ enqueue_actor_refresh(user, attr_to, 0);
232+
233+ timeline_add(user, id, obj);
234+
235+ snac_log(user, xs_fmt("new '%s' (RSS) %s %s", type, attr_to, id));
236+ }
237+ }
238+
239+ /* update the RSS metadata */
240+ etag = xs_dict_get(rsp, "etag");
241+
242+ if (xs_is_string(etag)) {
243+ rss_md = xs_dict_set(rss_md, "etag", etag);
244+ rss_md = xs_dict_set(rss_md, "url", url);
245+ if ((f = fopen(rss_md_fn, "w")) != NULL) {
246+ xs_json_dump(rss_md, 4, f);
247+ fclose(f);
248+ }
249+ }
250+}
251+
252+
253+void rss_poll_hashtags(void)
254+/* parses all RSS from all users */
255+{
256+ xs *list = user_list();
257+ const char *uid;
258+
259+ xs_list_foreach(list, uid) {
260+ snac user;
261+
262+ if (user_open(&user, uid)) {
263+ const xs_list *rss = xs_dict_get(user.config, "followed_hashtags");
264+
265+ if (xs_is_list(rss)) {
266+ const char *url;
267+
268+ xs_list_foreach(rss, url)
269+ rss_to_timeline(&user, url);
270+ }
271+
272+ user_free(&user);
273+ }
274+ }
275+}
+14,
-0
1@@ -13,6 +13,14 @@ void sbox_enter(const char *basedir)
2 return;
3 }
4
5+ int smail;
6+ const char *url = xs_dict_get(srv_config, "smtp_url");
7+
8+ if (xs_is_string(url) && *url)
9+ smail = 0;
10+ else
11+ smail = !xs_is_true(xs_dict_get(srv_config, "disable_email_notifications"));
12+
13 srv_debug(1, xs_fmt("Calling unveil()"));
14 unveil(basedir, "rwc");
15 unveil("/tmp", "rwc");
16@@ -25,6 +33,9 @@ void sbox_enter(const char *basedir)
17 if (*address == '/')
18 unveil(address, "rwc");
19
20+ if (smail)
21+ unveil("/usr/sbin/sendmail", "x");
22+
23 unveil(NULL, NULL);
24
25 srv_debug(1, xs_fmt("Calling pledge()"));
26@@ -34,6 +45,9 @@ void sbox_enter(const char *basedir)
27 if (*address == '/')
28 p = xs_str_cat(p, " unix");
29
30+ if (smail)
31+ p = xs_str_cat(p, " exec");
32+
33 pledge(p, NULL);
34 }
35
M
snac.h
+13,
-5
1@@ -1,7 +1,7 @@
2 /* snac - A simple, minimalistic ActivityPub instance */
3 /* copyright (c) 2022 - 2025 grunfink et al. / MIT license */
4
5-#define VERSION "2.77"
6+#define VERSION "2.81-dev"
7
8 #define USER_AGENT "snac/" VERSION
9
10@@ -164,7 +164,8 @@ int pending_count(snac *user);
11
12 double timeline_mtime(snac *snac);
13 int timeline_touch(snac *snac);
14-int timeline_here(snac *snac, const char *md5);
15+int timeline_here_by_md5(snac *snac, const char *md5);
16+int timeline_here(snac *snac, const char *id);
17 int timeline_get_by_md5(snac *snac, const char *md5, xs_dict **msg);
18 int timeline_del(snac *snac, const char *id);
19 xs_str *user_index_fn(snac *user, const char *idx_name);
20@@ -293,6 +294,8 @@ void enqueue_object_request(snac *user, const char *id, int forward_secs);
21 void enqueue_verify_links(snac *user);
22 void enqueue_actor_refresh(snac *user, const char *actor, int forward_secs);
23 void enqueue_webmention(const xs_dict *msg);
24+void enqueue_notify_webhook(snac *user, const xs_dict *noti, int retries);
25+
26 int was_question_voted(snac *user, const char *id);
27
28 xs_list *user_queue(snac *snac);
29@@ -322,7 +325,7 @@ void httpd(void);
30 int webfinger_request_signed(snac *snac, const char *qs, xs_str **actor, xs_str **user);
31 int webfinger_request(const char *qs, xs_str **actor, xs_str **user);
32 int webfinger_request_fake(const char *qs, xs_str **actor, xs_str **user);
33-int webfinger_get_handler(xs_dict *req, const char *q_path,
34+int webfinger_get_handler(const xs_dict *req, const char *q_path,
35 xs_val **body, int *b_size, char **ctype);
36
37 const char *default_avatar_base64(void);
38@@ -394,8 +397,6 @@ int html_get_handler(const xs_dict *req, const char *q_path,
39 int html_post_handler(const xs_dict *req, const char *q_path,
40 char *payload, int p_size,
41 char **body, int *b_size, char **ctype);
42-xs_str *timeline_to_rss(snac *user, const xs_list *timeline,
43- const char *title, const char *link, const char *desc);
44
45 int write_default_css(void);
46 int snac_init(const char *_basedir);
47@@ -433,6 +434,8 @@ void mastoapi_purge(void);
48 void verify_links(snac *user);
49
50 void export_csv(snac *user);
51+void export_posts(snac *user);
52+
53 int migrate_account(snac *user);
54
55 void import_blocked_accounts_csv(snac *user, const char *fn);
56@@ -461,3 +464,8 @@ int badlogin_check(const char *user, const char *addr);
57 void badlogin_inc(const char *user, const char *addr);
58
59 const char *lang_str(const char *str, const snac *user);
60+
61+xs_str *rss_from_timeline(snac *user, const xs_list *timeline,
62+ const char *title, const char *link, const char *desc);
63+void rss_to_timeline(snac *user, const char *url);
64+void rss_poll_hashtags(void);
M
utils.c
+76,
-0
1@@ -45,6 +45,7 @@ static const char *default_srv_config = "{"
2 static const char *default_css =
3 "body { max-width: 48em; margin: auto; line-height: 1.5; padding: 0.8em; word-wrap: break-word; }\n"
4 "pre { overflow-x: scroll; }\n"
5+ "blockquote { font-style: italic; }\n"
6 ".snac-embedded-video, img { max-width: 100% }\n"
7 ".snac-origin { font-size: 85% }\n"
8 ".snac-score { float: right; font-size: 85% }\n"
9@@ -488,6 +489,26 @@ void verify_links(snac *user)
10
11 int c = 0;
12 while (metadata && xs_dict_next(metadata, &k, &v, &c)) {
13+ xs *wfinger = NULL;
14+ const char *ov = NULL;
15+
16+ /* is it an account handle? */
17+ if (*v == '@' && strchr(v + 1, '@')) {
18+ /* resolve it via webfinger */
19+ if (valid_status(webfinger_request(v, &wfinger, NULL)) && xs_is_string(wfinger)) {
20+ ov = v;
21+ v = wfinger;
22+
23+ /* store the alias */
24+ if (user->links == NULL)
25+ user->links = xs_dict_new();
26+
27+ user->links = xs_dict_set(user->links, ov, v);
28+
29+ changed++;
30+ }
31+ }
32+
33 /* not an https link? skip */
34 if (!xs_startswith(v, "https:/" "/"))
35 continue;
36@@ -705,6 +726,61 @@ void export_csv(snac *user)
37 }
38
39
40+void export_posts(snac *user)
41+/* exports all posts to an OrderedCollection */
42+{
43+ xs *ifn = xs_fmt("%s/public.idx", user->basedir);
44+ xs *index = index_list(ifn, XS_ALL);
45+ xs *ofn = xs_fmt("%s/export/outbox.json", user->basedir);
46+ FILE *f;
47+
48+ if ((f = fopen(ofn, "w")) == NULL) {
49+ snac_log(user, xs_fmt("Cannot create file %s", ofn));
50+ return;
51+ }
52+
53+ int cnt = 0;
54+
55+ /* raw output */
56+ fprintf(f, "{\"@context\": \"https:/" "/www.w3.org/ns/activitystreams\",");
57+ fprintf(f, "\"id\": \"outbox.json\",");
58+ fprintf(f, "\"type\": \"OrderedCollection\",");
59+ fprintf(f, "\"orderedItems\": [");
60+
61+ const char *md5;
62+
63+ snac_log(user, xs_fmt("Creating %s...", ofn));
64+
65+ xs_list_foreach(index, md5) {
66+ xs *obj = NULL;
67+
68+ if (!valid_status(object_get_by_md5(md5, &obj)))
69+ continue;
70+
71+ const char *type = xs_dict_get(obj, "type");
72+
73+ if (!xs_is_string(type) || strcmp(type, "Note"))
74+ continue;
75+
76+ const char *atto = get_atto(obj);
77+
78+ if (!xs_is_string(atto) || strcmp(atto, user->actor))
79+ continue;
80+
81+ if (cnt)
82+ fprintf(f, ",");
83+
84+ xs *c_msg = msg_create(user, obj);
85+ xs_json_dump(c_msg, 0, f);
86+ cnt++;
87+ }
88+
89+ fprintf(f, "], \"totalItems\": %d}", cnt);
90+
91+ fclose(f);
92+}
93+
94+
95 void import_blocked_accounts_csv(snac *user, const char *ifn)
96 /* imports a Mastodon CSV file of blocked accounts */
97 {
+2,
-2
1@@ -93,7 +93,7 @@ int webfinger_request_signed(snac *snac, const char *qs, xs_str **actor, xs_str
2 if (user != NULL) {
3 const char *subject = xs_dict_get(obj, "subject");
4
5- if (subject)
6+ if (subject && xs_startswith(subject, "acct:"))
7 *user = xs_replace_n(subject, "acct:", "", 1);
8 }
9
10@@ -152,7 +152,7 @@ int webfinger_request_fake(const char *qs, xs_str **actor, xs_str **user)
11 }
12
13
14-int webfinger_get_handler(xs_dict *req, const char *q_path,
15+int webfinger_get_handler(const xs_dict *req, const char *q_path,
16 xs_val **body, int *b_size, char **ctype)
17 /* serves webfinger queries */
18 {
M
xs.h
+15,
-0
1@@ -90,6 +90,7 @@ xs_str *xs_rstrip_chars_i(xs_str *str, const char *chars);
2 xs_str *xs_strip_chars_i(xs_str *str, const char *chars);
3 #define xs_strip_i(str) xs_strip_chars_i(str, " \r\n\t\v\f")
4 xs_str *xs_tolower_i(xs_str *str);
5+xs_str *xs_toupper_i(xs_str *str);
6
7 xs_list *xs_list_new(void);
8 xs_list *xs_list_append_m(xs_list *list, const char *mem, int dsz);
9@@ -692,6 +693,20 @@ xs_str *xs_tolower_i(xs_str *str)
10 }
11
12
13+xs_str *xs_toupper_i(xs_str *str)
14+/* convert to lowercase */
15+{
16+ XS_ASSERT_TYPE(str, XSTYPE_STRING);
17+
18+ int n;
19+
20+ for (n = 0; str[n]; n++)
21+ str[n] = toupper(str[n]);
22+
23+ return str;
24+}
25+
26+
27 /** lists **/
28
29 xs_list *xs_list_new(void)
+2,
-2
1@@ -13,7 +13,7 @@
2 #define _XS_FCGI_H
3
4 xs_dict *xs_fcgi_request(FILE *f, xs_str **payload, int *p_size, int *id);
5- void xs_fcgi_response(FILE *f, int status, xs_dict *headers, xs_str *body, int b_size, int id);
6+ void xs_fcgi_response(FILE *f, int status, const xs_dict *headers, const xs_str *body, int b_size, int id);
7
8
9 #ifdef XS_IMPLEMENTATION
10@@ -290,7 +290,7 @@ end:
11 }
12
13
14-void xs_fcgi_response(FILE *f, int status, xs_dict *headers, xs_str *body, int b_size, int fcgi_id)
15+void xs_fcgi_response(FILE *f, int status, const xs_dict *headers, const xs_str *body, int b_size, int fcgi_id)
16 /* writes an FCGI response */
17 {
18 struct fcgi_record_header hdr = {0};
+6,
-6
1@@ -5,7 +5,8 @@
2 #define _XS_HTTPD_H
3
4 xs_dict *xs_httpd_request(FILE *f, xs_str **payload, int *p_size);
5-void xs_httpd_response(FILE *f, int status, const char *status_text, xs_dict *headers, xs_str *body, int b_size);
6+void xs_httpd_response(FILE *f, int status, const char *status_text,
7+ const xs_dict *headers, const xs_val *body, int b_size);
8
9
10 #ifdef XS_IMPLEMENTATION
11@@ -109,16 +110,15 @@ xs_dict *xs_httpd_request(FILE *f, xs_str **payload, int *p_size)
12 }
13
14
15-void xs_httpd_response(FILE *f, int status, const char *status_text, xs_dict *headers, xs_str *body, int b_size)
16+void xs_httpd_response(FILE *f, int status, const char *status_text,
17+ const xs_dict *headers, const xs_val *body, int b_size)
18 /* sends an httpd response */
19 {
20- xs *proto;
21+ fprintf(f, "HTTP/1.1 %d %s\r\n", status, status_text ? status_text : "");
22+
23 const xs_str *k;
24 const xs_val *v;
25
26- proto = xs_fmt("HTTP/1.1 %d %s", status, status_text);
27- fprintf(f, "%s\r\n", proto);
28-
29 xs_dict_foreach(headers, k, v) {
30 fprintf(f, "%s: %s\r\n", k, v);
31 }
+94,
-69
1@@ -8,6 +8,7 @@
2 #define MAX_JSON_DEPTH 32
3 #endif
4
5+void xs_json_dump_value(const xs_val *data, int level, int indent, FILE *f);
6 int xs_json_dump(const xs_val *data, int indent, FILE *f);
7 xs_str *xs_json_dumps(const xs_val *data, int indent);
8
9@@ -19,8 +20,8 @@ xs_val *xs_json_loads_full(const xs_str *json, int maxdepth);
10 xstype xs_json_load_type(FILE *f);
11 int xs_json_load_array_iter(FILE *f, xs_val **value, xstype *pt, int *c);
12 int xs_json_load_object_iter(FILE *f, xs_str **key, xs_val **value, xstype *pt, int *c);
13-xs_list *xs_json_load_array(FILE *f, int maxdepth);
14-xs_dict *xs_json_load_object(FILE *f, int maxdepth);
15+int xs_json_load_array(FILE *f, int maxdepth, xs_list **l);
16+int xs_json_load_object(FILE *f, int maxdepth, xs_dict **d);
17
18
19 #ifdef XS_IMPLEMENTATION
20@@ -77,7 +78,7 @@ static void _xs_json_indent(int level, int indent, FILE *f)
21 }
22
23
24-static void _xs_json_dump(const xs_val *data, int level, int indent, FILE *f)
25+void xs_json_dump_value(const xs_val *data, int level, int indent, FILE *f)
26 /* dumps partial data as JSON */
27 {
28 int c = 0;
29@@ -108,7 +109,7 @@ static void _xs_json_dump(const xs_val *data, int level, int indent, FILE *f)
30 fputc(',', f);
31
32 _xs_json_indent(level + 1, indent, f);
33- _xs_json_dump(v, level + 1, indent, f);
34+ xs_json_dump_value(v, level + 1, indent, f);
35
36 c++;
37 }
38@@ -135,7 +136,7 @@ static void _xs_json_dump(const xs_val *data, int level, int indent, FILE *f)
39 if (indent)
40 fputc(' ', f);
41
42- _xs_json_dump(v, level + 1, indent, f);
43+ xs_json_dump_value(v, level + 1, indent, f);
44
45 c++;
46 }
47@@ -154,6 +155,20 @@ static void _xs_json_dump(const xs_val *data, int level, int indent, FILE *f)
48 }
49
50
51+int xs_json_dump(const xs_val *data, int indent, FILE *f)
52+/* dumps data into a file as JSON */
53+{
54+ xstype t = xs_type(data);
55+
56+ if (t == XSTYPE_LIST || t == XSTYPE_DICT) {
57+ xs_json_dump_value(data, 0, indent, f);
58+ return 1;
59+ }
60+
61+ return 0;
62+}
63+
64+
65 xs_str *xs_json_dumps(const xs_val *data, int indent)
66 /* dumps data as a JSON string */
67 {
68@@ -173,20 +188,6 @@ xs_str *xs_json_dumps(const xs_val *data, int indent)
69 }
70
71
72-int xs_json_dump(const xs_val *data, int indent, FILE *f)
73-/* dumps data into a file as JSON */
74-{
75- xstype t = xs_type(data);
76-
77- if (t == XSTYPE_LIST || t == XSTYPE_DICT) {
78- _xs_json_dump(data, 0, indent, f);
79- return 1;
80- }
81-
82- return 0;
83-}
84-
85-
86 /** JSON loads **/
87
88 typedef enum {
89@@ -370,6 +371,8 @@ int xs_json_load_array_iter(FILE *f, xs_val **value, xstype *pt, int *c)
90 else
91 return -1;
92 }
93+ else
94+ *pt = xs_type(*value);
95
96 *c = *c + 1;
97
98@@ -377,43 +380,51 @@ int xs_json_load_array_iter(FILE *f, xs_val **value, xstype *pt, int *c)
99 }
100
101
102-xs_list *xs_json_load_array(FILE *f, int maxdepth)
103+int xs_json_load_array(FILE *f, int maxdepth, xs_list **l)
104 /* loads a full JSON array (after the initial OBRACK) */
105+/* l can be NULL for the content to be dropped */
106 {
107 xstype t;
108- xs_list *l = xs_list_new();
109+ int r = 0;
110 int c = 0;
111
112 for (;;) {
113 xs *v = NULL;
114- int r = xs_json_load_array_iter(f, &v, &t, &c);
115-
116- if (r == -1)
117- l = xs_free(l);
118+ r = xs_json_load_array_iter(f, &v, &t, &c);
119
120 if (r == 1) {
121 /* partial load? */
122 if (v == NULL && maxdepth != 0) {
123- if (t == XSTYPE_LIST)
124- v = xs_json_load_array(f, maxdepth - 1);
125+ if (t == XSTYPE_LIST) {
126+ if (l)
127+ v = xs_list_new();
128+
129+ r = xs_json_load_array(f, maxdepth - 1, &v);
130+ }
131 else
132- if (t == XSTYPE_DICT)
133- v = xs_json_load_object(f, maxdepth - 1);
134+ if (t == XSTYPE_DICT) {
135+ if (l)
136+ v = xs_dict_new();
137+
138+ r = xs_json_load_object(f, maxdepth - 1, &v);
139+ }
140 }
141
142- /* still null? fail */
143- if (v == NULL) {
144- l = xs_free(l);
145+ /* error? */
146+ if (r < 0)
147 break;
148- }
149
150- l = xs_list_append(l, v);
151+ if (l)
152+ *l = xs_list_append(*l, v);
153 }
154 else
155 break;
156 }
157
158- return l;
159+ if (r < 0 && l)
160+ *l = xs_free(*l);
161+
162+ return r;
163 }
164
165
166@@ -458,6 +469,8 @@ int xs_json_load_object_iter(FILE *f, xs_str **key, xs_val **value, xstype *pt,
167 else
168 return -1;
169 }
170+ else
171+ *pt = xs_type(*value);
172
173 *c = *c + 1;
174
175@@ -465,59 +478,52 @@ int xs_json_load_object_iter(FILE *f, xs_str **key, xs_val **value, xstype *pt,
176 }
177
178
179-xs_dict *xs_json_load_object(FILE *f, int maxdepth)
180+int xs_json_load_object(FILE *f, int maxdepth, xs_dict **d)
181 /* loads a full JSON object (after the initial OCURLY) */
182+/* d can be NULL for the content to be dropped */
183 {
184 xstype t;
185- xs_dict *d = xs_dict_new();
186+ int r = 0;
187 int c = 0;
188
189 for (;;) {
190 xs *k = NULL;
191 xs *v = NULL;
192- int r = xs_json_load_object_iter(f, &k, &v, &t, &c);
193-
194- if (r == -1)
195- d = xs_free(d);
196+ r = xs_json_load_object_iter(f, &k, &v, &t, &c);
197
198 if (r == 1) {
199 /* partial load? */
200 if (v == NULL && maxdepth != 0) {
201- if (t == XSTYPE_LIST)
202- v = xs_json_load_array(f, maxdepth - 1);
203+ if (t == XSTYPE_LIST) {
204+ if (d)
205+ v = xs_list_new();
206+
207+ r = xs_json_load_array(f, maxdepth - 1, &v);
208+ }
209 else
210- if (t == XSTYPE_DICT)
211- v = xs_json_load_object(f, maxdepth - 1);
212+ if (t == XSTYPE_DICT) {
213+ if (d)
214+ v = xs_dict_new();
215+
216+ r = xs_json_load_object(f, maxdepth - 1, &v);
217+ }
218 }
219
220- /* still null? fail */
221- if (v == NULL) {
222- d = xs_free(d);
223+ /* error? */
224+ if (r < 0)
225 break;
226- }
227
228- d = xs_dict_append(d, k, v);
229+ if (d)
230+ *d = xs_dict_append(*d, k, v);
231 }
232 else
233 break;
234 }
235
236- return d;
237-}
238-
239-
240-xs_val *xs_json_loads_full(const xs_str *json, int maxdepth)
241-/* loads a string in JSON format and converts to a multiple data */
242-{
243- FILE *f;
244- xs_val *v = NULL;
245-
246- if ((f = fmemopen((char *)json, strlen(json), "r")) != NULL) {
247- v = xs_json_load_full(f, maxdepth);
248- fclose(f);
249- }
250+ if (r < 0 && d)
251+ *d = xs_free(*d);
252
253- return v;
254+ return r;
255 }
256
257
258@@ -545,11 +551,30 @@ xs_val *xs_json_load_full(FILE *f, int maxdepth)
259 xs_val *v = NULL;
260 xstype t = xs_json_load_type(f);
261
262- if (t == XSTYPE_LIST)
263- v = xs_json_load_array(f, maxdepth);
264+ if (t == XSTYPE_LIST) {
265+ v = xs_list_new();
266+ xs_json_load_array(f, maxdepth, &v);
267+ }
268 else
269- if (t == XSTYPE_DICT)
270- v = xs_json_load_object(f, maxdepth);
271+ if (t == XSTYPE_DICT) {
272+ v = xs_dict_new();
273+ xs_json_load_object(f, maxdepth, &v);
274+ }
275+
276+ return v;
277+}
278+
279+
280+xs_val *xs_json_loads_full(const xs_str *json, int maxdepth)
281+/* loads a string in JSON format and converts to a multiple data */
282+{
283+ FILE *f;
284+ xs_val *v = NULL;
285+
286+ if ((f = fmemopen((char *)json, strlen(json), "r")) != NULL) {
287+ v = xs_json_load_full(f, maxdepth);
288+ fclose(f);
289+ }
290
291 return v;
292 }
+1,
-1
1@@ -1 +1 @@
2-/* 871d420cef893b6efe32869407294baf084ce3ab 2025-05-04T11:01:01+02:00 */
3+/* 401d229ffbec89b4a5cf97793926b7afb84a4f26 2025-07-08T15:44:54+02:00 */
+42,
-0
1@@ -5,6 +5,7 @@
2 #define _XS_WEBMENTION_H
3
4 int xs_webmention_send(const char *source, const char *target, const char *user_agent);
5+int xs_webmention_hook(const char *source, const char *target, const char *user_agent);
6
7
8 #ifdef XS_IMPLEMENTATION
9@@ -118,6 +119,47 @@ int xs_webmention_send(const char *source, const char *target, const char *user_
10 }
11
12
13+int xs_webmention_hook(const char *source, const char *target, const char *user_agent)
14+/* a Webmention has been received for a target that is ours; check if the source
15+ really contains a link to our target */
16+{
17+ int status = 0;
18+
19+ xs *ua = xs_fmt("%s (Webmention)", user_agent ? user_agent : "xs_webmention");
20+ xs *headers = xs_dict_new();
21+ headers = xs_dict_set(headers, "accept", "text/html");
22+ headers = xs_dict_set(headers, "user-agent", ua);
23+
24+ xs *g_req = NULL;
25+ xs *payload = NULL;
26+ int p_size = 0;
27+
28+ g_req = xs_http_request("GET", source, headers, NULL, 0, &status, &payload, &p_size, 0);
29+
30+ if (status < 200 || status > 299)
31+ return -1;
32+
33+ if (!xs_is_string(payload))
34+ return -2;
35+
36+ /* note: a "rogue" webmention can include a link to our target in commented-out HTML code */
37+
38+ xs *links = xs_regex_select(payload, "<(a +|link +)[^>]+>");
39+ const char *link;
40+
41+ status = 0;
42+ xs_list_foreach(links, link) {
43+ /* if the link contains our target, it's valid */
44+ if (xs_str_in(link, target) != -1) {
45+ status = 1;
46+ break;
47+ }
48+ }
49+
50+ return status;
51+}
52+
53+
54 #endif /* XS_IMPLEMENTATION */
55
56 #endif /* _XS_WEBMENTION_H */