Skip to main content

max / makenotwork

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