Skip to main content

max / makenotwork

10.2 KB · 331 lines History Blame Raw
1 //! RSS feed endpoints. No CSRF, no sessions.
2
3 use crate::extractors::ValidatedQuery;
4 use axum::{
5 extract::{Path, State},
6 response::{IntoResponse, Response},
7 routing::get,
8 };
9 use serde::Deserialize;
10 use sqlx::PgPool;
11
12 use crate::{
13 AppState,
14 config::Config,
15 constants,
16 csrf::CsrfRouter,
17 db::{self, Slug, UserId, Username},
18 error::{AppError, Result},
19 helpers,
20 rss::{self, FeedItem},
21 };
22
23 /// Register RSS feed routes. All GET-only; returned as a `CsrfRouter` so the
24 /// page-tree aggregator only ever composes posture-declared routers (a bare
25 /// `Router` can no longer be merged in).
26 pub(super) fn feed_routes() -> CsrfRouter<AppState> {
27 CsrfRouter::new()
28 .route_get("/u/{username}/rss", get(user_rss_feed))
29 .route_get("/p/{slug}/rss", get(project_rss_feed))
30 .route_get("/p/{slug}/blog/feed.xml", get(project_blog_rss))
31 .route_get("/changelog/feed.xml", get(changelog_rss))
32 .route_get("/feed/{user_id}", get(personal_feed))
33 }
34
35 /// Render an RSS 2.0 feed for a creator's public items.
36 ///
37 /// GET /u/{username}/rss
38 #[tracing::instrument(skip_all, name = "feeds::user_rss_feed")]
39 async fn user_rss_feed(
40 State(db): State<PgPool>,
41 State(config): State<Config>,
42 Path(username): Path<String>,
43 ) -> Result<Response> {
44 let username = Username::new(&username).map_err(|_| AppError::NotFound)?;
45 let db_user = db::users::get_user_by_username(&db, &username)
46 .await?
47 .ok_or(AppError::NotFound)?;
48
49 if db_user.is_sandbox {
50 return Err(AppError::NotFound);
51 }
52
53 // Single joined query instead of O(projects) loop
54 let db_items = db::items::get_public_items_by_user(&db, db_user.id).await?;
55
56 let feed_items: Vec<FeedItem> = db_items
57 .into_iter()
58 .map(|item| FeedItem {
59 title: item.title,
60 link: format!("{}/i/{}", config.host_url, item.id),
61 description: item.description.unwrap_or_default(),
62 pub_date: item.created_at,
63 guid: item.id.to_string(),
64 })
65 .collect();
66
67 let display_name = db_user.display_name.as_deref().unwrap_or(&db_user.username);
68 let bio = db_user.bio.as_deref().unwrap_or("");
69
70 let xml = rss::render_creator_feed(
71 display_name,
72 &db_user.username,
73 bio,
74 &feed_items,
75 &config.host_url,
76 );
77
78 Ok((
79 [(
80 axum::http::header::CONTENT_TYPE,
81 "application/rss+xml; charset=utf-8",
82 )],
83 xml,
84 )
85 .into_response())
86 }
87
88 /// Render an RSS 2.0 feed for a project's public items.
89 ///
90 /// GET /p/{slug}/rss
91 #[tracing::instrument(skip_all, name = "feeds::project_rss_feed")]
92 async fn project_rss_feed(
93 State(db): State<PgPool>,
94 State(config): State<Config>,
95 Path(slug): Path<String>,
96 ) -> Result<Response> {
97 let slug = Slug::new(&slug).map_err(|_| AppError::NotFound)?;
98 let db_project = db::projects::get_public_project_by_slug(&db, &slug)
99 .await?
100 .ok_or(AppError::NotFound)?;
101
102 let db_user = db::users::get_user_by_id(&db, db_project.user_id)
103 .await?
104 .ok_or(AppError::NotFound)?;
105
106 if db_user.is_sandbox {
107 return Err(AppError::NotFound);
108 }
109
110 let db_items = db::items::get_public_items_by_project(&db, db_project.id).await?;
111
112 let feed_items: Vec<FeedItem> = db_items
113 .into_iter()
114 .map(|item| FeedItem {
115 title: item.title,
116 link: format!("{}/i/{}", config.host_url, item.id),
117 description: item.description.unwrap_or_default(),
118 pub_date: item.created_at,
119 guid: item.id.to_string(),
120 })
121 .collect();
122
123 let xml = rss::render_project_feed(
124 &db_project.title,
125 &db_project.slug,
126 db_project.description.as_deref().unwrap_or(""),
127 &db_user.username,
128 &feed_items,
129 &config.host_url,
130 );
131
132 Ok((
133 [(
134 axum::http::header::CONTENT_TYPE,
135 "application/rss+xml; charset=utf-8",
136 )],
137 xml,
138 )
139 .into_response())
140 }
141
142 /// RSS feed for a project's blog posts.
143 #[tracing::instrument(skip_all, name = "feeds::project_blog_rss")]
144 async fn project_blog_rss(
145 State(db): State<PgPool>,
146 State(config): State<Config>,
147 Path(slug): Path<String>,
148 ) -> Result<Response> {
149 let slug = Slug::new(&slug).map_err(|_| AppError::NotFound)?;
150 let db_project = db::projects::get_public_project_by_slug(&db, &slug)
151 .await?
152 .ok_or(AppError::NotFound)?;
153
154 let db_user = db::users::get_user_by_id(&db, db_project.user_id)
155 .await?
156 .ok_or(AppError::NotFound)?;
157
158 if db_user.is_sandbox {
159 return Err(AppError::NotFound);
160 }
161
162 let db_posts = db::blog_posts::get_published_blog_posts_by_project(&db, db_project.id).await?;
163
164 let feed_items: Vec<FeedItem> = db_posts
165 .into_iter()
166 .map(|post| FeedItem {
167 title: post.title,
168 link: format!(
169 "{}/p/{}/blog/{}",
170 config.host_url, db_project.slug, post.slug
171 ),
172 description: post.body_markdown.chars().take(300).collect::<String>(),
173 pub_date: post.published_at.unwrap_or(post.created_at),
174 guid: post.id.to_string(),
175 })
176 .collect();
177
178 let xml = rss::render_blog_feed(
179 &db_project.title,
180 &db_project.slug,
181 db_project.description.as_deref().unwrap_or(""),
182 &db_user.username,
183 &feed_items,
184 &config.host_url,
185 );
186
187 Ok((
188 [(
189 axum::http::header::CONTENT_TYPE,
190 "application/rss+xml; charset=utf-8",
191 )],
192 xml,
193 )
194 .into_response())
195 }
196
197 /// Platform changelog RSS feed (alias for the "changelog" project blog feed).
198 ///
199 /// Mirrors the `/changelog` page alias in `blog.rs`: same project lookup, same
200 /// rendering, so subscribers can follow the canonical `/changelog/feed.xml`
201 /// path instead of discovering the underlying `/p/changelog/blog/feed.xml`.
202 #[tracing::instrument(skip_all, name = "feeds::changelog_rss")]
203 async fn changelog_rss(State(db): State<PgPool>, State(config): State<Config>) -> Result<Response> {
204 // `from_trusted` is safe here because CHANGELOG_PROJECT_SLUG is a
205 // compile-time constant (`&'static str`), not user input.
206 let slug = Slug::from_trusted(constants::CHANGELOG_PROJECT_SLUG.to_owned());
207 let db_project = db::projects::get_public_project_by_slug(&db, &slug)
208 .await?
209 .ok_or(AppError::NotFound)?;
210
211 let db_user = db::users::get_user_by_id(&db, db_project.user_id)
212 .await?
213 .ok_or(AppError::NotFound)?;
214
215 if db_user.is_sandbox {
216 return Err(AppError::NotFound);
217 }
218
219 let db_posts = db::blog_posts::get_published_blog_posts_by_project(&db, db_project.id).await?;
220
221 let feed_items: Vec<FeedItem> = db_posts
222 .into_iter()
223 .map(|post| FeedItem {
224 title: post.title,
225 link: format!("{}/changelog/{}", config.host_url, post.slug),
226 description: post.body_markdown.chars().take(300).collect::<String>(),
227 pub_date: post.published_at.unwrap_or(post.created_at),
228 guid: post.id.to_string(),
229 })
230 .collect();
231
232 let xml = rss::render_blog_feed(
233 &db_project.title,
234 &db_project.slug,
235 db_project.description.as_deref().unwrap_or(""),
236 &db_user.username,
237 &feed_items,
238 &config.host_url,
239 );
240
241 Ok((
242 [(
243 axum::http::header::CONTENT_TYPE,
244 "application/rss+xml; charset=utf-8",
245 )],
246 xml,
247 )
248 .into_response())
249 }
250
251 /// Query parameters for the personal feed.
252 #[derive(Debug, Deserialize)]
253 struct FeedQuery {
254 sig: String,
255 /// Feed key version the URL was signed for. Defaults to 0 for URLs minted
256 /// before per-user versioning existed; the handler still rejects it unless
257 /// it matches the user's current `feed_key_version`.
258 #[serde(default)]
259 v: i32,
260 }
261
262 /// Personalized RSS feed of items from followed users and projects.
263 ///
264 /// GET /feed/{user_id}?sig={hmac_signature}
265 ///
266 /// Auth is via HMAC signature in the query string, so RSS readers
267 /// can fetch the feed without cookies or headers.
268 #[tracing::instrument(skip_all, name = "feeds::personal_feed")]
269 async fn personal_feed(
270 State(db): State<PgPool>,
271 State(config): State<Config>,
272 Path(user_id): Path<UserId>,
273 ValidatedQuery(query): ValidatedQuery<FeedQuery>,
274 ) -> Result<Response> {
275 // Verify HMAC signature (cheap, no DB) before touching the database.
276 if !helpers::verify_feed_signature(user_id, query.v, &query.sig, &config.signing_secret) {
277 return Err(AppError::Forbidden);
278 }
279
280 // Verify the user exists
281 let db_user = db::users::get_user_by_id(&db, user_id)
282 .await?
283 .ok_or(AppError::NotFound)?;
284
285 // Sandbox accounts are hidden from every public feed surface (the other four
286 // feed handlers reject them); keep this one consistent.
287 if db_user.is_sandbox {
288 return Err(AppError::NotFound);
289 }
290
291 // Enforce key rotation: a signature valid for a stale version is a revoked
292 // URL (the user hit "Regenerate feed URL"). The HMAC above proves the `v`
293 // wasn't forged; this proves it's still current.
294 if query.v != db_user.feed_key_version {
295 return Err(AppError::Forbidden);
296 }
297
298 // Get items from followed users and projects
299 let db_items = db::follows::get_followed_items(&db, user_id).await?;
300
301 let feed_items: Vec<FeedItem> = db_items
302 .into_iter()
303 .map(|item| FeedItem {
304 title: item.title,
305 link: format!("{}/i/{}", config.host_url, item.id),
306 description: item.description.unwrap_or_default(),
307 pub_date: item.created_at,
308 guid: item.id.to_string(),
309 })
310 .collect();
311
312 let display_name = db_user.display_name.as_deref().unwrap_or(&db_user.username);
313 let title = format!("{display_name}'s Feed");
314
315 let xml = rss::render_feed_custom(
316 &title,
317 &format!("{}/feed/{}", config.host_url, user_id),
318 "New content from creators and projects you follow",
319 &feed_items,
320 );
321
322 Ok((
323 [(
324 axum::http::header::CONTENT_TYPE,
325 "application/rss+xml; charset=utf-8",
326 )],
327 xml,
328 )
329 .into_response())
330 }
331