Skip to main content

max / makenotwork

7.1 KB · 251 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 db::{self, Slug, UserId, Username},
13 error::{AppError, Result},
14 helpers,
15 rss::{self, FeedItem},
16 AppState,
17 };
18
19 /// Register RSS feed routes.
20 pub fn feed_routes() -> Router<AppState> {
21 Router::new()
22 .route("/u/{username}/rss", get(user_rss_feed))
23 .route("/p/{slug}/rss", get(project_rss_feed))
24 .route("/p/{slug}/blog/feed.xml", get(project_blog_rss))
25 .route("/feed/{user_id}", get(personal_feed))
26 }
27
28 /// Render an RSS 2.0 feed for a creator's public items.
29 ///
30 /// GET /u/{username}/rss
31 #[tracing::instrument(skip_all, name = "feeds::user_rss_feed")]
32 async fn user_rss_feed(
33 State(state): State<AppState>,
34 Path(username): Path<String>,
35 ) -> Result<Response> {
36 let username = Username::new(&username).map_err(|_| AppError::NotFound)?;
37 let db_user = db::users::get_user_by_username(&state.db, &username)
38 .await?
39 .ok_or(AppError::NotFound)?;
40
41 if db_user.is_sandbox {
42 return Err(AppError::NotFound);
43 }
44
45 // Single joined query instead of O(projects) loop
46 let db_items = db::items::get_public_items_by_user(&state.db, db_user.id).await?;
47
48 let feed_items: Vec<FeedItem> = db_items
49 .into_iter()
50 .map(|item| FeedItem {
51 title: item.title,
52 link: format!("{}/i/{}", state.config.host_url, item.id),
53 description: item.description.unwrap_or_default(),
54 pub_date: item.created_at,
55 guid: item.id.to_string(),
56 })
57 .collect();
58
59 let display_name = db_user
60 .display_name
61 .as_deref()
62 .unwrap_or(&db_user.username);
63 let bio = db_user.bio.as_deref().unwrap_or("");
64
65 let xml = rss::render_creator_feed(
66 display_name,
67 &db_user.username,
68 bio,
69 &feed_items,
70 &state.config.host_url,
71 );
72
73 Ok((
74 [(
75 axum::http::header::CONTENT_TYPE,
76 "application/rss+xml; charset=utf-8",
77 )],
78 xml,
79 )
80 .into_response())
81 }
82
83 /// Render an RSS 2.0 feed for a project's public items.
84 ///
85 /// GET /p/{slug}/rss
86 #[tracing::instrument(skip_all, name = "feeds::project_rss_feed")]
87 async fn project_rss_feed(
88 State(state): State<AppState>,
89 Path(slug): Path<String>,
90 ) -> Result<Response> {
91 let slug = Slug::new(&slug).map_err(|_| AppError::NotFound)?;
92 let db_project = db::projects::get_public_project_by_slug(&state.db, &slug)
93 .await?
94 .ok_or(AppError::NotFound)?;
95
96 let db_user = db::users::get_user_by_id(&state.db, db_project.user_id)
97 .await?
98 .ok_or(AppError::NotFound)?;
99
100 if db_user.is_sandbox {
101 return Err(AppError::NotFound);
102 }
103
104 let db_items = db::items::get_public_items_by_project(&state.db, db_project.id).await?;
105
106 let feed_items: Vec<FeedItem> = db_items
107 .into_iter()
108 .map(|item| FeedItem {
109 title: item.title,
110 link: format!("{}/i/{}", state.config.host_url, item.id),
111 description: item.description.unwrap_or_default(),
112 pub_date: item.created_at,
113 guid: item.id.to_string(),
114 })
115 .collect();
116
117 let xml = rss::render_project_feed(
118 &db_project.title,
119 &db_project.slug,
120 db_project.description.as_deref().unwrap_or(""),
121 &db_user.username,
122 &feed_items,
123 &state.config.host_url,
124 );
125
126 Ok((
127 [(
128 axum::http::header::CONTENT_TYPE,
129 "application/rss+xml; charset=utf-8",
130 )],
131 xml,
132 )
133 .into_response())
134 }
135
136 /// RSS feed for a project's blog posts.
137 #[tracing::instrument(skip_all, name = "feeds::project_blog_rss")]
138 async fn project_blog_rss(
139 State(state): State<AppState>,
140 Path(slug): Path<String>,
141 ) -> Result<Response> {
142 let slug = Slug::new(&slug).map_err(|_| AppError::NotFound)?;
143 let db_project = db::projects::get_public_project_by_slug(&state.db, &slug)
144 .await?
145 .ok_or(AppError::NotFound)?;
146
147 let db_user = db::users::get_user_by_id(&state.db, db_project.user_id)
148 .await?
149 .ok_or(AppError::NotFound)?;
150
151 if db_user.is_sandbox {
152 return Err(AppError::NotFound);
153 }
154
155 let db_posts = db::blog_posts::get_published_blog_posts_by_project(&state.db, db_project.id).await?;
156
157 let feed_items: Vec<FeedItem> = db_posts
158 .into_iter()
159 .map(|post| FeedItem {
160 title: post.title,
161 link: format!("{}/p/{}/blog/{}", state.config.host_url, db_project.slug, post.slug),
162 description: post.body_markdown.chars().take(300).collect::<String>(),
163 pub_date: post.published_at.unwrap_or(post.created_at),
164 guid: post.id.to_string(),
165 })
166 .collect();
167
168 let xml = rss::render_blog_feed(
169 &db_project.title,
170 &db_project.slug,
171 db_project.description.as_deref().unwrap_or(""),
172 &db_user.username,
173 &feed_items,
174 &state.config.host_url,
175 );
176
177 Ok((
178 [(
179 axum::http::header::CONTENT_TYPE,
180 "application/rss+xml; charset=utf-8",
181 )],
182 xml,
183 )
184 .into_response())
185 }
186
187 /// Query parameters for the personal feed.
188 #[derive(Debug, Deserialize)]
189 struct FeedQuery {
190 sig: String,
191 }
192
193 /// Personalized RSS feed of items from followed users and projects.
194 ///
195 /// GET /feed/{user_id}?sig={hmac_signature}
196 ///
197 /// Auth is via HMAC signature in the query string, so RSS readers
198 /// can fetch the feed without cookies or headers.
199 #[tracing::instrument(skip_all, name = "feeds::personal_feed")]
200 async fn personal_feed(
201 State(state): State<AppState>,
202 Path(user_id): Path<UserId>,
203 Query(query): Query<FeedQuery>,
204 ) -> Result<Response> {
205 // Verify HMAC signature
206 if !helpers::verify_feed_signature(user_id, &query.sig, &state.config.signing_secret) {
207 return Err(AppError::Forbidden);
208 }
209
210 // Verify the user exists
211 let db_user = db::users::get_user_by_id(&state.db, user_id)
212 .await?
213 .ok_or(AppError::NotFound)?;
214
215 // Get items from followed users and projects
216 let db_items = db::follows::get_followed_items(&state.db, user_id).await?;
217
218 let feed_items: Vec<FeedItem> = db_items
219 .into_iter()
220 .map(|item| FeedItem {
221 title: item.title,
222 link: format!("{}/i/{}", state.config.host_url, item.id),
223 description: item.description.unwrap_or_default(),
224 pub_date: item.created_at,
225 guid: item.id.to_string(),
226 })
227 .collect();
228
229 let display_name = db_user
230 .display_name
231 .as_deref()
232 .unwrap_or(&db_user.username);
233 let title = format!("{}'s Feed", display_name);
234
235 let xml = rss::render_feed_custom(
236 &title,
237 &format!("{}/feed/{}", state.config.host_url, user_id),
238 "New content from creators and projects you follow",
239 &feed_items,
240 );
241
242 Ok((
243 [(
244 axum::http::header::CONTENT_TYPE,
245 "application/rss+xml; charset=utf-8",
246 )],
247 xml,
248 )
249 .into_response())
250 }
251