2.91
rss.c
1/* snac - A simple, minimalistic ActivityPub instance */
2/* copyright (c) 2025 - 2026 grunfink et al. / MIT license */
3
4#include "xs.h"
5#include "xs_html.h"
6#include "xs_regex.h"
7#include "xs_time.h"
8#include "xs_match.h"
9#include "xs_curl.h"
10#include "xs_openssl.h"
11#include "xs_json.h"
12#include "xs_http.h"
13#include "xs_unicode.h"
14
15#include "snac.h"
16
17xs_str *rss_from_timeline(snac *user, const xs_list *timeline,
18 const char *title, const char *link, const char *desc)
19/* converts a timeline to rss */
20{
21 xs_html *rss = xs_html_tag("rss",
22 xs_html_attr("xmlns:content", "http:/" "/purl.org/rss/1.0/modules/content/"),
23 xs_html_attr("version", "2.0"),
24 xs_html_attr("xmlns:atom", "http:/" "/www.w3.org/2005/Atom"));
25
26 xs_html *channel = xs_html_tag("channel",
27 xs_html_tag("title",
28 xs_html_text(title)),
29 xs_html_tag("language",
30 xs_html_text("en")),
31 xs_html_tag("link",
32 xs_html_text(link)),
33 xs_html_sctag("atom:link",
34 xs_html_attr("href", link),
35 xs_html_attr("rel", "self"),
36 xs_html_attr("type", "application/rss+xml")),
37 xs_html_tag("generator",
38 xs_html_text(USER_AGENT)),
39 xs_html_tag("description",
40 xs_html_text(desc)));
41
42 xs_html_add(rss, channel);
43
44 int cnt = 0;
45 const char *v;
46
47 xs_list_foreach(timeline, v) {
48 xs *msg = NULL;
49
50 if (user) {
51 if (!valid_status(timeline_get_by_md5(user, v, &msg)))
52 continue;
53 }
54 else {
55 if (!valid_status(object_get_by_md5(v, &msg)))
56 continue;
57 }
58
59 const char *id = xs_dict_get(msg, "id");
60 const char *content = xs_dict_get(msg, "content");
61 const char *published = xs_dict_get(msg, "published");
62
63 if (user && !is_msg_mine(user, id))
64 continue;
65
66 if (!id || !content || !published)
67 continue;
68
69 if (!is_msg_public(msg))
70 continue;
71
72 /* create a title with the first line of the content */
73 xs *title = xs_replace(content, "<br>", "\n");
74 title = xs_regex_replace_i(title, "<[^>]+>", " ");
75 title = xs_regex_replace_i(title, "&[^;]+;", " ");
76 int i;
77
78 for (i = 0; title[i] && title[i] != '\n' && i < 50; ) {
79 const char *p = &title[i];
80 unsigned int cp = xs_utf8_dec(&p);
81 int n = p - title;
82 if (cp == 0xfffd || n > 50)
83 break;
84 i = n;
85 }
86
87 if (title[i] != '\0') {
88 title[i] = '\0';
89 title = xs_str_cat(title, "...");
90 }
91
92 title = xs_strip_i(title);
93
94 /* convert the date */
95 time_t t = xs_parse_iso_date(published, 0);
96 xs *rss_date = xs_str_utctime(t, "%a, %d %b %Y %T +0000");
97
98 /* if it's the first one, add it to the header */
99 if (cnt == 0)
100 xs_html_add(channel,
101 xs_html_tag("lastBuildDate",
102 xs_html_text(rss_date)));
103
104 xs *att_content = xs_dup(content);
105 xs *attach_list = get_attachments(msg);
106
107 /* add possible attachments */
108 if (xs_list_len(attach_list)) {
109 const xs_val *a;
110 xs_html *content_attachments = xs_html_tag("div",
111 xs_html_attr("class", "snac-content-attachments"));
112
113 xs_list_foreach(attach_list, a) {
114 const char *type = xs_dict_get(a, "type");
115 const char *o_href = xs_dict_get(a, "href");
116 const char *name = xs_dict_get_def(a, "name", "");
117
118 if (!xs_is_string(type) || !xs_is_string(o_href))
119 continue;
120
121 if (xs_startswith(type, "image/")) {
122 xs_html_add(content_attachments,
123 xs_html_tag("figure",
124 xs_html_sctag("img",
125 xs_html_attr("src", o_href)),
126 xs_html_tag("figcaption",
127 xs_html_text(name))));
128 }
129 else {
130 xs_html_add(content_attachments,
131 xs_html_tag("a",
132 xs_html_attr("href", o_href),
133 xs_html_text(name),
134 xs_html_text(" "),
135 xs_html_text(o_href),
136 xs_html_sctag("br", NULL)));
137 }
138 }
139
140 xs *s1 = xs_html_render(content_attachments);
141 att_content = xs_str_cat(att_content, s1);
142 }
143
144 xs_html_add(channel,
145 xs_html_tag("item",
146 xs_html_tag("title",
147 xs_html_text(title)),
148 xs_html_tag("link",
149 xs_html_text(id)),
150 xs_html_tag("guid",
151 xs_html_text(id)),
152 xs_html_tag("pubDate",
153 xs_html_text(rss_date)),
154 xs_html_tag("description",
155 xs_html_text(att_content))));
156
157 cnt++;
158 }
159
160 return xs_html_render_s(rss, "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
161}
162
163
164void rss_to_timeline(snac *user, const char *url)
165/* reads an RSS and inserts all ActivityPub posts into the user's timeline */
166{
167 if (!xs_startswith(url, "https:/") && !xs_startswith(url, "http:/"))
168 return;
169
170 xs *hdrs = xs_dict_new();
171 hdrs = xs_dict_set(hdrs, "accept", "application/rss+xml");
172 hdrs = xs_dict_set(hdrs, "user-agent", USER_AGENT);
173
174 /* get the RSS metadata */
175 xs *md5 = xs_md5_hex(url, strlen(url));
176 xs *rss_md_fn = xs_fmt("%s/rss", user->basedir);
177 mkdirx(rss_md_fn);
178 rss_md_fn = xs_str_cat(rss_md_fn, "/", md5, ".json");
179
180 xs *rss_md = NULL;
181 const char *etag = NULL;
182
183 FILE *f;
184 if ((f = fopen(rss_md_fn, "r")) != NULL) {
185 rss_md = xs_json_load(f);
186 fclose(f);
187
188 etag = xs_dict_get(rss_md, "etag");
189
190 if (xs_is_string(etag))
191 hdrs = xs_dict_set(hdrs, "if-none-match", etag);
192 }
193
194 if (rss_md == NULL)
195 rss_md = xs_dict_new();
196
197 xs *payload = NULL;
198 int status;
199 int p_size;
200
201 xs *rsp = xs_http_request("GET", url, hdrs, NULL, 0, &status, &payload, &p_size, 0);
202
203 snac_log(user, xs_fmt("parsing RSS %s %d", url, status));
204
205 if (!valid_status(status) || !xs_is_string(payload))
206 return;
207
208 /* not an RSS? done */
209 const char *ctype = xs_dict_get(rsp, "content-type");
210 if (!xs_is_string(ctype) || xs_str_in(ctype, "application/rss+xml") == -1)
211 return;
212
213 /* yes, parsing is done with regexes (now I have two problems blah blah blah) */
214 xs *links = xs_regex_select(payload, "<link>[^<]+</link>");
215 const char *link;
216
217 xs_list_foreach(links, link) {
218 xs *l = xs_replace(link, "<link>", "");
219 char *p = strchr(l, '<');
220
221 if (p == NULL)
222 continue;
223 *p = '\0';
224
225 /* skip this same URL */
226 if (strcmp(l, url) == 0)
227 continue;
228
229 /* skip crap */
230 if (!xs_startswith(l, "https:/") && !xs_startswith(l, "http:/"))
231 continue;
232
233 snac_debug(user, 1, xs_fmt("RSS link: %s", l));
234
235 if (timeline_here(user, l)) {
236 snac_debug(user, 1, xs_fmt("RSS entry already in timeline %s", l));
237 continue;
238 }
239
240 /* special trick for Mastodon: convert from the alternate format */
241 if (strchr(l, '@') != NULL) {
242 xs *l2 = xs_split(l, "/");
243
244 if (xs_list_len(l2) == 5) {
245 const char *uid = xs_list_get(l2, 3);
246 if (*uid == '@') {
247 xs *guessed_id = xs_fmt("https:/" "/%s/users/%s/statuses/%s",
248 xs_list_get(l2, 2), uid + 1, xs_list_get(l2, -1));
249
250 if (timeline_here(user, guessed_id)) {
251 snac_debug(user, 1, xs_fmt("RSS entry already in timeline (alt) %s", guessed_id));
252 continue;
253 }
254 }
255 }
256 }
257
258 xs *obj = NULL;
259
260 if (!valid_status(object_get(l, &obj))) {
261 /* object is not here: bring it */
262 if (!valid_status(activitypub_request(user, l, &obj)))
263 continue;
264 }
265
266 if (xs_is_dict(obj)) {
267 const char *id = xs_dict_get(obj, "id");
268 const char *type = xs_dict_get(obj, "type");
269 const char *attr_to = get_atto(obj);
270
271 if (!xs_is_string(id) || !xs_is_string(type) || !xs_is_string(attr_to))
272 continue;
273
274 if (!xs_match(type, POSTLIKE_OBJECT_TYPE))
275 continue;
276
277 if (timeline_here(user, id)) {
278 snac_debug(user, 1, xs_fmt("RSS entry already in timeline (id) %s", id));
279 continue;
280 }
281
282 enqueue_actor_refresh(user, attr_to, 0);
283
284 timeline_add(user, id, obj);
285
286 snac_log(user, xs_fmt("new '%s' (RSS) %s %s", type, attr_to, id));
287 }
288 }
289
290 /* update the RSS metadata */
291 etag = xs_dict_get(rsp, "etag");
292
293 if (xs_is_string(etag)) {
294 rss_md = xs_dict_set(rss_md, "etag", etag);
295 rss_md = xs_dict_set(rss_md, "url", url);
296 if ((f = fopen(rss_md_fn, "w")) != NULL) {
297 xs_json_dump(rss_md, 4, f);
298 fclose(f);
299 }
300 }
301}
302
303
304void rss_poll_hashtags(void)
305/* parses all RSS from all users */
306{
307 xs *list = user_list();
308 const char *uid;
309
310 xs_list_foreach(list, uid) {
311 snac user;
312
313 if (user_open(&user, uid)) {
314 const xs_list *rss = xs_dict_get(user.config, "followed_hashtags");
315
316 if (xs_is_list(rss)) {
317 const char *url;
318
319 xs_list_foreach(rss, url)
320 rss_to_timeline(&user, url);
321 }
322
323 user_free(&user);
324 }
325 }
326}