2.88
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_html_add(channel,
105 xs_html_tag("item",
106 xs_html_tag("title",
107 xs_html_text(title)),
108 xs_html_tag("link",
109 xs_html_text(id)),
110 xs_html_tag("guid",
111 xs_html_text(id)),
112 xs_html_tag("pubDate",
113 xs_html_text(rss_date)),
114 xs_html_tag("description",
115 xs_html_text(content))));
116
117 cnt++;
118 }
119
120 return xs_html_render_s(rss, "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
121}
122
123
124void rss_to_timeline(snac *user, const char *url)
125/* reads an RSS and inserts all ActivityPub posts into the user's timeline */
126{
127 if (!xs_startswith(url, "https:/") && !xs_startswith(url, "http:/"))
128 return;
129
130 xs *hdrs = xs_dict_new();
131 hdrs = xs_dict_set(hdrs, "accept", "application/rss+xml");
132 hdrs = xs_dict_set(hdrs, "user-agent", USER_AGENT);
133
134 /* get the RSS metadata */
135 xs *md5 = xs_md5_hex(url, strlen(url));
136 xs *rss_md_fn = xs_fmt("%s/rss", user->basedir);
137 mkdirx(rss_md_fn);
138 rss_md_fn = xs_str_cat(rss_md_fn, "/", md5, ".json");
139
140 xs *rss_md = NULL;
141 const char *etag = NULL;
142
143 FILE *f;
144 if ((f = fopen(rss_md_fn, "r")) != NULL) {
145 rss_md = xs_json_load(f);
146 fclose(f);
147
148 etag = xs_dict_get(rss_md, "etag");
149
150 if (xs_is_string(etag))
151 hdrs = xs_dict_set(hdrs, "if-none-match", etag);
152 }
153
154 if (rss_md == NULL)
155 rss_md = xs_dict_new();
156
157 xs *payload = NULL;
158 int status;
159 int p_size;
160
161 xs *rsp = xs_http_request("GET", url, hdrs, NULL, 0, &status, &payload, &p_size, 0);
162
163 snac_log(user, xs_fmt("parsing RSS %s %d", url, status));
164
165 if (!valid_status(status) || !xs_is_string(payload))
166 return;
167
168 /* not an RSS? done */
169 const char *ctype = xs_dict_get(rsp, "content-type");
170 if (!xs_is_string(ctype) || xs_str_in(ctype, "application/rss+xml") == -1)
171 return;
172
173 /* yes, parsing is done with regexes (now I have two problems blah blah blah) */
174 xs *links = xs_regex_select(payload, "<link>[^<]+</link>");
175 const char *link;
176
177 xs_list_foreach(links, link) {
178 xs *l = xs_replace(link, "<link>", "");
179 char *p = strchr(l, '<');
180
181 if (p == NULL)
182 continue;
183 *p = '\0';
184
185 /* skip this same URL */
186 if (strcmp(l, url) == 0)
187 continue;
188
189 /* skip crap */
190 if (!xs_startswith(l, "https:/") && !xs_startswith(l, "http:/"))
191 continue;
192
193 snac_debug(user, 1, xs_fmt("RSS link: %s", l));
194
195 if (timeline_here(user, l)) {
196 snac_debug(user, 1, xs_fmt("RSS entry already in timeline %s", l));
197 continue;
198 }
199
200 /* special trick for Mastodon: convert from the alternate format */
201 if (strchr(l, '@') != NULL) {
202 xs *l2 = xs_split(l, "/");
203
204 if (xs_list_len(l2) == 5) {
205 const char *uid = xs_list_get(l2, 3);
206 if (*uid == '@') {
207 xs *guessed_id = xs_fmt("https:/" "/%s/users/%s/statuses/%s",
208 xs_list_get(l2, 2), uid + 1, xs_list_get(l2, -1));
209
210 if (timeline_here(user, guessed_id)) {
211 snac_debug(user, 1, xs_fmt("RSS entry already in timeline (alt) %s", guessed_id));
212 continue;
213 }
214 }
215 }
216 }
217
218 xs *obj = NULL;
219
220 if (!valid_status(object_get(l, &obj))) {
221 /* object is not here: bring it */
222 if (!valid_status(activitypub_request(user, l, &obj)))
223 continue;
224 }
225
226 if (xs_is_dict(obj)) {
227 const char *id = xs_dict_get(obj, "id");
228 const char *type = xs_dict_get(obj, "type");
229 const char *attr_to = get_atto(obj);
230
231 if (!xs_is_string(id) || !xs_is_string(type) || !xs_is_string(attr_to))
232 continue;
233
234 if (!xs_match(type, POSTLIKE_OBJECT_TYPE))
235 continue;
236
237 if (timeline_here(user, id)) {
238 snac_debug(user, 1, xs_fmt("RSS entry already in timeline (id) %s", id));
239 continue;
240 }
241
242 enqueue_actor_refresh(user, attr_to, 0);
243
244 timeline_add(user, id, obj);
245
246 snac_log(user, xs_fmt("new '%s' (RSS) %s %s", type, attr_to, id));
247 }
248 }
249
250 /* update the RSS metadata */
251 etag = xs_dict_get(rsp, "etag");
252
253 if (xs_is_string(etag)) {
254 rss_md = xs_dict_set(rss_md, "etag", etag);
255 rss_md = xs_dict_set(rss_md, "url", url);
256 if ((f = fopen(rss_md_fn, "w")) != NULL) {
257 xs_json_dump(rss_md, 4, f);
258 fclose(f);
259 }
260 }
261}
262
263
264void rss_poll_hashtags(void)
265/* parses all RSS from all users */
266{
267 xs *list = user_list();
268 const char *uid;
269
270 xs_list_foreach(list, uid) {
271 snac user;
272
273 if (user_open(&user, uid)) {
274 const xs_list *rss = xs_dict_get(user.config, "followed_hashtags");
275
276 if (xs_is_list(rss)) {
277 const char *url;
278
279 xs_list_foreach(rss, url)
280 rss_to_timeline(&user, url);
281 }
282
283 user_free(&user);
284 }
285 }
286}