learn and share notes on atproto (wip) 馃 malfestio.stormlightlabs.org/
readability solid axum atproto srs
5
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 345 lines 12 kB view raw
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}