learn and share notes on atproto (wip) 馃
malfestio.stormlightlabs.org/
readability
solid
axum
atproto
srs
1use crate::middleware::auth::UserContext;
2use crate::state::SharedState;
3
4use crate::repository::deck::{CreateDeckParams, DeckRepoError, UpdateDeckParams};
5use axum::{
6 Json,
7 extract::{Extension, Path, Query, State},
8 http::StatusCode,
9 response::IntoResponse,
10};
11use malfestio_core::model::Visibility;
12use serde::Deserialize;
13use serde_json::json;
14
15#[derive(Deserialize)]
16pub struct CreateDeckRequest {
17 title: String,
18 description: String,
19 tags: Vec<String>,
20 visibility: Visibility,
21}
22
23#[derive(Deserialize)]
24pub struct PublishDeckRequest {
25 pub published: bool,
26}
27
28pub async fn create_deck(
29 State(state): State<SharedState>, ctx: Option<axum::Extension<UserContext>>, Json(payload): Json<CreateDeckRequest>,
30) -> impl IntoResponse {
31 let user = match ctx {
32 Some(axum::Extension(user)) => user,
33 None => return (StatusCode::UNAUTHORIZED, Json(json!({"error": "Unauthorized"}))).into_response(),
34 };
35
36 let params = CreateDeckParams {
37 owner_did: user.did,
38 title: payload.title,
39 description: payload.description,
40 tags: payload.tags,
41 visibility: payload.visibility,
42 };
43
44 match state.deck_repo.create(params).await {
45 Ok(deck) => (StatusCode::CREATED, Json(deck)).into_response(),
46 Err(e) => {
47 tracing::error!("Failed to create deck: {:?}", e);
48 (
49 StatusCode::INTERNAL_SERVER_ERROR,
50 Json(json!({"error": "Failed to create deck"})),
51 )
52 .into_response()
53 }
54 }
55}
56
57#[derive(Deserialize)]
58pub struct RemoteDeckQuery {
59 uri: String,
60}
61
62pub async fn fetch_remote_deck(
63 State(state): State<SharedState>, Query(query): Query<RemoteDeckQuery>,
64) -> impl IntoResponse {
65 match state.deck_repo.get_remote_deck(&query.uri).await {
66 Ok((deck, cards)) => Json(json!({ "deck": deck, "cards": cards })).into_response(),
67 Err(DeckRepoError::NotFound(_)) => {
68 (StatusCode::NOT_FOUND, Json(json!({"error": "Deck not found"}))).into_response()
69 }
70 Err(e) => {
71 tracing::error!("Failed to fetch remote deck: {:?}", e);
72 (
73 StatusCode::INTERNAL_SERVER_ERROR,
74 Json(json!({"error": "Failed to fetch remote deck"})),
75 )
76 .into_response()
77 }
78 }
79}
80
81pub async fn list_decks(
82 State(state): State<SharedState>, ctx: Option<axum::Extension<UserContext>>,
83) -> impl IntoResponse {
84 let user_did = ctx.map(|Extension(u)| u.did);
85
86 match state.deck_repo.list_visible(user_did.as_deref()).await {
87 Ok(decks) => Json(decks).into_response(),
88 Err(e) => {
89 tracing::error!("Failed to list decks: {:?}", e);
90 (
91 StatusCode::INTERNAL_SERVER_ERROR,
92 Json(json!({"error": "Failed to list decks"})),
93 )
94 .into_response()
95 }
96 }
97}
98
99pub async fn get_deck(
100 State(state): State<SharedState>, ctx: Option<axum::Extension<UserContext>>, Path(id): Path<String>,
101) -> impl IntoResponse {
102 let user_did = ctx.map(|Extension(u)| u.did);
103
104 match state.deck_repo.get(&id).await {
105 Ok(deck) => {
106 let is_owner = user_did.as_ref() == Some(&deck.owner_did);
107 let has_access = match &deck.visibility {
108 Visibility::Public | Visibility::Unlisted => true,
109 Visibility::Private => is_owner,
110 Visibility::SharedWith(dids) => {
111 is_owner || user_did.as_ref().map(|did| dids.contains(did)).unwrap_or(false)
112 }
113 };
114
115 if !has_access {
116 return (StatusCode::FORBIDDEN, Json(json!({"error": "Access denied"}))).into_response();
117 }
118
119 Json(deck).into_response()
120 }
121 Err(crate::repository::deck::DeckRepoError::NotFound(_)) => {
122 (StatusCode::NOT_FOUND, Json(json!({"error": "Deck not found"}))).into_response()
123 }
124 Err(e) => {
125 tracing::error!("Failed to get deck: {:?}", e);
126 (
127 StatusCode::INTERNAL_SERVER_ERROR,
128 Json(json!({"error": "Failed to retrieve deck"})),
129 )
130 .into_response()
131 }
132 }
133}
134
135pub async fn publish_deck(
136 State(state): State<SharedState>, ctx: Option<axum::Extension<UserContext>>, Path(id): Path<String>,
137 Json(payload): Json<PublishDeckRequest>,
138) -> impl IntoResponse {
139 let user = match ctx {
140 Some(axum::Extension(user)) => user,
141 None => return (StatusCode::UNAUTHORIZED, Json(json!({"error": "Unauthorized"}))).into_response(),
142 };
143
144 let deck = match state.deck_repo.get(&id).await {
145 Ok(d) => d,
146 Err(DeckRepoError::NotFound(_)) => {
147 return (StatusCode::NOT_FOUND, Json(json!({"error": "Deck not found"}))).into_response();
148 }
149 Err(e) => {
150 tracing::error!("Failed to get deck: {:?}", e);
151 return (
152 StatusCode::INTERNAL_SERVER_ERROR,
153 Json(json!({"error": "Failed to fetch deck"})),
154 )
155 .into_response();
156 }
157 };
158
159 if deck.owner_did != user.did {
160 return (StatusCode::FORBIDDEN, Json(json!({"error": "Only owner can publish"}))).into_response();
161 }
162
163 let mut updated_deck = deck.clone();
164 let mut deck_at_uri = None;
165
166 if payload.published {
167 let cards = match state.card_repo.list_by_deck(&id).await {
168 Ok(c) => c,
169 Err(e) => {
170 tracing::error!("Failed to fetch cards: {:?}", e);
171 return (
172 StatusCode::INTERNAL_SERVER_ERROR,
173 Json(json!({"error": "Failed to fetch cards"})),
174 )
175 .into_response();
176 }
177 };
178
179 match crate::pds::publish::publish_deck_to_pds(state.oauth_repo.clone(), &user, &deck, &cards).await {
180 Ok(result) => {
181 deck_at_uri = Some(result.deck_at_uri.clone());
182
183 let params = UpdateDeckParams {
184 deck_id: id.clone(),
185 visibility: Some(Visibility::Public),
186 published_at: Some(chrono::Utc::now().to_rfc3339()),
187 at_uri: Some(result.deck_at_uri.clone()),
188 };
189
190 if let Err(e) = state.deck_repo.update(params).await {
191 tracing::error!("Failed to update deck: {:?}", e);
192 }
193
194 updated_deck.visibility = Visibility::Public;
195 updated_deck.published_at = Some(chrono::Utc::now().to_rfc3339());
196
197 for (i, at_uri) in result.card_at_uris.iter().enumerate() {
198 if i < cards.len()
199 && let Err(e) = state.card_repo.update_at_uri(&cards[i].id, at_uri).await
200 {
201 tracing::warn!("Failed to store card AT-URI: {:?}", e);
202 }
203 }
204 }
205 Err(e) => {
206 tracing::error!("Failed to publish to PDS: {}", e);
207 return (
208 StatusCode::SERVICE_UNAVAILABLE,
209 Json(json!({"error": format!("Failed to publish to PDS: {}", e)})),
210 )
211 .into_response();
212 }
213 }
214 } else {
215 let params = UpdateDeckParams {
216 deck_id: id.clone(),
217 visibility: Some(Visibility::Private),
218 published_at: None,
219 at_uri: None,
220 };
221 if let Err(e) = state.deck_repo.update(params).await {
222 tracing::error!("Failed to update deck: {:?}", e);
223 return (
224 StatusCode::INTERNAL_SERVER_ERROR,
225 Json(json!({"error": "Failed to update deck"})),
226 )
227 .into_response();
228 }
229 updated_deck.visibility = Visibility::Private;
230 updated_deck.published_at = None;
231 }
232
233 if let Some(at_uri) = deck_at_uri {
234 Json(json!({
235 "deck": updated_deck,
236 "at_uri": at_uri
237 }))
238 .into_response()
239 } else {
240 Json(updated_deck).into_response()
241 }
242}
243
244pub async fn fork_deck(
245 State(state): State<SharedState>, ctx: Option<axum::Extension<UserContext>>, Path(id): Path<String>,
246) -> impl IntoResponse {
247 let user = match ctx {
248 Some(axum::Extension(user)) => user,
249 None => return (StatusCode::UNAUTHORIZED, Json(json!({"error": "Unauthorized"}))).into_response(),
250 };
251
252 match state.deck_repo.fork(&id, &user.did).await {
253 Ok(deck) => (StatusCode::CREATED, Json(deck)).into_response(),
254 Err(crate::repository::deck::DeckRepoError::NotFound(_)) => {
255 (StatusCode::NOT_FOUND, Json(json!({"error": "Deck not found"}))).into_response()
256 }
257 Err(crate::repository::deck::DeckRepoError::AccessDenied(_)) => (
258 StatusCode::FORBIDDEN,
259 Json(json!({"error": "Cannot fork private deck"})),
260 )
261 .into_response(),
262 Err(e) => {
263 tracing::error!("Failed to fork deck: {:?}", e);
264 (
265 StatusCode::INTERNAL_SERVER_ERROR,
266 Json(json!({"error": "Failed to fork deck"})),
267 )
268 .into_response()
269 }
270 }
271}
272
273#[cfg(test)]
274mod tests {
275 use super::*;
276 use crate::middleware::auth::UserContext;
277 use crate::state::AppState;
278 use axum::extract::{Extension, State};
279 use axum::http::StatusCode;
280 use axum::response::IntoResponse;
281 use malfestio_core::model::Visibility;
282 use std::sync::Arc;
283
284 #[tokio::test]
285 async fn test_create_deck_success() {
286 let pool = crate::db::create_pool("postgres://postgres:postgres@localhost:5432/malfestio").unwrap();
287
288 let state = AppState::new_with_repos(
289 pool,
290 Arc::new(crate::repository::card::mock::MockCardRepository::new()),
291 Arc::new(crate::repository::note::mock::MockNoteRepository::new()),
292 Arc::new(crate::repository::oauth::mock::MockOAuthRepository::new()),
293 );
294
295 let user = UserContext {
296 did: "did:plc:alice".to_string(),
297 handle: "alice.bsky.social".to_string(),
298 access_token: "test_token".to_string(),
299 pds_url: "https://bsky.social".to_string(),
300 has_dpop: false,
301 };
302
303 let payload = CreateDeckRequest {
304 title: "My New Deck".to_string(),
305 description: "A test deck".to_string(),
306 tags: vec!["rust".to_string()],
307 visibility: Visibility::Public,
308 };
309
310 let response = create_deck(State(state), Some(Extension(user)), Json(payload))
311 .await
312 .into_response();
313
314 assert_eq!(response.status(), StatusCode::CREATED);
315
316 let body_bytes = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap();
317 let body_json: serde_json::Value = serde_json::from_slice(&body_bytes).unwrap();
318
319 assert_eq!(body_json["title"], "My New Deck");
320 assert_eq!(body_json["owner_did"], "did:plc:alice");
321 assert_eq!(body_json["visibility"]["type"], "Public");
322 }
323
324 #[tokio::test]
325 async fn test_create_deck_unauthorized() {
326 let pool = crate::db::create_pool("postgres://postgres:postgres@localhost:5432/malfestio").unwrap();
327 let state = AppState::new_with_repos(
328 pool,
329 Arc::new(crate::repository::card::mock::MockCardRepository::new()),
330 Arc::new(crate::repository::note::mock::MockNoteRepository::new()),
331 Arc::new(crate::repository::oauth::mock::MockOAuthRepository::new()),
332 );
333
334 let payload = CreateDeckRequest {
335 title: "My New Deck".to_string(),
336 description: "A test deck".to_string(),
337 tags: vec![],
338 visibility: Visibility::Public,
339 };
340
341 let response = create_deck(State(state), None, Json(payload)).await.into_response();
342
343 assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
344 }
345}