commit f6044d3
Merge branch 'master' into master
43 files changed,
+1020,
-198
M
LICENSE
+1,
-1
1@@ -1,6 +1,6 @@
2 MIT License
3
4-Copyright (c) 2022 - 2024 grunfink et al. (Fediverse: @grunfink@comam.es)
5+Copyright (c) 2022 - 2025 grunfink et al. (Fediverse: @grunfink@comam.es)
6
7 Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
8
M
Makefile
+2,
-1
1@@ -42,7 +42,8 @@ data.o: data.c xs.h xs_hex.h xs_io.h xs_json.h xs_openssl.h xs_glob.h \
2 format.o: format.c xs.h xs_regex.h xs_mime.h xs_html.h xs_json.h \
3 xs_time.h xs_match.h snac.h http_codes.h
4 html.o: html.c xs.h xs_io.h xs_json.h xs_regex.h xs_set.h xs_openssl.h \
5- xs_time.h xs_mime.h xs_match.h xs_html.h xs_curl.h snac.h http_codes.h
6+ xs_time.h xs_mime.h xs_match.h xs_html.h xs_curl.h xs_unicode.h snac.h \
7+ http_codes.h
8 http.o: http.c xs.h xs_io.h xs_openssl.h xs_curl.h xs_time.h xs_json.h \
9 snac.h http_codes.h
10 httpd.o: httpd.c xs.h xs_io.h xs_json.h xs_socket.h xs_unix_socket.h \
+3,
-2
1@@ -5,7 +5,7 @@ LDFLAGS=-lrt
2
3 all: snac
4
5-snac: snac.o main.o data.o http.o httpd.o webfinger.o \
6+snac: snac.o main.o sandbox.o data.o http.o httpd.o webfinger.o \
7 activitypub.o html.o utils.o format.o upgrade.o mastoapi.o
8 $(CC) $(CFLAGS) -L/usr/pkg/lib *.o -lcurl -lcrypto -pthread $(LDFLAGS) -Wl,-rpath,/usr/lib -Wl,-rpath,/usr/pkg/lib -o $@
9
10@@ -44,7 +44,8 @@ data.o: data.c xs.h xs_hex.h xs_io.h xs_json.h xs_openssl.h xs_glob.h \
11 format.o: format.c xs.h xs_regex.h xs_mime.h xs_html.h xs_json.h \
12 xs_time.h xs_match.h snac.h http_codes.h
13 html.o: html.c xs.h xs_io.h xs_json.h xs_regex.h xs_set.h xs_openssl.h \
14- xs_time.h xs_mime.h xs_match.h xs_html.h xs_curl.h snac.h http_codes.h
15+ xs_time.h xs_mime.h xs_match.h xs_html.h xs_curl.h xs_unicode.h snac.h \
16+ http_codes.h
17 http.o: http.c xs.h xs_io.h xs_openssl.h xs_curl.h xs_time.h xs_json.h \
18 snac.h http_codes.h
19 httpd.o: httpd.c xs.h xs_io.h xs_json.h xs_socket.h xs_unix_socket.h \
+29,
-1
1@@ -1,6 +1,34 @@
2 # Release Notes
3
4-## UNRELEASED
5+## 2.69 "Yin/Yang of Love"
6+
7+Added support for subscribing to LitePub (Pleroma-style) Fediverse Relays like e.g. https://fedi-relay.gyptazy.com to improve federation. See `snac(8)` (the Administrator Manual) for more information on how to use this feature.
8+
9+Added support for following hashtags. This is only useful if your instance is subscribed to relays (see above).
10+
11+Added support for a Mastodon-like `/authorize_interaction` webpoint entry, that allows following, liking and boosting from another account's Mastodon public web interface. To be able to use it, you must reconfigure your https proxy to redirect `/authorize_interaction` to snac (see `snac(8)`).
12+
13+Some fixes to accept `Event` objects properly (like those coming from implementations like https://gancio.org/ or https://mobilizon.fr).
14+
15+Added some caching for local `Actor` objects.
16+
17+Hashtags that are not explicitly linked in a post's content are shown below it.
18+
19+Fixed broken NetBSD build (missing dependency in Makefile.NetBSD).
20+
21+The user profile can now include longitude and latitude data for your current location.
22+
23+Mastodon API: implemented limit= on notification fetches (contributed by nowster), implemented faster min_id handling (contributed by nowster), obey the quiet public visibility set for posts, other timeline improvements (contributed by nowster).
24+
25+Reduced RSA key size for new users from 4096 to 2048. This will be friendlier to smaller machines, and everybody else out there is using 2048.
26+
27+If the `SNAC_BASEDIR` environment variable is defined and set to the base directory of your installation, you don't have to include the base directory in the command line.
28+
29+Fixed a bug in the generation of the top page (contributed by an-im-dugud).
30+
31+Added support for Markdown headers and underlining (contributed by an-im-dugud).
32+
33+## 2.68
34
35 Fixed regression in link verification code (contributed by nowster).
36
M
TODO.md
+3,
-3
1@@ -22,7 +22,7 @@ Mastoapi: implement /v1/conversations.
2
3 Implement following of hashtags (this is not trivial).
4
5-Track 'Event' data types standardization; how to add plan-to-attend and similar activities (more info: https://event-federation.eu/)
6+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).
7
8 Implement "FEP-3b86: Activity Intents" https://codeberg.org/fediverse/fep/src/branch/main/fep/3b86/fep-3b86.md
9
10@@ -32,8 +32,6 @@ Integrate "Added handling for International Domain Names" PR https://codeberg.or
11
12 Do something about Akkoma and Misskey's quoted replies (they use the `quoteUrl` field instead of `inReplyTo`).
13
14-Add support for /authorize_interaction (whatever it is).
15-
16 Add a list of hashtags to drop.
17
18 Take a look at crashes in the brittle Mastodon official app (crashes when hitting the reply button, crashes or 'ownVotes is null' errors when trying to show polls).
19@@ -365,3 +363,5 @@ Unfollowing lemmy groups gets rejected with an http status of 400 (it seems to w
20 CSV import/export does not work with OpenBSD security on; document it or fix it (2025-01-04T19:35:09+0100).
21
22 Add support for /share?text=tt&website=url (whatever it is, see https://mastodonshare.com/ for details) (2025-01-06T18:43:52+0100).
23+
24+Add support for /authorize_interaction (whatever it is) (2025-01-16T14:45:28+0100).
+221,
-62
1@@ -1,5 +1,5 @@
2 /* snac - A simple, minimalistic ActivityPub instance */
3-/* copyright (c) 2022 - 2024 grunfink et al. / MIT license */
4+/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */
5
6 #include "xs.h"
7 #include "xs_json.h"
8@@ -587,6 +587,70 @@ int is_msg_from_private_user(const xs_dict *msg)
9 }
10
11
12+int followed_hashtag_check(snac *user, const xs_dict *msg)
13+/* returns true if this message contains a hashtag followed by me */
14+{
15+ const xs_list *fw_tags = xs_dict_get(user->config, "followed_hashtags");
16+
17+ if (xs_is_list(fw_tags)) {
18+ const xs_list *tags_in_msg = xs_dict_get(msg, "tag");
19+
20+ if (xs_is_list(tags_in_msg)) {
21+ const xs_dict *te;
22+
23+ /* iterate the tags in the message */
24+ xs_list_foreach(tags_in_msg, te) {
25+ if (xs_is_dict(te)) {
26+ const char *type = xs_dict_get(te, "type");
27+ const char *name = xs_dict_get(te, "name");
28+
29+ if (xs_is_string(type) && xs_is_string(name)) {
30+ if (strcmp(type, "Hashtag") == 0) {
31+ xs *lc_name = xs_utf8_to_lower(name);
32+
33+ if (xs_list_in(fw_tags, lc_name) != -1)
34+ return 1;
35+ }
36+ }
37+ }
38+ }
39+ }
40+ }
41+
42+ return 0;
43+}
44+
45+
46+void followed_hashtag_distribute(const xs_dict *msg)
47+/* distribute this post to all users following the included hashtags */
48+{
49+ const char *id = xs_dict_get(msg, "id");
50+ const xs_list *tags_in_msg = xs_dict_get(msg, "tag");
51+
52+ if (!xs_is_string(id) || !xs_is_list(tags_in_msg) || xs_list_len(tags_in_msg) == 0)
53+ return;
54+
55+ srv_debug(1, xs_fmt("followed_hashtag_distribute check for %s", id));
56+
57+ xs *users = user_list();
58+ const char *uid;
59+
60+ xs_list_foreach(users, uid) {
61+ snac user;
62+
63+ if (user_open(&user, uid)) {
64+ if (followed_hashtag_check(&user, msg)) {
65+ timeline_add(&user, id, msg);
66+
67+ snac_log(&user, xs_fmt("followed hashtag in %s", id));
68+ }
69+
70+ user_free(&user);
71+ }
72+ }
73+}
74+
75+
76 int is_msg_for_me(snac *snac, const xs_dict *c_msg)
77 /* checks if this message is for me */
78 {
79@@ -602,19 +666,32 @@ int is_msg_for_me(snac *snac, const xs_dict *c_msg)
80 if (xs_match(type, "Like|Announce|EmojiReact")) {
81 const char *object = xs_dict_get(c_msg, "object");
82
83- if (xs_type(object) == XSTYPE_DICT)
84+ if (xs_is_dict(object))
85 object = xs_dict_get(object, "id");
86
87 /* bad object id? reject */
88- if (xs_type(object) != XSTYPE_STRING)
89+ if (!xs_is_string(object))
90 return 0;
91
92 /* if it's about one of our posts, accept it */
93 if (xs_startswith(object, snac->actor))
94 return 2;
95
96- /* if it's by someone we don't follow, reject */
97- return following_check(snac, actor);
98+ /* if it's by someone we follow, accept it */
99+ if (following_check(snac, actor))
100+ return 1;
101+
102+ /* do we follow any hashtag? */
103+ if (xs_is_list(xs_dict_get(snac->config, "followed_hashtags"))) {
104+ xs *obj = NULL;
105+
106+ /* if the admired object contains any followed hashtag, accept it */
107+ if (valid_status(object_get(object, &obj)) &&
108+ followed_hashtag_check(snac, obj))
109+ return 7;
110+ }
111+
112+ return 0;
113 }
114
115 /* if it's an Undo, it must be from someone related to us */
116@@ -675,7 +752,7 @@ int is_msg_for_me(snac *snac, const xs_dict *c_msg)
117
118 if (pub_msg) {
119 /* a public message for someone we follow? (probably cc'ed) accept */
120- if (following_check(snac, v))
121+ if (strcmp(v, public_address) != 0 && following_check(snac, v))
122 return 5;
123 }
124 else
125@@ -708,30 +785,8 @@ int is_msg_for_me(snac *snac, const xs_dict *c_msg)
126 }
127
128 /* does this message contain a tag we are following? */
129- const xs_list *fw_tags = xs_dict_get(snac->config, "followed_hashtags");
130- if (pub_msg && xs_type(fw_tags) == XSTYPE_LIST) {
131- const xs_list *tags_in_msg = xs_dict_get(msg, "tag");
132- if (xs_type(tags_in_msg) == XSTYPE_LIST) {
133- const xs_dict *te;
134-
135- /* iterate the tags in the message */
136- xs_list_foreach(tags_in_msg, te) {
137- if (xs_type(te) == XSTYPE_DICT) {
138- const char *type = xs_dict_get(te, "type");
139- const char *name = xs_dict_get(te, "name");
140-
141- if (xs_type(type) == XSTYPE_STRING && xs_type(name) == XSTYPE_STRING) {
142- if (strcmp(type, "Hashtag") == 0) {
143- xs *lc_name = xs_utf8_to_lower(name);
144-
145- if (xs_list_in(fw_tags, lc_name) != -1)
146- return 7;
147- }
148- }
149- }
150- }
151- }
152- }
153+ if (pub_msg && followed_hashtag_check(snac, msg))
154+ return 7;
155
156 return 0;
157 }
158@@ -889,6 +944,11 @@ void notify(snac *snac, const char *type, const char *utype, const char *actor,
159 /* if it's not an admiration about something by us, done */
160 if (xs_is_null(objid) || !xs_startswith(objid, snac->actor))
161 return;
162+
163+ /* if it's an announce by our own relay, done */
164+ xs *relay_id = xs_fmt("%s/relay", srv_baseurl);
165+ if (xs_startswith(id, relay_id))
166+ return;
167 }
168
169 /* updated poll? */
170@@ -1184,6 +1244,28 @@ xs_dict *msg_repulsion(snac *user, const char *id, const char *type)
171 }
172
173
174+xs_dict *msg_actor_place(snac *user, const char *label)
175+/* creates a Place object, if the user has a location defined */
176+{
177+ xs_dict *place = NULL;
178+ const char *latitude = xs_dict_get_def(user->config, "latitude", "");
179+ const char *longitude = xs_dict_get_def(user->config, "longitude", "");
180+
181+ if (*latitude && *longitude) {
182+ xs *d_la = xs_number_new(atof(latitude));
183+ xs *d_lo = xs_number_new(atof(longitude));
184+
185+ place = msg_base(user, "Place", NULL, user->actor, NULL, NULL);
186+
187+ place = xs_dict_set(place, "name", label);
188+ place = xs_dict_set(place, "latitude", d_la);
189+ place = xs_dict_set(place, "longitude", d_lo);
190+ }
191+
192+ return place;
193+}
194+
195+
196 xs_dict *msg_actor(snac *snac)
197 /* create a Person message for this actor */
198 {
199@@ -1194,10 +1276,20 @@ xs_dict *msg_actor(snac *snac)
200 xs *avtr = NULL;
201 xs *kid = NULL;
202 xs *f_bio = NULL;
203- xs_dict *msg = msg_base(snac, "Person", snac->actor, NULL, NULL, NULL);
204+ xs_dict *msg = NULL;
205 const char *p;
206 int n;
207
208+ /* everybody loves some caching */
209+ if (time(NULL) - object_mtime(snac->actor) < 3 * 3600 &&
210+ valid_status(object_get(snac->actor, &msg))) {
211+ snac_debug(snac, 2, xs_fmt("Returning cached actor %s", snac->actor));
212+
213+ return msg;
214+ }
215+
216+ msg = msg_base(snac, "Person", snac->actor, NULL, NULL, NULL);
217+
218 /* change the @context (is this really necessary?) */
219 ctxt = xs_list_append(ctxt, "https:/" "/www.w3.org/ns/activitystreams");
220 ctxt = xs_list_append(ctxt, "https:/" "/w3id.org/security/v1");
221@@ -1242,6 +1334,10 @@ xs_dict *msg_actor(snac *snac)
222 if (xs_type(xs_dict_get(snac->config, "bot")) == XSTYPE_TRUE)
223 msg = xs_dict_set(msg, "type", "Service");
224
225+ /* if it's named "relay", then identify as an "Application" */
226+ if (strcmp(snac->uid, "relay") == 0)
227+ msg = xs_dict_set(msg, "type", "Application");
228+
229 /* add the header image, if there is one defined */
230 const char *header = xs_dict_get(snac->config, "header");
231 if (!xs_is_null(header)) {
232@@ -1307,7 +1403,7 @@ xs_dict *msg_actor(snac *snac)
233 }
234
235 /* use shared inboxes? */
236- if (xs_type(xs_dict_get(srv_config, "shared_inboxes")) == XSTYPE_TRUE) {
237+ if (xs_is_true(xs_dict_get(srv_config, "shared_inboxes")) || strcmp(snac->uid, "relay") == 0) {
238 xs *d = xs_dict_new();
239 xs *si = xs_fmt("%s/shared-inbox", srv_baseurl);
240 d = xs_dict_append(d, "sharedInbox", si);
241@@ -1326,6 +1422,15 @@ xs_dict *msg_actor(snac *snac)
242 msg = xs_dict_set(msg, "manuallyApprovesFollowers",
243 xs_stock(xs_is_true(manually) ? XSTYPE_TRUE : XSTYPE_FALSE));
244
245+ /* if there are location coords, create a Place object */
246+ xs *location = msg_actor_place(snac, "Home");
247+ if (xs_type(location) == XSTYPE_DICT)
248+ msg = xs_dict_set(msg, "location", location);
249+
250+ /* cache it */
251+ snac_debug(snac, 1, xs_fmt("Caching actor %s", snac->actor));
252+ object_add_ow(snac->actor, msg);
253+
254 return msg;
255 }
256
257@@ -1423,8 +1528,9 @@ xs_dict *msg_follow(snac *snac, const char *q)
258
259 xs_dict *msg_note(snac *snac, const xs_str *content, const xs_val *rcpts,
260 const xs_str *in_reply_to, const xs_list *attach,
261- int priv, const char *lang_str)
262+ int scope, const char *lang_str)
263 /* creates a 'Note' message */
264+/* scope: 0, public; 1, private (mentioned only); 2, "quiet public"; 3, followers only */
265 {
266 xs *ntid = tid(0);
267 xs *id = xs_fmt("%s/p/%s", snac->actor, ntid);
268@@ -1440,6 +1546,9 @@ xs_dict *msg_note(snac *snac, const xs_str *content, const xs_val *rcpts,
269 xs_list *p;
270 const xs_val *v;
271
272+ /* FIXME: implement scope 3 */
273+ int priv = scope == 1;
274+
275 if (rcpts == NULL)
276 to = xs_list_new();
277 else {
278@@ -1557,6 +1666,12 @@ xs_dict *msg_note(snac *snac, const xs_str *content, const xs_val *rcpts,
279 }
280 }
281
282+ if (scope == 2) {
283+ /* Mastodon's "quiet public": add public address to cc */
284+ if (xs_list_in(cc, public_address) == -1)
285+ cc = xs_list_append(cc, public_address);
286+ }
287+ else
288 /* no recipients? must be for everybody */
289 if (!priv && xs_list_len(to) == 0)
290 to = xs_list_append(to, public_address);
291@@ -1845,6 +1960,17 @@ int process_input_message(snac *snac, const xs_dict *msg, const xs_dict *req)
292 /* reject uninteresting messages right now */
293 if (xs_match(type, "Add|View|Reject|Read|Remove")) {
294 srv_debug(0, xs_fmt("Ignored message of type '%s'", type));
295+
296+ /* archive the ignored activity */
297+ xs *ntid = tid(0);
298+ xs *fn = xs_fmt("%s/ignored/%s.json", srv_basedir, ntid);
299+ FILE *f;
300+
301+ if ((f = fopen(fn, "w")) != NULL) {
302+ xs_json_dump(msg, 4, f);
303+ fclose(f);
304+ }
305+
306 return -1;
307 }
308
309@@ -2118,14 +2244,14 @@ int process_input_message(snac *snac, const xs_dict *msg, const xs_dict *req)
310 snac_log(snac, xs_fmt("new 'Question' %s %s", actor, id));
311 }
312 else
313- if (strcmp(utype, "Video") == 0) { /** **/
314+ if (xs_match(utype, "Audio|Video|Event")) { /** **/
315 const char *id = xs_dict_get(object, "id");
316
317 if (xs_is_null(id))
318 snac_log(snac, xs_fmt("malformed message: no 'id' field"));
319 else
320 if (timeline_add(snac, id, object))
321- snac_log(snac, xs_fmt("new 'Video' %s %s", actor, id));
322+ snac_log(snac, xs_fmt("new '%s' %s %s", utype, actor, id));
323 }
324 else
325 snac_debug(snac, 1, xs_fmt("ignored 'Create' for object type '%s'", utype));
326@@ -2203,15 +2329,23 @@ int process_input_message(snac *snac, const xs_dict *msg, const xs_dict *req)
327 xs *who_o = NULL;
328
329 if (valid_status(actor_request(snac, who, &who_o))) {
330- if (timeline_admire(snac, object, actor, 0) == HTTP_STATUS_CREATED)
331- snac_log(snac, xs_fmt("new 'Announce' %s %s", actor, object));
332- else
333- snac_log(snac, xs_fmt("repeated 'Announce' from %s to %s",
334- actor, object));
335+ /* don't account as such announces by our own relay */
336+ xs *this_relay = xs_fmt("%s/relay", srv_baseurl);
337+
338+ if (strcmp(actor, this_relay) != 0) {
339+ if (timeline_admire(snac, object, actor, 0) == HTTP_STATUS_CREATED)
340+ snac_log(snac, xs_fmt("new 'Announce' %s %s", actor, object));
341+ else
342+ snac_log(snac, xs_fmt("repeated 'Announce' from %s to %s",
343+ actor, object));
344+ }
345
346 /* distribute the post with the actor as 'proxy' */
347 list_distribute(snac, actor, a_msg);
348
349+ /* distribute the post to users following these hashtags */
350+ followed_hashtag_distribute(a_msg);
351+
352 do_notify = 1;
353 }
354 else
355@@ -2226,14 +2360,14 @@ int process_input_message(snac *snac, const xs_dict *msg, const xs_dict *req)
356 }
357 else
358 if (strcmp(type, "Update") == 0) { /** **/
359- if (xs_match(utype, "Person|Service")) { /** **/
360+ if (xs_match(utype, "Person|Service|Application")) { /** **/
361 actor_add(actor, xs_dict_get(msg, "object"));
362 timeline_touch(snac);
363
364 snac_log(snac, xs_fmt("updated actor %s", actor));
365 }
366 else
367- if (xs_match(utype, "Note|Page|Article|Video")) { /** **/
368+ if (xs_match(utype, "Note|Page|Article|Video|Audio|Event")) { /** **/
369 const char *id = xs_dict_get(object, "id");
370
371 if (xs_is_null(id))
372@@ -2419,7 +2553,7 @@ int send_email(const char *msg)
373 }
374
375
376-void process_user_queue_item(snac *snac, xs_dict *q_item)
377+void process_user_queue_item(snac *user, xs_dict *q_item)
378 /* processes an item from the user queue */
379 {
380 const char *type;
381@@ -2430,7 +2564,7 @@ void process_user_queue_item(snac *snac, xs_dict *q_item)
382
383 if (strcmp(type, "message") == 0) {
384 const xs_dict *msg = xs_dict_get(q_item, "message");
385- xs *rcpts = recipient_list(snac, msg, 1);
386+ xs *rcpts = recipient_list(user, msg, 1);
387 xs_set inboxes;
388 const xs_str *actor;
389
390@@ -2439,7 +2573,7 @@ void process_user_queue_item(snac *snac, xs_dict *q_item)
391 /* add this shared inbox first */
392 xs *this_shared_inbox = xs_fmt("%s/shared-inbox", srv_baseurl);
393 xs_set_add(&inboxes, this_shared_inbox);
394- enqueue_output(snac, msg, this_shared_inbox, 0, 0);
395+ enqueue_output(user, msg, this_shared_inbox, 0, 0);
396
397 /* iterate the recipients */
398 xs_list_foreach(rcpts, actor) {
399@@ -2450,10 +2584,10 @@ void process_user_queue_item(snac *snac, xs_dict *q_item)
400 if (inbox != NULL) {
401 /* add to the set and, if it's not there, send message */
402 if (xs_set_add(&inboxes, inbox) == 1)
403- enqueue_output(snac, msg, inbox, 0, 0);
404+ enqueue_output(user, msg, inbox, 0, 0);
405 }
406 else
407- snac_log(snac, xs_fmt("cannot find inbox for %s", actor));
408+ snac_log(user, xs_fmt("cannot find inbox for %s", actor));
409 }
410 }
411
412@@ -2465,12 +2599,36 @@ void process_user_queue_item(snac *snac, xs_dict *q_item)
413
414 xs_list_foreach(shibx, inbox) {
415 if (xs_set_add(&inboxes, inbox) == 1)
416- enqueue_output(snac, msg, inbox, 0, 0);
417+ enqueue_output(user, msg, inbox, 0, 0);
418 }
419 }
420 }
421
422 xs_set_free(&inboxes);
423+
424+ /* relay this note */
425+ if (is_msg_public(msg) && strcmp(user->uid, "relay") != 0) { /* avoid loops */
426+ snac relay;
427+ if (user_open(&relay, "relay")) {
428+ /* a 'relay' user exists */
429+ const char *type = xs_dict_get(msg, "type");
430+
431+ if (xs_is_string(type) && strcmp(type, "Create") == 0) {
432+ const xs_val *object = xs_dict_get(msg, "object");
433+
434+ if (xs_is_dict(object)) {
435+ object = xs_dict_get(object, "id");
436+
437+ snac_debug(&relay, 1, xs_fmt("relaying message %s", object));
438+
439+ xs *boost = msg_admiration(&relay, object, "Announce");
440+ enqueue_message(&relay, boost);
441+ }
442+ }
443+
444+ user_free(&relay);
445+ }
446+ }
447 }
448 else
449 if (strcmp(type, "input") == 0) {
450@@ -2482,13 +2640,13 @@ void process_user_queue_item(snac *snac, xs_dict *q_item)
451 if (xs_is_null(msg))
452 return;
453
454- if (!process_input_message(snac, msg, req)) {
455+ if (!process_input_message(user, msg, req)) {
456 if (retries > queue_retry_max)
457- snac_log(snac, xs_fmt("input giving up"));
458+ snac_log(user, xs_fmt("input giving up"));
459 else {
460 /* reenqueue */
461- enqueue_input(snac, msg, req, retries + 1);
462- snac_log(snac, xs_fmt("input requeue #%d", retries + 1));
463+ enqueue_input(user, msg, req, retries + 1);
464+ snac_log(user, xs_fmt("input requeue #%d", retries + 1));
465 }
466 }
467 }
468@@ -2498,7 +2656,7 @@ void process_user_queue_item(snac *snac, xs_dict *q_item)
469 const char *id = xs_dict_get(q_item, "message");
470
471 if (!xs_is_null(id))
472- update_question(snac, id);
473+ update_question(user, id);
474 }
475 else
476 if (strcmp(type, "object_request") == 0) {
477@@ -2508,17 +2666,17 @@ void process_user_queue_item(snac *snac, xs_dict *q_item)
478 int status;
479 xs *data = NULL;
480
481- status = activitypub_request(snac, id, &data);
482+ status = activitypub_request(user, id, &data);
483
484 if (valid_status(status))
485 object_add_ow(id, data);
486
487- snac_debug(snac, 1, xs_fmt("object_request %s %d", id, status));
488+ snac_debug(user, 1, xs_fmt("object_request %s %d", id, status));
489 }
490 }
491 else
492 if (strcmp(type, "verify_links") == 0) {
493- verify_links(snac);
494+ verify_links(user);
495 }
496 else
497 if (strcmp(type, "actor_refresh") == 0) {
498@@ -2530,16 +2688,16 @@ void process_user_queue_item(snac *snac, xs_dict *q_item)
499 xs *actor_o = NULL;
500 int status;
501
502- if (valid_status((status = activitypub_request(snac, actor, &actor_o))))
503+ if (valid_status((status = activitypub_request(user, actor, &actor_o))))
504 actor_add(actor, actor_o);
505 else
506 object_touch(actor);
507
508- snac_log(snac, xs_fmt("actor_refresh %s %d", actor, status));
509+ snac_log(user, xs_fmt("actor_refresh %s %d", actor, status));
510 }
511 }
512 else
513- snac_log(snac, xs_fmt("unexpected user q_item type '%s'", type));
514+ snac_log(user, xs_fmt("unexpected user q_item type '%s'", type));
515 }
516
517
518@@ -2640,7 +2798,7 @@ void process_queue_item(xs_dict *q_item)
519 || status == HTTP_STATUS_UNPROCESSABLE_CONTENT
520 || status < 0)
521 /* explicit error: discard */
522- srv_log(xs_fmt("output message: fatal error %s %d", inbox, status));
523+ srv_log(xs_fmt("output message: error %s %d", inbox, status));
524 else
525 if (retries > queue_retry_max)
526 srv_log(xs_fmt("output message: giving up %s %d", inbox, status));
527@@ -2769,11 +2927,12 @@ void process_queue_item(xs_dict *q_item)
528 snac user;
529
530 if (user_open(&user, v)) {
531- if (is_msg_for_me(&user, msg)) {
532+ int rsn = is_msg_for_me(&user, msg);
533+ if (rsn) {
534 xs *fn = xs_fmt("%s/queue/%s.json", user.basedir, ntid);
535
536 snac_debug(&user, 1,
537- xs_fmt("enqueue_input (from shared inbox) %s", xs_dict_get(msg, "id")));
538+ xs_fmt("enqueue_input (from shared inbox) %s [%d]", xs_dict_get(msg, "id"), rsn));
539
540 if (link(tmpfn, fn) < 0)
541 srv_log(xs_fmt("link(%s, %s) error", tmpfn, fn));
M
data.c
+55,
-8
1@@ -1,5 +1,5 @@
2 /* snac - A simple, minimalistic ActivityPub instance */
3-/* copyright (c) 2022 - 2024 grunfink et al. / MIT license */
4+/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */
5
6 #include "xs.h"
7 #include "xs_hex.h"
8@@ -319,7 +319,8 @@ int user_persist(snac *snac, int publish)
9
10 if (old != NULL) {
11 int nw = 0;
12- const char *fields[] = { "header", "avatar", "name", "bio", "metadata", NULL };
13+ const char *fields[] = { "header", "avatar", "name", "bio",
14+ "metadata", "latitude", "longitude", NULL };
15
16 for (int n = 0; fields[n]; n++) {
17 const char *of = xs_dict_get(old, fields[n]);
18@@ -336,6 +337,10 @@ int user_persist(snac *snac, int publish)
19
20 if (!nw)
21 publish = 0;
22+ else {
23+ /* uncache the actor object */
24+ object_del(snac->actor);
25+ }
26 }
27 }
28 }
29@@ -674,6 +679,37 @@ int index_desc_first(FILE *f, char md5[MD5_HEX_SIZE], int skip)
30 return 1;
31 }
32
33+int index_asc_first(FILE *f,char md5[MD5_HEX_SIZE], const char *seek_md5)
34+/* reads the first entry of an ascending index, starting from a given md5 */
35+{
36+ fseek(f, SEEK_SET, 0);
37+ while (fread(md5, MD5_HEX_SIZE, 1, f)) {
38+ md5[MD5_HEX_SIZE - 1] = '\0';
39+ if (strcmp(md5,seek_md5) == 0) {
40+ return index_asc_next(f, md5);
41+ }
42+ }
43+ return 0;
44+}
45+
46+int index_asc_next(FILE *f, char md5[MD5_HEX_SIZE])
47+/* reads the next entry of an ascending index */
48+{
49+ for (;;) {
50+ /* read an md5 */
51+ if (!fread(md5, MD5_HEX_SIZE, 1, f))
52+ return 0;
53+
54+ /* deleted, skip */
55+ if (md5[0] != '-')
56+ break;
57+ }
58+
59+ md5[MD5_HEX_SIZE - 1] = '\0';
60+
61+ return 1;
62+}
63+
64
65 xs_list *index_list_desc(const char *fn, int skip, int show)
66 /* returns an index as a list, in reverse order */
67@@ -1363,11 +1399,13 @@ void timeline_update_indexes(snac *snac, const char *id)
68 if (valid_status(object_get(id, &msg))) {
69 /* if its ours and is public, also store in public */
70 if (is_msg_public(msg)) {
71- object_user_cache_add(snac, id, "public");
72-
73- /* also add it to the instance public timeline */
74- xs *ipt = xs_fmt("%s/public.idx", srv_basedir);
75- index_add(ipt, id);
76+ if (object_user_cache_add(snac, id, "public") >= 0) {
77+ /* also add it to the instance public timeline */
78+ xs *ipt = xs_fmt("%s/public.idx", srv_basedir);
79+ index_add(ipt, id);
80+ }
81+ else
82+ srv_debug(1, xs_fmt("Not added to public instance index %s", id));
83 }
84 }
85 }
86@@ -1488,8 +1526,17 @@ xs_list *timeline_instance_list(int skip, int show)
87 /* returns the timeline for the full instance */
88 {
89 xs *idx = instance_index_fn();
90+ xs *lst = index_list_desc(idx, skip, show);
91
92- return index_list_desc(idx, skip, show);
93+ /* make the list unique */
94+ xs_set rep;
95+ xs_set_init(&rep);
96+ const char *md5;
97+
98+ xs_list_foreach(lst, md5)
99+ xs_set_add(&rep, md5);
100+
101+ return xs_set_result(&rep);
102 }
103
104
+8,
-1
1@@ -256,7 +256,7 @@ it's - (a lonely hyphen), the post content will be read from stdin.
2 The rest of command line arguments are treated as media files to be
3 attached to the post.
4 .It Cm note_unlisted Ar basedir Ar uid Ar text Op file file ...
5-Like the previous one, but creates an "unlisted" (or "quiet public") one.
6+Like the previous one, but creates an "unlisted" (or "quiet public") post.
7 .It Cm block Ar basedir Ar instance_url
8 Blocks a full instance, given its URL or domain name. All subsequent
9 incoming activities with identifiers from that instance will be immediately
10@@ -377,6 +377,13 @@ https://$SNAC_HOST/oauth/x-snac-get-token
11 .Pp
12 .Sh ENVIRONMENT
13 .Bl -tag -width Ds
14+.It SNAC_BASEDIR
15+This optional environment variable can be set to the base directory of
16+your installation; if set, you don't have to add the base directory as an
17+argument to command-line operations. This may prove useful if you only
18+have one
19+.Nm
20+instance in you system (which is probably your case).
21 .It Ev DEBUG
22 Overrides the debugging level from the server 'dbglevel' configuration
23 variable. Set it to an integer value. The higher, the deeper in meaningless
+6,
-1
1@@ -24,9 +24,11 @@ A special subset of Markdown is allowed, including:
2 .It bold
3 **text between two pairs of asterisks**
4 .It italic
5-*text between a pair of asterisks*
6+*text between a pair of asterisks* or _between a pair of underscores_
7 .It strikethrough text
8 ~~text between a pair of tildes~~
9+.It underlined text
10+__text between two pairs of underscores__
11 .It code
12 Text `between backticks` is formatted as code.
13 .Bd -literal
14@@ -53,6 +55,9 @@ Horizonal rules can be inserted by typing three minus symbols
15 alone in a line.
16 .It quoted text
17 Lines starting with >.
18+.It headers
19+One, two or three # at the beginning of a line plus a space plus
20+some text are converted to HTML headers.
21 .It user mentions
22 Strings in the format @user@host are requested using the Webfinger
23 protocol and converted to links and mentions if something reasonable
+40,
-0
1@@ -585,6 +585,31 @@ to pass the remote connection address in the
2 .Ic X-Forwarded-For
3 HTTP header (unless you use the FastCGI interface; if that's the case, you don't have
4 to do anything).
5+.Pp
6+.Ss Subscribing to Fediverse Relays
7+Since version 2.69, a
8+.Nm
9+instance can subscribe to LitePub (Pleroma-style) Fediverse Relays. Doing this improves
10+visibility and allows following hashtags. To do this, you must create a special user named
11+relay and, from it, follow the relay actor(s) like you do with regular actor URLs. This
12+special user will start receiving boosts from the relay server of posts from other instances
13+also following it. If any other user of the same
14+.Nm
15+instance follows any of the hashtags included in these boosted posts coming from the relay,
16+they will be received as if they were for them.
17+.Pp
18+Example:
19+.Bd -literal -offset indent
20+snac adduser $SNAC_BASEDIR relay # only needed once
21+snac follow $SNAC_BASEDIR relay https://relay.example.com/actor
22+.Ed
23+.Pp
24+Users on your instance do NOT need to follow the local relay user to benefit from following
25+hashtags.
26+.Pp
27+Please take note that subscribing to relays can increase the traffic towards your instance
28+significantly. In any case, lowering the "Maximum days to keep posts" value for the relay
29+special user is recommended (e.g. setting to just 1 day).
30 .Sh ENVIRONMENT
31 .Bl -tag -width Ds
32 .It Ev DEBUG
33@@ -685,6 +710,12 @@ location /share {
34 proxy_set_header Host $http_host;
35 proxy_set_header X-Forwarded-For $remote_addr;
36 }
37+# optional (Mastodon-like "authorize interaction" entrypoint)
38+location /authorize_interaction {
39+ proxy_pass http://localhost:8001;
40+ proxy_set_header Host $http_host;
41+ proxy_set_header X-Forwarded-For $remote_addr;
42+}
43 .Ed
44 .Pp
45 Restart the nginx daemon and connect to
46@@ -738,6 +769,11 @@ ProxyPreserveHost On
47 <Location /share>
48 ProxyPass http://127.0.0.1:8001/share
49 </Location>
50+
51+# optional (Mastodon-like "authorize interaction" entrypoint)
52+<Location /authorize_interaction>
53+ ProxyPass http://127.0.0.1:8001/share
54+</Location>
55 .Ed
56 .Pp
57 Since version 2.43,
58@@ -797,6 +833,10 @@ location "/.well-known/host-meta" {
59 location "/share" {
60 fastcgi socket tcp "127.0.0.1" 8001
61 }
62+
63+location "/authorize_interaction" {
64+ fastcgi socket tcp "127.0.0.1" 8001
65+}
66 .Ed
67 .Sh SEE ALSO
68 .Xr snac 1 ,
M
format.c
+47,
-2
1@@ -1,5 +1,5 @@
2 /* snac - A simple, minimalistic ActivityPub instance */
3-/* copyright (c) 2022 - 2024 grunfink et al. / MIT license */
4+/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */
5
6 #include "xs.h"
7 #include "xs_regex.h"
8@@ -92,6 +92,8 @@ static xs_str *format_line(const char *line, xs_list **attach)
9 "`[^`]+`" "|"
10 "~~[^~]+~~" "|"
11 "\\*\\*?\\*?[^\\*]+\\*?\\*?\\*" "|"
12+ "_[^_]+_" "|" //anzu
13+ "__[^_]+__" "|" //anzu
14 "!\\[[^]]+\\]\\([^\\)]+\\)" "|"
15 "\\[[^]]+\\]\\([^\\)]+\\)" "|"
16 "[a-z]+:/" "/[^[:space:]]+" "|"
17@@ -127,6 +129,20 @@ static xs_str *format_line(const char *line, xs_list **attach)
18 xs *s2 = xs_fmt("<i>%s</i>", s1);
19 s = xs_str_cat(s, s2);
20 }
21+ //anzu - begin
22+ else
23+ if (xs_startswith(v, "__")) {
24+ xs *s1 = xs_strip_chars_i(xs_dup(v), "_");
25+ xs *s2 = xs_fmt("<u>%s</u>", s1);
26+ s = xs_str_cat(s, s2);
27+ }
28+ else
29+ if (xs_startswith(v, "_")) {
30+ xs *s1 = xs_strip_chars_i(xs_dup(v), "_");
31+ xs *s2 = xs_fmt("<i>%s</i>", s1);
32+ s = xs_str_cat(s, s2);
33+ }
34+ //anzu - end
35 else
36 if (xs_startswith(v, "~~")) {
37 xs *s1 = xs_strip_chars_i(xs_dup(v), "~");
38@@ -303,6 +319,31 @@ xs_str *not_really_markdown(const char *content, xs_list **attach, xs_list **tag
39 continue;
40 }
41
42+ //anzu - begin
43+ // h1 reserved for snac?
44+ if (xs_startswith(ss, "# ")) {
45+ ss = xs_strip_i(xs_crop_i(ss, 2, 0));
46+ s = xs_str_cat(s, "<h2>");
47+ s = xs_str_cat(s, ss);
48+ s = xs_str_cat(s, "</h2>");
49+ continue;
50+ }
51+ if (xs_startswith(ss, "## ")) {
52+ ss = xs_strip_i(xs_crop_i(ss, 3, 0));
53+ s = xs_str_cat(s, "<h2>");
54+ s = xs_str_cat(s, ss);
55+ s = xs_str_cat(s, "</h2>");
56+ continue;
57+ }
58+ if (xs_startswith(ss, "### ")) {
59+ ss = xs_strip_i(xs_crop_i(ss, 4, 0));
60+ s = xs_str_cat(s, "<h3>");
61+ s = xs_str_cat(s, ss);
62+ s = xs_str_cat(s, "</h3>");
63+ continue;
64+ }
65+ //anzu - end
66+
67 if (xs_startswith(ss, ">")) {
68 /* delete the > and subsequent spaces */
69 ss = xs_strip_i(xs_crop_i(ss, 1, 0));
70@@ -336,6 +377,8 @@ xs_str *not_really_markdown(const char *content, xs_list **attach, xs_list **tag
71 s = xs_replace_i(s, "<br><br><blockquote>", "<br><blockquote>");
72 s = xs_replace_i(s, "</blockquote><br>", "</blockquote>");
73 s = xs_replace_i(s, "</pre><br>", "</pre>");
74+ s = xs_replace_i(s, "</h2><br>", "</h2>"); //anzu ???
75+ s = xs_replace_i(s, "</h3><br>", "</h3>"); //anzu ???
76
77 {
78 /* traditional emoticons */
79@@ -378,7 +421,9 @@ xs_str *not_really_markdown(const char *content, xs_list **attach, xs_list **tag
80
81 const char *valid_tags[] = {
82 "a", "p", "br", "br/", "blockquote", "ul", "ol", "li", "cite", "small",
83- "span", "i", "b", "u", "s", "pre", "code", "em", "strong", "hr", "img", "del", "bdi", NULL
84+ "span", "i", "b", "u", "s", "pre", "code", "em", "strong", "hr", "img", "del", "bdi",
85+ "h2","h3", //anzu
86+ NULL
87 };
88
89 xs_str *sanitize(const char *content)
M
html.c
+374,
-19
1@@ -1,5 +1,5 @@
2 /* snac - A simple, minimalistic ActivityPub instance */
3-/* copyright (c) 2022 - 2024 grunfink et al. / MIT license */
4+/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */
5
6 #include "xs.h"
7 #include "xs_io.h"
8@@ -12,6 +12,7 @@
9 #include "xs_match.h"
10 #include "xs_html.h"
11 #include "xs_curl.h"
12+#include "xs_unicode.h"
13
14 #include "snac.h"
15
16@@ -113,7 +114,8 @@ xs_str *actor_name(xs_dict *actor, const char *proxy)
17
18
19 xs_html *html_actor_icon(snac *user, xs_dict *actor, const char *date,
20- const char *udate, const char *url, int priv, int in_people, const char *proxy)
21+ const char *udate, const char *url, int priv,
22+ int in_people, const char *proxy, const char *lang)
23 {
24 xs_html *actor_icon = xs_html_tag("p", NULL);
25
26@@ -219,6 +221,9 @@ xs_html *html_actor_icon(snac *user, xs_dict *actor, const char *date,
27 date_title = xs_str_cat(date_title, " / ", udate);
28 }
29
30+ if (xs_is_string(lang))
31+ date_title = xs_str_cat(date_title, " (", lang, ")");
32+
33 xs_html_add(actor_icon,
34 xs_html_text(" "),
35 xs_html_tag("time",
36@@ -265,6 +270,7 @@ xs_html *html_msg_icon(snac *user, const char *actor_id, const xs_dict *msg, con
37 const char *date = NULL;
38 const char *udate = NULL;
39 const char *url = NULL;
40+ const char *lang = NULL;
41 int priv = 0;
42 const char *type = xs_dict_get(msg, "type");
43
44@@ -276,7 +282,17 @@ xs_html *html_msg_icon(snac *user, const char *actor_id, const xs_dict *msg, con
45 date = xs_dict_get(msg, "published");
46 udate = xs_dict_get(msg, "updated");
47
48- actor_icon = html_actor_icon(user, actor, date, udate, url, priv, 0, proxy);
49+ lang = xs_dict_get(msg, "contentMap");
50+ if (xs_is_dict(lang)) {
51+ const char *v;
52+ int c = 0;
53+
54+ xs_dict_next(lang, &lang, &v, &c);
55+ }
56+ else
57+ lang = NULL;
58+
59+ actor_icon = html_actor_icon(user, actor, date, udate, url, priv, 0, proxy, lang);
60 }
61
62 return actor_icon;
63@@ -933,6 +949,7 @@ static xs_html *html_user_body(snac *user, int read_only)
64 xs_html_raw("✔ "),
65 xs_html_tag("a",
66 xs_html_attr("rel", "me"),
67+ xs_html_attr("target", "_blank"),
68 xs_html_attr("href", v),
69 xs_html_text(v)));
70 }
71@@ -969,6 +986,23 @@ static xs_html *html_user_body(snac *user, int read_only)
72 snac_metadata);
73 }
74
75+ const char *latitude = xs_dict_get_def(user->config, "latitude", "");
76+ const char *longitude = xs_dict_get_def(user->config, "longitude", "");
77+
78+ if (*latitude && *longitude) {
79+ xs *label = xs_fmt(L("%s,%s"), latitude, longitude);
80+ xs *url = xs_fmt(L("https://openstreetmap.org/search?query=%s,%s"),
81+ latitude, longitude);
82+
83+ xs_html_add(top_user,
84+ xs_html_tag("p",
85+ xs_html_text(L("Location: ")),
86+ xs_html_tag("a",
87+ xs_html_attr("href", url),
88+ xs_html_attr("target", "_blank"),
89+ xs_html_text(label))));
90+ }
91+
92 if (xs_is_true(xs_dict_get(user->config, "show_contact_metrics"))) {
93 xs *fwers = follower_list(user);
94 xs *fwing = following_list(user);
95@@ -1110,6 +1144,8 @@ xs_html *html_top_controls(snac *snac)
96 const xs_val *coll_thrds = xs_dict_get(snac->config, "collapse_threads");
97 const xs_val *pending = xs_dict_get(snac->config, "approve_followers");
98 const xs_val *show_foll = xs_dict_get(snac->config, "show_contact_metrics");
99+ const char *latitude = xs_dict_get_def(snac->config, "latitude", "");
100+ const char *longitude = xs_dict_get_def(snac->config, "longitude", "");
101
102 xs *metadata = NULL;
103 const xs_dict *md = xs_dict_get(snac->config, "metadata");
104@@ -1299,6 +1335,20 @@ xs_html *html_top_controls(snac *snac)
105 xs_html_tag("label",
106 xs_html_attr("for", "show_contact_metrics"),
107 xs_html_text(L("Publish follower and following metrics")))),
108+ xs_html_tag("p",
109+ xs_html_text(L("Current location:")),
110+ xs_html_sctag("br", NULL),
111+ xs_html_sctag("input",
112+ xs_html_attr("type", "text"),
113+ xs_html_attr("name", "latitude"),
114+ xs_html_attr("value", latitude),
115+ xs_html_attr("placeholder", "latitude")),
116+ xs_html_text(" "),
117+ xs_html_sctag("input",
118+ xs_html_attr("type", "text"),
119+ xs_html_attr("name", "longitude"),
120+ xs_html_attr("value", longitude),
121+ xs_html_attr("placeholder", "longitude"))),
122 xs_html_tag("p",
123 xs_html_text(L("Profile metadata (key=value pairs in each line):")),
124 xs_html_sctag("br", NULL),
125@@ -1328,7 +1378,41 @@ xs_html *html_top_controls(snac *snac)
126 xs_html_sctag("input",
127 xs_html_attr("type", "submit"),
128 xs_html_attr("class", "button"),
129- xs_html_attr("value", L("Update user info")))))));
130+ xs_html_attr("value", L("Update user info"))),
131+
132+ xs_html_tag("p", NULL)))));
133+
134+ xs *followed_hashtags_action = xs_fmt("%s/admin/followed-hashtags", snac->actor);
135+ xs *followed_hashtags = xs_join(xs_dict_get_def(snac->config,
136+ "followed_hashtags", xs_stock(XSTYPE_LIST)), "\n");
137+
138+ xs_html_add(top_controls,
139+ xs_html_tag("details",
140+ xs_html_tag("summary",
141+ xs_html_text(L("Followed hashtags..."))),
142+ xs_html_tag("p",
143+ xs_html_text(L("One hashtag per line"))),
144+ xs_html_tag("div",
145+ xs_html_attr("class", "snac-followed-hashtags"),
146+ xs_html_tag("form",
147+ xs_html_attr("autocomplete", "off"),
148+ xs_html_attr("method", "post"),
149+ xs_html_attr("action", followed_hashtags_action),
150+ xs_html_attr("enctype", "multipart/form-data"),
151+
152+ xs_html_tag("textarea",
153+ xs_html_attr("name", "followed_hashtags"),
154+ xs_html_attr("cols", "40"),
155+ xs_html_attr("rows", "4"),
156+ xs_html_attr("placeholder", "#cats\n#windowfriday\n#classicalmusic"),
157+ xs_html_text(followed_hashtags)),
158+
159+ xs_html_tag("br", NULL),
160+
161+ xs_html_sctag("input",
162+ xs_html_attr("type", "submit"),
163+ xs_html_attr("class", "button"),
164+ xs_html_attr("value", L("Update hashtags")))))));
165
166 return top_controls;
167 }
168@@ -1781,13 +1865,15 @@ xs_html *html_entry(snac *user, xs_dict *msg, int read_only,
169 }
170 }
171 }
172- else
173- if (strcmp(type, "Note") == 0) {
174- if (level == 0) {
175- /* is the parent not here? */
176- const char *parent = get_in_reply_to(msg);
177
178- if (user && !xs_is_null(parent) && *parent && !timeline_here(user, parent)) {
179+ if (user && strcmp(type, "Note") == 0) {
180+ /* is the parent not here? */
181+ const char *parent = get_in_reply_to(msg);
182+
183+ if (!xs_is_null(parent) && *parent) {
184+ xs *md5 = xs_md5_hex(parent, strlen(parent));
185+
186+ if (!timeline_here(user, md5)) {
187 xs_html_add(post_header,
188 xs_html_tag("div",
189 xs_html_attr("class", "snac-origin"),
190@@ -2199,6 +2285,135 @@ xs_html *html_entry(snac *user, xs_dict *msg, int read_only,
191 au_tag);
192 }
193
194+ /* does it have a location? */
195+ const xs_dict *location = xs_dict_get(msg, "location");
196+ if (xs_type(location) == XSTYPE_DICT) {
197+ const xs_number *latitude = xs_dict_get(location, "latitude");
198+ const xs_number *longitude = xs_dict_get(location, "longitude");
199+ const char *name = xs_dict_get(location, "name");
200+ const char *address = xs_dict_get(location, "address");
201+ xs *label_list = xs_list_new();
202+
203+ if (xs_type(name) == XSTYPE_STRING)
204+ label_list = xs_list_append(label_list, name);
205+ if (xs_type(address) == XSTYPE_STRING)
206+ label_list = xs_list_append(label_list, address);
207+
208+ if (xs_list_len(label_list)) {
209+ const char *url = xs_dict_get(location, "url");
210+ xs *label = xs_join(label_list, ", ");
211+
212+ if (xs_type(url) == XSTYPE_STRING) {
213+ xs_html_add(snac_content_wrap,
214+ xs_html_tag("p",
215+ xs_html_text(L("Location: ")),
216+ xs_html_tag("a",
217+ xs_html_attr("href", url),
218+ xs_html_attr("target", "_blank"),
219+ xs_html_text(label))));
220+ }
221+ else
222+ if (!xs_is_null(latitude) && !xs_is_null(longitude)) {
223+ xs *url = xs_fmt("https://openstreetmap.org/search/?query=%s,%s",
224+ xs_number_str(latitude), xs_number_str(longitude));
225+
226+ xs_html_add(snac_content_wrap,
227+ xs_html_tag("p",
228+ xs_html_text(L("Location: ")),
229+ xs_html_tag("a",
230+ xs_html_attr("href", url),
231+ xs_html_attr("target", "_blank"),
232+ xs_html_text(label))));
233+ }
234+ else
235+ xs_html_add(snac_content_wrap,
236+ xs_html_tag("p",
237+ xs_html_text(L("Location: ")),
238+ xs_html_text(label)));
239+ }
240+ }
241+
242+ if (strcmp(type, "Event") == 0) { /** Event start and end times **/
243+ const char *s_time = xs_dict_get(msg, "startTime");
244+
245+ if (xs_is_string(s_time) && strlen(s_time) > 20) {
246+ const char *e_time = xs_dict_get(msg, "endTime");
247+ const char *tz = xs_dict_get(msg, "timezone");
248+
249+ xs *s = xs_replace_i(xs_dup(s_time), "T", " ");
250+ xs *e = NULL;
251+
252+ if (xs_is_string(e_time) && strlen(e_time) > 20)
253+ e = xs_replace_i(xs_dup(e_time), "T", " ");
254+
255+ /* if the event has a timezone, crop the offsets */
256+ if (xs_is_string(tz)) {
257+ s = xs_crop_i(s, 0, 19);
258+
259+ if (e)
260+ e = xs_crop_i(e, 0, 19);
261+ }
262+ else
263+ tz = "";
264+
265+ /* if start and end share the same day, crop it from the end */
266+ if (e && memcmp(s, e, 11) == 0)
267+ e = xs_crop_i(e, 11, 0);
268+
269+ if (e)
270+ s = xs_str_cat(s, " / ", e);
271+
272+ if (*tz)
273+ s = xs_str_cat(s, " (", tz, ")");
274+
275+ /* replace ugly decimals */
276+ s = xs_replace_i(s, ".000", "");
277+
278+ xs_html_add(snac_content_wrap,
279+ xs_html_tag("p",
280+ xs_html_text(L("Time: ")),
281+ xs_html_text(s)));
282+ }
283+ }
284+
285+ /* show all hashtags that has not been shown previously in the content */
286+ const xs_list *tags = xs_dict_get(msg, "tag");
287+ const char *o_content = xs_dict_get_def(msg, "content", "");
288+
289+ if (xs_is_string(o_content) && xs_is_list(tags) && xs_list_len(tags)) {
290+ xs *content = xs_utf8_to_lower(o_content);
291+ const xs_dict *tag;
292+
293+ xs_html *add_hashtags = xs_html_tag("ul",
294+ xs_html_attr("class", "snac-more-hashtags"));
295+
296+ xs_list_foreach(tags, tag) {
297+ const char *type = xs_dict_get(tag, "type");
298+
299+ if (xs_is_string(type) && strcmp(type, "Hashtag") == 0) {
300+ const char *o_href = xs_dict_get(tag, "href");
301+ const char *name = xs_dict_get(tag, "name");
302+
303+ if (xs_is_string(o_href) && xs_is_string(name)) {
304+ xs *href = xs_utf8_to_lower(o_href);
305+
306+ if (xs_str_in(content, href) == -1 && xs_str_in(content, name) == -1) {
307+ /* not in the content: add here */
308+ xs_html_add(add_hashtags,
309+ xs_html_tag("li",
310+ xs_html_tag("a",
311+ xs_html_attr("href", href),
312+ xs_html_text(name),
313+ xs_html_text(" "))));
314+ }
315+ }
316+ }
317+ }
318+
319+ xs_html_add(snac_content_wrap,
320+ add_hashtags);
321+ }
322+
323 /** controls **/
324
325 if (!read_only && user) {
326@@ -2583,7 +2798,7 @@ xs_html *html_people_list(snac *snac, xs_list *list, char *header, char *t, cons
327 xs_html_tag("div",
328 xs_html_attr("class", "snac-post-header"),
329 html_actor_icon(snac, actor, xs_dict_get(actor, "published"),
330- NULL, NULL, 0, 1, proxy)));
331+ NULL, NULL, 0, 1, proxy, NULL)));
332
333 /* content (user bio) */
334 const char *c = xs_dict_get(actor, "summary");
335@@ -2762,9 +2977,15 @@ xs_str *html_notifications(snac *user, int skip, int show)
336 xs_html_attr("class", "snac-posts"));
337 xs_html_add(body, posts);
338
339- xs_list *p = n_list;
340+ xs_set rep;
341+ xs_set_init(&rep);
342+
343+ /* dict to store previous notification labels */
344+ xs *admiration_labels = xs_dict_new();
345+
346 const xs_str *v;
347- while (xs_list_iter(&p, &v)) {
348+
349+ xs_list_foreach(n_list, v) {
350 xs *noti = notify_get(user, v);
351
352 if (noti == NULL)
353@@ -2775,6 +2996,7 @@ xs_str *html_notifications(snac *user, int skip, int show)
354 const char *utype = xs_dict_get(noti, "utype");
355 const char *id = xs_dict_get(noti, "objid");
356 const char *date = xs_dict_get(noti, "date");
357+ const char *id2 = xs_dict_get_path(noti, "msg.id");
358 xs *wrk = NULL;
359
360 if (xs_is_null(id))
361@@ -2783,8 +3005,16 @@ xs_str *html_notifications(snac *user, int skip, int show)
362 if (is_hidden(user, id))
363 continue;
364
365+ if (xs_is_string(id2) && xs_set_add(&rep, id2) != 1)
366+ continue;
367+
368 object_get(id, &obj);
369
370+ const char *msg_id = NULL;
371+
372+ if (xs_is_dict(obj))
373+ msg_id = xs_dict_get(obj, "id");
374+
375 const char *actor_id = xs_dict_get(noti, "actor");
376 xs *actor = NULL;
377
378@@ -2817,9 +3047,7 @@ xs_str *html_notifications(snac *user, int skip, int show)
379
380 xs *s_date = xs_crop_i(xs_dup(date), 0, 10);
381
382- xs_html *entry = xs_html_tag("div",
383- xs_html_attr("class", "snac-post-with-desc"),
384- xs_html_tag("p",
385+ xs_html *this_html_label = xs_html_container(
386 xs_html_tag("b",
387 xs_html_text(label),
388 xs_html_text(" by "),
389@@ -2830,13 +3058,45 @@ xs_str *html_notifications(snac *user, int skip, int show)
390 xs_html_tag("time",
391 xs_html_attr("class", "dt-published snac-pubdate"),
392 xs_html_attr("title", date),
393- xs_html_text(s_date))));
394+ xs_html_text(s_date)));
395+
396+ xs_html *html_label = NULL;
397+
398+ if (xs_is_string(msg_id)) {
399+ const xs_val *prev_label = xs_dict_get(admiration_labels, msg_id);
400+
401+ if (xs_type(prev_label) == XSTYPE_DATA) {
402+ /* there is a previous list of admiration labels! */
403+ xs_data_get(&html_label, prev_label);
404+
405+ xs_html_add(html_label,
406+ xs_html_sctag("br", NULL),
407+ this_html_label);
408+
409+ continue;
410+ }
411+ }
412+
413+ xs_html *entry = NULL;
414+
415+ html_label = xs_html_tag("p",
416+ this_html_label);
417+
418+ /* store in the admiration labels dict */
419+ xs *pl = xs_data_new(&html_label, sizeof(html_label));
420+
421+ if (xs_is_string(msg_id))
422+ admiration_labels = xs_dict_set(admiration_labels, msg_id, pl);
423+
424+ entry = xs_html_tag("div",
425+ xs_html_attr("class", "snac-post-with-desc"),
426+ html_label);
427
428 if (strcmp(type, "Follow") == 0 || strcmp(utype, "Follow") == 0 || strcmp(type, "Block") == 0) {
429 xs_html_add(entry,
430 xs_html_tag("div",
431 xs_html_attr("class", "snac-post"),
432- html_actor_icon(user, actor, NULL, NULL, NULL, 0, 0, proxy)));
433+ html_actor_icon(user, actor, NULL, NULL, NULL, 0, 0, proxy, NULL)));
434 }
435 else
436 if (strcmp(type, "Move") == 0) {
437@@ -2850,7 +3110,7 @@ xs_str *html_notifications(snac *user, int skip, int show)
438 xs_html_add(entry,
439 xs_html_tag("div",
440 xs_html_attr("class", "snac-post"),
441- html_actor_icon(user, old_actor, NULL, NULL, NULL, 0, 0, proxy)));
442+ html_actor_icon(user, old_actor, NULL, NULL, NULL, 0, 0, proxy, NULL)));
443 }
444 }
445 }
446@@ -2917,6 +3177,8 @@ xs_str *html_notifications(snac *user, int skip, int show)
447 xs_html_text(L("More...")))));
448 }
449
450+ xs_set_free(&rep);
451+
452 xs_html_add(body,
453 html_footer());
454
455@@ -2970,6 +3232,21 @@ int html_get_handler(const xs_dict *req, const char *q_path,
456 else
457 return HTTP_STATUS_NOT_FOUND;
458 }
459+ else
460+ if (strcmp(v, "auth-int-bridge") == 0) {
461+ const char *login = xs_dict_get(q_vars, "login");
462+ const char *id = xs_dict_get(q_vars, "id");
463+ const char *action = xs_dict_get(q_vars, "action");
464+
465+ if (xs_is_string(login) && xs_is_string(id) && xs_is_string(action)) {
466+ *body = xs_fmt("%s/%s/authorize_interaction?action=%s&id=%s",
467+ srv_baseurl, login, action, id);
468+
469+ return HTTP_STATUS_SEE_OTHER;
470+ }
471+ else
472+ return HTTP_STATUS_NOT_FOUND;
473+ }
474
475 uid = xs_dup(v);
476
477@@ -3541,6 +3818,52 @@ int html_get_handler(const xs_dict *req, const char *q_path,
478 status = HTTP_STATUS_SEE_OTHER;
479 }
480 }
481+ else
482+ if (strcmp(p_path, "authorize_interaction") == 0) { /** follow, like or boost from Mastodon **/
483+ if (!login(&snac, req)) {
484+ *body = xs_dup(uid);
485+ status = HTTP_STATUS_UNAUTHORIZED;
486+ }
487+ else {
488+ status = HTTP_STATUS_NOT_FOUND;
489+
490+ const char *id = xs_dict_get(q_vars, "id");
491+ const char *action = xs_dict_get(q_vars, "action");
492+
493+ if (xs_is_string(id) && xs_is_string(action)) {
494+ if (strcmp(action, "Follow") == 0) {
495+ xs *msg = msg_follow(&snac, id);
496+
497+ if (msg != NULL) {
498+ const char *actor = xs_dict_get(msg, "object");
499+
500+ following_add(&snac, actor, msg);
501+
502+ enqueue_output_by_actor(&snac, msg, actor, 0);
503+
504+ status = HTTP_STATUS_SEE_OTHER;
505+ }
506+ }
507+ else
508+ if (xs_match(action, "Like|Boost|Announce")) {
509+ /* bring the post */
510+ xs *msg = msg_admiration(&snac, id, *action == 'L' ? "Like" : "Announce");
511+
512+ if (msg != NULL) {
513+ enqueue_message(&snac, msg);
514+ timeline_admire(&snac, xs_dict_get(msg, "object"), snac.actor, *action == 'L' ? 1 : 0);
515+
516+ status = HTTP_STATUS_SEE_OTHER;
517+ }
518+ }
519+ }
520+
521+ if (status == HTTP_STATUS_SEE_OTHER) {
522+ *body = xs_fmt("%s/admin", snac.actor);
523+ *b_size = strlen(*body);
524+ }
525+ }
526+ }
527 else
528 status = HTTP_STATUS_NOT_FOUND;
529
530@@ -4024,6 +4347,9 @@ int html_post_handler(const xs_dict *req, const char *q_path,
531 else
532 snac.config = xs_dict_set(snac.config, "show_contact_metrics", xs_stock(XSTYPE_FALSE));
533
534+ snac.config = xs_dict_set(snac.config, "latitude", xs_dict_get_def(p_vars, "latitude", ""));
535+ snac.config = xs_dict_set(snac.config, "longitude", xs_dict_get_def(p_vars, "longitude", ""));
536+
537 if ((v = xs_dict_get(p_vars, "metadata")) != NULL)
538 snac.config = xs_dict_set(snac.config, "metadata", v);
539
540@@ -4140,6 +4466,35 @@ int html_post_handler(const xs_dict *req, const char *q_path,
541
542 status = HTTP_STATUS_SEE_OTHER;
543 }
544+ else
545+ if (p_path && strcmp(p_path, "admin/followed-hashtags") == 0) { /** **/
546+ const char *followed_hashtags = xs_dict_get(p_vars, "followed_hashtags");
547+
548+ if (xs_is_string(followed_hashtags)) {
549+ xs *new_hashtags = xs_list_new();
550+ xs *l = xs_split(followed_hashtags, "\n");
551+ const char *v;
552+
553+ xs_list_foreach(l, v) {
554+ xs *s1 = xs_strip_i(xs_dup(v));
555+ s1 = xs_replace_i(s1, " ", "");
556+
557+ if (*s1 == '\0')
558+ continue;
559+
560+ xs *s2 = xs_utf8_to_lower(s1);
561+ if (*s2 != '#')
562+ s2 = xs_str_prepend_i(s2, "#");
563+
564+ new_hashtags = xs_list_append(new_hashtags, s2);
565+ }
566+
567+ snac.config = xs_dict_set(snac.config, "followed_hashtags", new_hashtags);
568+ user_persist(&snac, 0);
569+ }
570+
571+ status = HTTP_STATUS_SEE_OTHER;
572+ }
573
574 if (status == HTTP_STATUS_SEE_OTHER) {
575 const char *redir = xs_dict_get(p_vars, "redir");
M
http.c
+1,
-1
1@@ -1,5 +1,5 @@
2 /* snac - A simple, minimalistic ActivityPub instance */
3-/* copyright (c) 2022 - 2024 grunfink et al. / MIT license */
4+/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */
5
6 #include "xs.h"
7 #include "xs_io.h"
M
httpd.c
+48,
-6
1@@ -1,5 +1,5 @@
2 /* snac - A simple, minimalistic ActivityPub instance */
3-/* copyright (c) 2022 - 2024 grunfink et al. / MIT license */
4+/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */
5
6 #include "xs.h"
7 #include "xs_io.h"
8@@ -138,7 +138,7 @@ static xs_str *greeting_html(void)
9 while (xs_list_iter(&p, &uid)) {
10 snac user;
11
12- if (user_open(&user, uid)) {
13+ if (strcmp(uid, "relay") && user_open(&user, uid)) {
14 xs_html_add(ul,
15 xs_html_tag("li",
16 xs_html_tag("a",
17@@ -182,6 +182,29 @@ const char *share_page = ""
18 "";
19
20
21+const char *authorize_interaction_page = ""
22+"<!DOCTYPE html>\n"
23+"<html>\n"
24+"<head>\n"
25+"<title>%s - snac</title>\n"
26+"<meta content=\"width=device-width, initial-scale=1, minimum-scale=1, user-scalable=no\" name=\"viewport\">\n"
27+"<link rel=\"stylesheet\" type=\"text/css\" href=\"%s/style.css\"/>\n"
28+"<style>:root {color-scheme: light dark}</style>\n"
29+"</head>\n"
30+"<body><h1>%s authorize interaction</h1>\n"
31+"<form method=\"get\" action=\"%s/auth-int-bridge\">\n"
32+"<select name=\"action\">\n"
33+"<option value=\"Follow\">Follow</option>\n"
34+"<option value=\"Boost\">Boost</option>\n"
35+"<option value=\"Like\">Like</option>\n"
36+"</select> %s\n"
37+"<input type=\"hidden\" name=\"id\" value=\"%s\">\n"
38+"<p>Login: <input type=\"text\" name=\"login\" autocapitalize=\"off\" required=\"required\"></p>\n"
39+"<input type=\"submit\" value=\"OK\">\n"
40+"</form><p>%s</p></body></html>\n"
41+"";
42+
43+
44 int server_get_handler(xs_dict *req, const char *q_path,
45 char **body, int *b_size, char **ctype)
46 /* basic server services */
47@@ -189,7 +212,7 @@ int server_get_handler(xs_dict *req, const char *q_path,
48 int status = 0;
49
50 /* is it the server root? */
51- if (*q_path == '\0') {
52+ if (*q_path == '\0' || strcmp(q_path, "/") == 0) {
53 const xs_dict *q_vars = xs_dict_get(req, "q_vars");
54 const char *t = NULL;
55
56@@ -318,6 +341,25 @@ int server_get_handler(xs_dict *req, const char *q_path,
57 USER_AGENT
58 );
59 }
60+ else
61+ if (strcmp(q_path, "/authorize_interaction") == 0) {
62+ const xs_dict *q_vars = xs_dict_get(req, "q_vars");
63+ const char *uri = xs_dict_get(q_vars, "uri");
64+
65+ if (xs_is_string(uri)) {
66+ status = HTTP_STATUS_OK;
67+ *ctype = "text/html; charset=utf-8";
68+ *body = xs_fmt(authorize_interaction_page,
69+ xs_dict_get(srv_config, "host"),
70+ srv_baseurl,
71+ xs_dict_get(srv_config, "host"),
72+ srv_baseurl,
73+ uri,
74+ uri,
75+ USER_AGENT
76+ );
77+ }
78+ }
79
80 if (status != 0)
81 srv_debug(1, xs_fmt("server_get_handler serving '%s' %d", q_path, status));
82@@ -459,13 +501,13 @@ void httpd_connection(FILE *f)
83 }
84
85 if (status == HTTP_STATUS_FORBIDDEN)
86- body = xs_str_new("<h1>403 Forbidden</h1>");
87+ body = xs_str_new("<h1>403 Forbidden (" USER_AGENT ")</h1>");
88
89 if (status == HTTP_STATUS_NOT_FOUND)
90- body = xs_str_new("<h1>404 Not Found</h1>");
91+ body = xs_str_new("<h1>404 Not Found (" USER_AGENT ")</h1>");
92
93 if (status == HTTP_STATUS_BAD_REQUEST && body != NULL)
94- body = xs_str_new("<h1>400 Bad Request</h1>");
95+ body = xs_str_new("<h1>400 Bad Request (" USER_AGENT ")</h1>");
96
97 if (status == HTTP_STATUS_SEE_OTHER)
98 headers = xs_dict_append(headers, "location", body);
M
main.c
+46,
-28
1@@ -1,11 +1,12 @@
2 /* snac - A simple, minimalistic ActivityPub instance */
3-/* copyright (c) 2022 - 2024 grunfink et al. / MIT license */
4+/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */
5
6 #include "xs.h"
7 #include "xs_io.h"
8 #include "xs_json.h"
9 #include "xs_time.h"
10 #include "xs_openssl.h"
11+#include "xs_match.h"
12
13 #include "snac.h"
14
15@@ -14,7 +15,7 @@
16 int usage(void)
17 {
18 printf("snac " VERSION " - A simple, minimalistic ActivityPub instance\n");
19- printf("Copyright (c) 2022 - 2024 grunfink et al. / MIT license\n");
20+ printf("Copyright (c) 2022 - 2025 grunfink et al. / MIT license\n");
21 printf("\n");
22 printf("Commands:\n");
23 printf("\n");
24@@ -34,6 +35,7 @@ int usage(void)
25 printf("actor {basedir} [{uid}] {url} Requests an actor\n");
26 printf("note {basedir} {uid} {text} [files...] Sends a note with optional attachments\n");
27 printf("note_unlisted {basedir} {uid} {text} [files...] Sends an unlisted note with optional attachments\n");
28+ printf("note_mention {basedir} {uid} {text} [files...] Sends a note only to mentioned accounts\n");
29 printf("boost|announce {basedir} {uid} {url} Boosts (announces) a post\n");
30 printf("unboost {basedir} {uid} {url} Unboosts a post\n");
31 printf("resetpwd {basedir} {uid} Resets the password of a user\n");
32@@ -49,10 +51,10 @@ int usage(void)
33 printf("unlimit {basedir} {uid} {actor} Unlimits an actor\n");
34 printf("verify_links {basedir} {uid} Verifies a user's links (in the metadata)\n");
35 printf("search {basedir} {uid} {regex} Searches posts by content\n");
36- printf("export_csv {basedir} {uid} Exports data as CSV files into current directory\n");
37+ printf("export_csv {basedir} {uid} Exports data as CSV files\n");
38 printf("alias {basedir} {uid} {account} Sets account (@user@host or actor url) as an alias\n");
39 printf("migrate {basedir} {uid} Migrates to the account defined as the alias\n");
40- printf("import_csv {basedir} {uid} Imports data from CSV files in the current directory\n");
41+ printf("import_csv {basedir} {uid} Imports data from CSV files\n");
42 printf("import_list {basedir} {uid} {file} Imports a Mastodon CSV list file\n");
43 printf("import_block_list {basedir} {uid} {file} Imports a Mastodon CSV block list file\n");
44
45@@ -94,19 +96,6 @@ int main(int argc, char *argv[])
46 return snac_init(basedir);
47 }
48
49- if (strcmp(cmd, "upgrade") == 0) { /** **/
50- int ret;
51-
52- /* upgrade */
53- if ((basedir = GET_ARGV()) == NULL)
54- return usage();
55-
56- if ((ret = srv_open(basedir, 1)) == 1)
57- srv_log(xs_dup("OK"));
58-
59- return ret;
60- }
61-
62 if (strcmp(cmd, "markdown") == 0) { /** **/
63 /* undocumented, for testing only */
64 xs *c = xs_readall(stdin);
65@@ -116,8 +105,20 @@ int main(int argc, char *argv[])
66 return 0;
67 }
68
69- if ((basedir = GET_ARGV()) == NULL)
70- return usage();
71+ if ((basedir = getenv("SNAC_BASEDIR")) == NULL) {
72+ if ((basedir = GET_ARGV()) == NULL)
73+ return usage();
74+ }
75+
76+ if (strcmp(cmd, "upgrade") == 0) { /** **/
77+ int ret;
78+
79+ /* upgrade */
80+ if ((ret = srv_open(basedir, 1)) == 1)
81+ srv_log(xs_dup("OK"));
82+
83+ return ret;
84+ }
85
86 if (!srv_open(basedir, 0)) {
87 srv_log(xs_fmt("error opening data storage at %s", basedir));
88@@ -351,6 +352,22 @@ int main(int argc, char *argv[])
89 return 0;
90 }
91
92+
93+ if (strcmp(cmd, "assist") == 0) { /** **/
94+ /* undocumented: experimental (do not use) */
95+ xs *msg = msg_admiration(&snac, url, "Accept");
96+
97+ if (msg != NULL) {
98+ enqueue_message(&snac, msg);
99+
100+ if (dbglevel) {
101+ xs_json_dump(msg, 4, stdout);
102+ }
103+ }
104+
105+ return 0;
106+ }
107+
108 if (strcmp(cmd, "unboost") == 0) { /** **/
109 xs *msg = msg_repulsion(&snac, url, "Announce");
110
111@@ -604,7 +621,9 @@ int main(int argc, char *argv[])
112 return 0;
113 }
114
115- if (strcmp(cmd, "note") == 0 || strcmp(cmd, "note_unlisted") == 0) { /** **/
116+ if (strcmp(cmd, "note") == 0 || /** **/
117+ strcmp(cmd, "note_unlisted") == 0 || /** **/
118+ strcmp(cmd, "note_mention") == 0) { /** **/
119 xs *content = NULL;
120 xs *msg = NULL;
121 xs *c_msg = NULL;
122@@ -668,15 +687,14 @@ int main(int argc, char *argv[])
123 else
124 content = xs_dup(url);
125
126- msg = msg_note(&snac, content, NULL, NULL, attl, 0, getenv("LANG"));
127+ int scope = 0;
128+ if (strcmp(cmd, "note_mention") == 0)
129+ scope = 1;
130+ else
131+ if (strcmp(cmd, "note_unlisted") == 0)
132+ scope = 2;
133
134- if (strcmp(cmd, "note_unlisted") == 0) {
135- /* according to Mastodon, "unlisted" posts (now called "quiet public")
136- has the public address as a cc instead of to, so toggle it */
137- xs *to = xs_dup(xs_dict_get(msg, "to"));
138- msg = xs_dict_set(msg, "cc", to);
139- msg = xs_dict_set(msg, "to", xs_stock(XSTYPE_LIST));
140- }
141+ msg = msg_note(&snac, content, NULL, NULL, attl, scope, getenv("LANG"));
142
143 c_msg = msg_create(&snac, msg);
144
+52,
-24
1@@ -1,5 +1,5 @@
2 /* snac - A simple, minimalistic ActivityPub instance */
3-/* copyright (c) 2022 - 2024 grunfink et al. / MIT license */
4+/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */
5
6 #ifndef NO_MASTODON_API
7
8@@ -1339,6 +1339,9 @@ xs_list *mastoapi_timeline(snac *user, const xs_dict *args, const char *index_fn
9 const char *since_id = xs_dict_get(args, "since_id");
10 const char *min_id = xs_dict_get(args, "min_id"); /* unsupported old-to-new navigation */
11 const char *limit_s = xs_dict_get(args, "limit");
12+ int (*iterator)(FILE *, char *);
13+ int initial_status = 0;
14+ int ascending = 0;
15 int limit = 0;
16 int cnt = 0;
17
18@@ -1348,27 +1351,40 @@ xs_list *mastoapi_timeline(snac *user, const xs_dict *args, const char *index_fn
19 if (limit == 0)
20 limit = 20;
21
22- if (index_desc_first(f, md5, 0)) {
23+ if (min_id) {
24+ iterator = &index_asc_next;
25+ initial_status = index_asc_first(f, md5, MID_TO_MD5(min_id));
26+ ascending = 1;
27+ }
28+ else {
29+ iterator = &index_desc_next;
30+ initial_status = index_desc_first(f, md5, 0);
31+ }
32+
33+ if (initial_status) {
34 do {
35 xs *msg = NULL;
36
37 /* only return entries older that max_id */
38 if (max_id) {
39- if (strcmp(md5, MID_TO_MD5(max_id)) == 0)
40+ if (strcmp(md5, MID_TO_MD5(max_id)) == 0) {
41 max_id = NULL;
42-
43- continue;
44+ if (ascending)
45+ break;
46+ }
47+ if (!ascending)
48+ continue;
49 }
50
51 /* only returns entries newer than since_id */
52 if (since_id) {
53- if (strcmp(md5, MID_TO_MD5(since_id)) == 0)
54- break;
55- }
56-
57- if (min_id) {
58- if (strcmp(md5, MID_TO_MD5(min_id)) == 0)
59- break;
60+ if (strcmp(md5, MID_TO_MD5(since_id)) == 0) {
61+ if (!ascending)
62+ break;
63+ since_id = NULL;
64+ }
65+ if (ascending)
66+ continue;
67 }
68
69 /* get the entry */
70@@ -1428,26 +1444,23 @@ xs_list *mastoapi_timeline(snac *user, const xs_dict *args, const char *index_fn
71 continue;
72 }
73
74- /* if it has a name and it's not a Page or a Video,
75+ /* if it has a name and it's not an object that may have one,
76 it's a poll vote, so discard it */
77- if (!xs_is_null(xs_dict_get(msg, "name")) && !xs_match(type, "Page|Video"))
78+ if (!xs_is_null(xs_dict_get(msg, "name")) && !xs_match(type, "Page|Video|Audio|Event"))
79 continue;
80
81 /* convert the Note into a Mastodon status */
82 xs *st = mastoapi_status(user, msg);
83
84 if (st != NULL) {
85- out = xs_list_append(out, st);
86+ if (ascending)
87+ out = xs_list_insert(out, 0, st);
88+ else
89+ out = xs_list_append(out, st);
90 cnt++;
91 }
92- if (min_id) {
93- while (cnt > limit) {
94- out = xs_list_del(out, 0);
95- cnt--;
96- }
97- }
98
99- } while ((min_id || (cnt < limit)) && index_desc_next(f, md5));
100+ } while ((cnt < limit) && (*iterator)(f, md5));
101 }
102
103 int more = index_desc_next(f, md5);
104@@ -1816,6 +1829,11 @@ int mastoapi_get_handler(const xs_dict *req, const char *q_path,
105 const xs_list *excl = xs_dict_get(args, "exclude_types[]");
106 const char *min_id = xs_dict_get(args, "min_id");
107 const char *max_id = xs_dict_get(args, "max_id");
108+ const char *limit = xs_dict_get(args, "limit");
109+ int limit_count = 0;
110+ if (!xs_is_null(limit)) {
111+ limit_count = atoi(limit);
112+ }
113
114 if (dbglevel) {
115 xs *js = xs_json_dumps(args, 0);
116@@ -1903,6 +1921,10 @@ int mastoapi_get_handler(const xs_dict *req, const char *q_path,
117 }
118
119 out = xs_list_append(out, mn);
120+ if (!xs_is_null(limit)) {
121+ if (--limit_count <= 0)
122+ break;
123+ }
124 }
125
126 srv_debug(1, xs_fmt("mastoapi_notifications count %d", xs_list_len(out)));
127@@ -2650,8 +2672,14 @@ int mastoapi_post_handler(const xs_dict *req, const char *q_path,
128 }
129
130 /* prepare the message */
131- xs *msg = msg_note(&snac, content, NULL, irt, attach_list,
132- strcmp(visibility, "public") == 0 ? 0 : 1, language);
133+ int scope = 1;
134+ if (strcmp(visibility, "unlisted") == 0)
135+ scope = 2;
136+ else
137+ if (strcmp(visibility, "public") == 0)
138+ scope = 0;
139+
140+ xs *msg = msg_note(&snac, content, NULL, irt, attach_list, scope, language);
141
142 if (!xs_is_null(summary) && *summary) {
143 msg = xs_dict_set(msg, "sensitive", xs_stock(XSTYPE_TRUE));
+8,
-1
1@@ -71,15 +71,22 @@ LL_BEGIN(sbox_enter_linux_, const char* basedir, const char *address, int smail)
2 LANDLOCK_ACCESS_FS_REFER_COMPAT,
3 s = LANDLOCK_ACCESS_FS_MAKE_SOCK,
4 x = LANDLOCK_ACCESS_FS_EXECUTE;
5+ char *resolved_path = NULL;
6
7 LL_PATH(basedir, rf|rd|w|c);
8 LL_PATH("/tmp", rf|rd|w|c);
9 #ifndef WITHOUT_SHM
10 LL_PATH("/dev/shm", rf|w|c );
11 #endif
12+ LL_PATH("/dev/urandom", rf );
13 LL_PATH("/etc/resolv.conf", rf );
14 LL_PATH("/etc/hosts", rf );
15- LL_PATH("/etc/ssl", rf );
16+ LL_PATH("/etc/ssl", rf|rd );
17+ if ((resolved_path = realpath("/etc/ssl/cert.pem", NULL))) {
18+ /* some distros like cert.pem to be a symlink */
19+ LL_PATH(resolved_path, rf );
20+ free(resolved_path);
21+ }
22 LL_PATH("/usr/share/zoneinfo", rf );
23
24 if (mtime("/etc/pki") > 0)
M
snac.c
+1,
-1
1@@ -1,5 +1,5 @@
2 /* snac - A simple, minimalistic ActivityPub instance */
3-/* copyright (c) 2022 - 2024 grunfink et al. / MIT license */
4+/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */
5
6 #define XS_IMPLEMENTATION
7
M
snac.h
+5,
-3
1@@ -1,7 +1,7 @@
2 /* snac - A simple, minimalistic ActivityPub instance */
3-/* copyright (c) 2022 - 2024 grunfink et al. / MIT license */
4+/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */
5
6-#define VERSION "2.68-dev"
7+#define VERSION "2.70-dev"
8
9 #define USER_AGENT "snac/" VERSION
10
11@@ -108,6 +108,8 @@ int index_len(const char *fn);
12 xs_list *index_list(const char *fn, int max);
13 int index_desc_next(FILE *f, char md5[MD5_HEX_SIZE]);
14 int index_desc_first(FILE *f, char md5[MD5_HEX_SIZE], int skip);
15+int index_asc_next(FILE *f, char md5[MD5_HEX_SIZE]);
16+int index_asc_first(FILE *f, char md5[MD5_HEX_SIZE], const char *seek_md5);
17 xs_list *index_list_desc(const char *fn, int skip, int show);
18
19 int object_add(const char *id, const xs_dict *obj);
20@@ -317,7 +319,7 @@ xs_dict *msg_follow(snac *snac, const char *actor);
21
22 xs_dict *msg_note(snac *snac, const xs_str *content, const xs_val *rcpts,
23 const xs_str *in_reply_to, const xs_list *attach,
24- int priv, const char *lang);
25+ int scope, const char *lang);
26
27 xs_dict *msg_undo(snac *snac, const xs_val *object);
28 xs_dict *msg_delete(snac *snac, const char *id);
+1,
-1
1@@ -1,5 +1,5 @@
2 /* snac - A simple, minimalistic ActivityPub instance */
3-/* copyright (c) 2022 - 2024 grunfink et al. / MIT license */
4+/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */
5
6 #include "xs.h"
7 #include "xs_io.h"
M
utils.c
+7,
-3
1@@ -1,5 +1,5 @@
2 /* snac - A simple, minimalistic ActivityPub instance */
3-/* copyright (c) 2022 - 2024 grunfink et al. / MIT license */
4+/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */
5
6 #include "xs.h"
7 #include "xs_io.h"
8@@ -98,7 +98,7 @@ static const char *greeting_html =
9 "<html><head>\n"
10 "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"/>\n"
11 "<link rel=\"icon\" type=\"image/x-icon\" href=\"https://%host%/favicon.ico\"/>\n"
12- "<title>Welcome to %host%</title>\n"
13+ "<title>Welcome to %host%</title>\n</head>\n"
14 "<body style=\"margin: auto; max-width: 50em\">\n"
15 "%blurb%"
16 "<p>The following users are part of this community:</p>\n"
17@@ -319,6 +319,10 @@ int adduser(const char *uid)
18 mkdirx(d);
19 }
20
21+ /* add a specially short data retention time for the relay */
22+ if (strcmp(uid, "relay") == 0)
23+ config = xs_dict_set(config, "purge_days", xs_stock(1));
24+
25 xs *cfn = xs_fmt("%s/user.json", basedir);
26
27 if ((f = fopen(cfn, "w")) == NULL) {
28@@ -331,7 +335,7 @@ int adduser(const char *uid)
29 }
30
31 printf("\nCreating RSA key...\n");
32- key = xs_evp_genkey(4096);
33+ key = xs_evp_genkey(2048);
34 printf("Done.\n");
35
36 xs *kfn = xs_fmt("%s/key.json", basedir);
+1,
-1
1@@ -1,5 +1,5 @@
2 /* snac - A simple, minimalistic ActivityPub instance */
3-/* copyright (c) 2022 - 2024 grunfink et al. / MIT license */
4+/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */
5
6 #include "xs.h"
7 #include "xs_json.h"
M
xs.h
+18,
-8
1@@ -1,4 +1,4 @@
2-/* copyright (c) 2022 - 2024 grunfink et al. / MIT license */
3+/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */
4
5 #ifndef _XS_H
6
7@@ -157,6 +157,9 @@ unsigned int xs_hash_func(const char *data, int size);
8 #define xs_is_true(v) (xs_type((v)) == XSTYPE_TRUE)
9 #define xs_is_false(v) (xs_type((v)) == XSTYPE_FALSE)
10 #define xs_not(v) xs_stock(xs_is_true((v)) ? XSTYPE_FALSE : XSTYPE_TRUE)
11+#define xs_is_string(v) (xs_type((v)) == XSTYPE_STRING)
12+#define xs_is_list(v) (xs_type((v)) == XSTYPE_LIST)
13+#define xs_is_dict(v) (xs_type((v)) == XSTYPE_DICT)
14
15 #define xs_list_foreach(l, v) for (int ct_##__LINE__ = 0; xs_list_next(l, &v, &ct_##__LINE__); )
16 #define xs_dict_foreach(l, k, v) for (int ct_##__LINE__ = 0; xs_dict_next(l, &k, &v, &ct_##__LINE__); )
17@@ -623,15 +626,14 @@ int xs_between(const char *prefix, const char *str, const char *suffix)
18 xs_str *xs_crop_i(xs_str *str, int start, int end)
19 /* crops the string to be only from start to end */
20 {
21- XS_ASSERT_TYPE(str, XSTYPE_STRING);
22-
23 int sz = strlen(str);
24
25 if (end <= 0)
26 end = sz + end;
27
28 /* crop from the top */
29- str[end] = '\0';
30+ if (end > 0 && end < sz)
31+ str[end] = '\0';
32
33 /* crop from the bottom */
34 str = xs_collapse(str, 0, start);
35@@ -1061,14 +1063,15 @@ xs_keyval *xs_keyval_make(xs_keyval *keyval, const xs_str *key, const xs_val *va
36
37 typedef struct {
38 int value_offset; /* offset to value (from dict start) */
39- int next; /* next node in sequential search */
40+ int next; /* next node in sequential scanning */
41 int child[4]; /* child nodes in hashed search */
42 char key[]; /* C string key */
43 } ditem_hdr;
44
45 typedef struct {
46 int size; /* size of full dict (_XS_TYPE_SIZE) */
47- int first; /* first node for sequential search */
48+ int first; /* first node for sequential scanning */
49+ int last; /* last node for sequential scanning */
50 int root; /* root node for hashed search */
51 /* a bunch of ditem_hdr and value follows */
52 } dict_hdr;
53@@ -1153,8 +1156,15 @@ xs_dict *xs_dict_set(xs_dict *dict, const xs_str *key, const xs_val *value)
54 memcpy(dict + di->value_offset, value, vsz);
55
56 /* chain to the sequential list */
57- di->next = dh->first;
58- dh->first = end;
59+ if (dh->first == 0)
60+ dh->first = end;
61+ else {
62+ /* chain this new element to the last one */
63+ ditem_hdr *dil = (ditem_hdr *)(dict + dh->last);
64+ dil->next = end;
65+ }
66+
67+ dh->last = end;
68 }
69 else {
70 /* ditem already exists */
+1,
-1
1@@ -1,4 +1,4 @@
2-/* copyright (c) 2022 - 2024 grunfink et al. / MIT license */
3+/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */
4
5 #ifndef _XS_CURL_H
6
+1,
-1
1@@ -1,4 +1,4 @@
2-/* copyright (c) 2022 - 2024 grunfink et al. / MIT license */
3+/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */
4
5 /*
6 This is an intentionally-dead-simple FastCGI implementation;
+1,
-1
1@@ -1,4 +1,4 @@
2-/* copyright (c) 2022 - 2024 grunfink et al. / MIT license */
3+/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */
4
5 #ifndef _XS_GLOB_H
6
M
xs_hex.h
+1,
-1
1@@ -1,4 +1,4 @@
2-/* copyright (c) 2022 - 2024 grunfink et al. / MIT license */
3+/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */
4
5 #ifndef _XS_HEX_H
6
+1,
-1
1@@ -1,4 +1,4 @@
2-/* copyright (c) 2022 - 2024 grunfink et al. / MIT license */
3+/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */
4
5 #ifndef _XS_HTML_H
6
+1,
-1
1@@ -1,4 +1,4 @@
2-/* copyright (c) 2022 - 2024 grunfink et al. / MIT license */
3+/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */
4
5 #ifndef _XS_HTTPD_H
6
M
xs_io.h
+1,
-1
1@@ -1,4 +1,4 @@
2-/* copyright (c) 2022 - 2024 grunfink et al. / MIT license */
3+/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */
4
5 #ifndef _XS_IO_H
6
+1,
-1
1@@ -1,4 +1,4 @@
2-/* copyright (c) 2022 - 2024 grunfink et al. / MIT license */
3+/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */
4
5 #ifndef _XS_JSON_H
6
+1,
-1
1@@ -1,4 +1,4 @@
2-/* copyright (c) 2022 - 2024 grunfink et al. / MIT license */
3+/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */
4
5 #ifndef _XS_MATCH_H
6
+1,
-1
1@@ -1,4 +1,4 @@
2-/* copyright (c) 2022 - 2024 grunfink et al. / MIT license */
3+/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */
4
5 #ifndef _XS_MIME_H
6
+1,
-1
1@@ -1,4 +1,4 @@
2-/* copyright (c) 2022 - 2024 grunfink et al. / MIT license */
3+/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */
4
5 #ifndef _XS_OPENSSL_H
6
+1,
-1
1@@ -1,4 +1,4 @@
2-/* copyright (c) 2022 - 2024 grunfink et al. / MIT license */
3+/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */
4
5 #ifndef _XS_RANDOM_H
6
+1,
-1
1@@ -1,4 +1,4 @@
2-/* copyright (c) 2022 - 2024 grunfink et al. / MIT license */
3+/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */
4
5 #ifndef _XS_REGEX_H
6
M
xs_set.h
+1,
-1
1@@ -1,4 +1,4 @@
2-/* copyright (c) 2022 - 2024 grunfink et al. / MIT license */
3+/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */
4
5 #ifndef _XS_SET_H
6
+1,
-1
1@@ -1,4 +1,4 @@
2-/* copyright (c) 2022 - 2024 grunfink et al. / MIT license */
3+/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */
4
5 #ifndef _XS_SOCKET_H
6
+1,
-1
1@@ -1,4 +1,4 @@
2-/* copyright (c) 2022 - 2024 grunfink et al. / MIT license */
3+/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */
4
5 #ifndef _XS_TIME_H
6
+1,
-1
1@@ -1,4 +1,4 @@
2-/* copyright (c) 2022 - 2024 grunfink et al. / MIT license */
3+/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */
4
5 #ifndef _XS_UNICODE_H
6
+1,
-1
1@@ -1,4 +1,4 @@
2-/* copyright (c) 2022 - 2024 grunfink et al. / MIT license */
3+/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */
4
5 #ifndef _XS_UNIX_SOCKET_H
6
M
xs_url.h
+24,
-1
1@@ -1,10 +1,11 @@
2-/* copyright (c) 2022 - 2024 grunfink et al. / MIT license */
3+/* copyright (c) 2022 - 2025 grunfink et al. / MIT license */
4
5 #ifndef _XS_URL_H
6
7 #define _XS_URL_H
8
9 xs_str *xs_url_dec(const char *str);
10+xs_str *xs_url_enc(const char *str);
11 xs_dict *xs_url_vars(const char *str);
12 xs_dict *xs_multipart_form_data(const char *payload, int p_size, const char *header);
13
14@@ -39,6 +40,28 @@ xs_str *xs_url_dec(const char *str)
15 }
16
17
18+xs_str *xs_url_enc(const char *str)
19+/* URL-encodes a string (RFC 3986) */
20+{
21+ xs_str *s = xs_str_new(NULL);
22+
23+ while (*str) {
24+ if (isalnum(*str) || strchr("-._~", *str)) {
25+ s = xs_append_m(s, str, 1);
26+ }
27+ else {
28+ char tmp[8];
29+ snprintf(tmp, sizeof(tmp), "%%%02X", (unsigned char)*str);
30+ s = xs_append_m(s, tmp, 3);
31+ }
32+
33+ str++;
34+ }
35+
36+ return s;
37+}
38+
39+
40 xs_dict *xs_url_vars(const char *str)
41 /* parse url variables */
42 {
+1,
-1
1@@ -1 +1 @@
2-/* 297f71e198be7819213e9122e1e78c3b963111bc 2024-11-24T18:48:42+01:00 */
3+/* b865e89769aedfdbc61251e94451e9d37579f52e 2025-01-12T16:17:47+01:00 */