Skip to main content

max / makenotwork

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