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