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.

feat: implement card and note repositories

+1029 -27
+69
AGENTS.md
··· 1 + # AGENTS.md 2 + 3 + This file is the canonical source of truth for AI agents working on this project. It also serves as 4 + a reference for contributors to the project. 5 + 6 + ## Project Overview 7 + 8 + Malfestio is a learning OS combining flashcards, notes, lectures, and articles for daily study built on top of the 9 + AT Protocol. It implements a local-first approach with social features for publishing, sharing, and remixing learning artifacts. 10 + 11 + ## Development Commands 12 + 13 + ### Rust Backend 14 + 15 + ```bash 16 + # Build the workspace 17 + cargo build 18 + 19 + # Run the server via CLI 20 + cargo run --bin malfestio-cli start 21 + 22 + # Run tests 23 + cargo test 24 + 25 + # Run tests for specific crate 26 + cargo test -p malfestio-server 27 + cargo test -p malfestio-core 28 + 29 + # Check without building 30 + cargo check 31 + 32 + # Run clippy lints 33 + cargo clippy 34 + ``` 35 + 36 + ### Frontend (SolidJS) 37 + 38 + ```bash 39 + # Install dependencies 40 + cd web && pnpm install 41 + 42 + # Run development server 43 + pnpm dev 44 + 45 + # Build for production 46 + pnpm build 47 + 48 + # Run tests 49 + pnpm test 50 + 51 + # Type check without building 52 + pnpm check 53 + ``` 54 + 55 + ## Project Structure 56 + 57 + ```sh 58 + # tree 59 + . 60 + ├── crates 61 + │ ├── core 62 + │ ├── server 63 + │ └── cli 64 + └── web 65 + ``` 66 + 67 + ## Rules & Workflows 68 + 69 + - *todo*
+9
Cargo.lock
··· 385 385 ] 386 386 387 387 [[package]] 388 + name = "dotenvy" 389 + version = "0.15.7" 390 + source = "registry+https://github.com/rust-lang/crates.io-index" 391 + checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" 392 + 393 + [[package]] 388 394 name = "encoding_rs" 389 395 version = "0.8.35" 390 396 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1063 1069 version = "0.1.0" 1064 1070 dependencies = [ 1065 1071 "clap", 1072 + "dotenvy", 1066 1073 "malfestio-core", 1067 1074 "malfestio-server", 1068 1075 "tokio", ··· 1082 1089 name = "malfestio-server" 1083 1090 version = "0.1.0" 1084 1091 dependencies = [ 1092 + "async-trait", 1085 1093 "axum", 1086 1094 "chrono", 1087 1095 "deadpool-postgres", 1096 + "dotenvy", 1088 1097 "malfestio-core", 1089 1098 "readability", 1090 1099 "regex",
+1
crates/cli/Cargo.toml
··· 5 5 6 6 [dependencies] 7 7 clap = { version = "4.5.53", features = ["derive"] } 8 + dotenvy = "0.15.7" 8 9 malfestio-core = { version = "0.1.0", path = "../core" } 9 10 malfestio-server = { version = "0.1.0", path = "../server" } 10 11 tokio = { version = "1.48.0", features = ["full"] }
+4 -1
crates/cli/src/main.rs
··· 27 27 28 28 #[tokio::main] 29 29 async fn main() -> malfestio_core::Result<()> { 30 + let _ = dotenvy::from_filename(".env.local"); 31 + let _ = dotenvy::dotenv(); 32 + 30 33 let cli = Cli::parse(); 31 34 32 35 match &cli.command { ··· 49 52 malfestio_core::Error::InvalidArgument("DB_URL not provided via --db-url or DB_URL env var".to_string()) 50 53 })?; 51 54 52 - println!("🔌 Connecting to database..."); 55 + println!("Connecting to database..."); 53 56 let (mut client, connection) = tokio_postgres::connect(&db_url, NoTls) 54 57 .await 55 58 .map_err(|e| malfestio_core::Error::Database(format!("Failed to connect to database: {}", e)))?;
+2
crates/server/Cargo.toml
··· 4 4 edition = "2024" 5 5 6 6 [dependencies] 7 + async-trait = "0.1.83" 7 8 axum = "0.8.8" 8 9 chrono = { version = "0.4.42", features = ["serde"] } 9 10 deadpool-postgres = "0.14.0" 11 + dotenvy = "0.15.7" 10 12 malfestio-core = { version = "0.1.0", path = "../core" } 11 13 readability = "0.3.0" 12 14 regex = "1.12.2"
+144 -11
crates/server/src/api/card.rs
··· 1 1 use crate::middleware::auth::UserContext; 2 + use crate::repository::card::CardRepoError; 2 3 use crate::state::SharedState; 3 4 4 5 use axum::{ ··· 11 12 use serde_json::json; 12 13 13 14 #[derive(Deserialize)] 14 - #[allow(dead_code)] 15 15 pub struct CreateCardRequest { 16 16 deck_id: String, 17 17 front: String, ··· 20 20 } 21 21 22 22 pub async fn create_card( 23 - State(_state): State<SharedState>, _ctx: Option<Extension<UserContext>>, Json(_payload): Json<CreateCardRequest>, 23 + State(state): State<SharedState>, ctx: Option<Extension<UserContext>>, Json(payload): Json<CreateCardRequest>, 24 24 ) -> impl IntoResponse { 25 - // TODO: Implement database-backed card creation 26 - ( 27 - StatusCode::NOT_IMPLEMENTED, 28 - Json(json!({"error": "Card creation not yet implemented with database"})), 29 - ) 30 - .into_response() 25 + let user = match ctx { 26 + Some(Extension(user)) => user, 27 + None => return (StatusCode::UNAUTHORIZED, Json(json!({"error": "Unauthorized"}))).into_response(), 28 + }; 29 + 30 + let result = state 31 + .card_repo 32 + .create( 33 + &user.did, 34 + &payload.deck_id, 35 + &payload.front, 36 + &payload.back, 37 + payload.media_url.as_deref(), 38 + ) 39 + .await; 40 + 41 + match result { 42 + Ok(card) => (StatusCode::CREATED, Json(card)).into_response(), 43 + Err(CardRepoError::NotFound(msg)) => (StatusCode::NOT_FOUND, Json(json!({"error": msg}))).into_response(), 44 + Err(CardRepoError::InvalidArgument(msg)) => { 45 + (StatusCode::BAD_REQUEST, Json(json!({"error": msg}))).into_response() 46 + } 47 + Err(CardRepoError::DatabaseError(msg)) => { 48 + tracing::error!("Database error: {}", msg); 49 + ( 50 + StatusCode::INTERNAL_SERVER_ERROR, 51 + Json(json!({"error": "Failed to create card"})), 52 + ) 53 + .into_response() 54 + } 55 + } 31 56 } 32 57 33 58 pub async fn list_cards( 34 - State(_state): State<SharedState>, _ctx: Option<Extension<UserContext>>, Path(_deck_id): Path<String>, 59 + State(state): State<SharedState>, _ctx: Option<Extension<UserContext>>, Path(deck_id): Path<String>, 35 60 ) -> impl IntoResponse { 36 - // TODO: Implement database-backed card listing 37 - Json(Vec::<serde_json::Value>::new()).into_response() 61 + let result = state.card_repo.list_by_deck(&deck_id).await; 62 + 63 + match result { 64 + Ok(cards) => Json(cards).into_response(), 65 + Err(CardRepoError::NotFound(msg)) => (StatusCode::NOT_FOUND, Json(json!({"error": msg}))).into_response(), 66 + Err(CardRepoError::InvalidArgument(msg)) => { 67 + (StatusCode::BAD_REQUEST, Json(json!({"error": msg}))).into_response() 68 + } 69 + Err(CardRepoError::DatabaseError(msg)) => { 70 + tracing::error!("Database error: {}", msg); 71 + ( 72 + StatusCode::INTERNAL_SERVER_ERROR, 73 + Json(json!({"error": "Failed to retrieve cards"})), 74 + ) 75 + .into_response() 76 + } 77 + } 78 + } 79 + 80 + #[cfg(test)] 81 + mod tests { 82 + use super::*; 83 + use crate::middleware::auth::UserContext; 84 + use crate::repository::card::mock::MockCardRepository; 85 + use crate::state::AppState; 86 + use malfestio_core::model::Card; 87 + use std::sync::Arc; 88 + 89 + fn create_test_state() -> SharedState { 90 + let pool = crate::db::create_pool().unwrap_or_else(|_| panic!("For testing without DB, use mock pool")); 91 + let card_repo = Arc::new(MockCardRepository::new()) as Arc<dyn crate::repository::card::CardRepository>; 92 + let note_repo = Arc::new(crate::repository::note::mock::MockNoteRepository::new()) 93 + as Arc<dyn crate::repository::note::NoteRepository>; 94 + AppState::new_with_repos(pool, card_repo, note_repo) 95 + } 96 + 97 + #[tokio::test] 98 + async fn test_create_card_success() { 99 + let state = create_test_state(); 100 + let user = UserContext { did: "did:plc:test123".to_string(), handle: "test.handle".to_string() }; 101 + 102 + let payload = CreateCardRequest { 103 + deck_id: "550e8400-e29b-41d4-a716-446655440000".to_string(), 104 + front: "Question".to_string(), 105 + back: "Answer".to_string(), 106 + media_url: None, 107 + }; 108 + 109 + let response = create_card(axum::extract::State(state), Some(Extension(user)), Json(payload)) 110 + .await 111 + .into_response(); 112 + 113 + assert_eq!(response.status(), StatusCode::CREATED); 114 + } 115 + 116 + #[tokio::test] 117 + async fn test_create_card_unauthorized() { 118 + let state = create_test_state(); 119 + 120 + let payload = CreateCardRequest { 121 + deck_id: "550e8400-e29b-41d4-a716-446655440000".to_string(), 122 + front: "Question".to_string(), 123 + back: "Answer".to_string(), 124 + media_url: None, 125 + }; 126 + 127 + let response = create_card(axum::extract::State(state), None, Json(payload)) 128 + .await 129 + .into_response(); 130 + 131 + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); 132 + } 133 + 134 + #[tokio::test] 135 + async fn test_list_cards_success() { 136 + let pool = crate::db::create_pool().unwrap_or_else(|_| panic!("For testing without DB, use mock pool")); 137 + 138 + let test_deck_id = "550e8400-e29b-41d4-a716-446655440000".to_string(); 139 + let test_cards = vec![ 140 + Card { 141 + id: "card-1".to_string(), 142 + owner_did: "did:plc:test".to_string(), 143 + deck_id: test_deck_id.clone(), 144 + front: "Q1".to_string(), 145 + back: "A1".to_string(), 146 + media_url: None, 147 + }, 148 + Card { 149 + id: "card-2".to_string(), 150 + owner_did: "did:plc:test".to_string(), 151 + deck_id: test_deck_id.clone(), 152 + front: "Q2".to_string(), 153 + back: "A2".to_string(), 154 + media_url: None, 155 + }, 156 + ]; 157 + 158 + let card_repo = 159 + Arc::new(MockCardRepository::with_cards(test_cards)) as Arc<dyn crate::repository::card::CardRepository>; 160 + let note_repo = Arc::new(crate::repository::note::mock::MockNoteRepository::new()) 161 + as Arc<dyn crate::repository::note::NoteRepository>; 162 + 163 + let state = AppState::new_with_repos(pool, card_repo, note_repo); 164 + 165 + let response = list_cards(axum::extract::State(state), None, Path(test_deck_id)) 166 + .await 167 + .into_response(); 168 + 169 + assert_eq!(response.status(), StatusCode::OK); 170 + } 38 171 }
+240 -14
crates/server/src/api/note.rs
··· 1 1 use crate::middleware::auth::UserContext; 2 + use crate::repository::note::NoteRepoError; 2 3 use crate::state::SharedState; 3 4 4 5 use axum::{ ··· 12 13 use serde_json::json; 13 14 14 15 #[derive(Deserialize)] 15 - #[allow(dead_code)] 16 16 pub struct CreateNoteRequest { 17 17 title: String, 18 18 body: String, ··· 21 21 } 22 22 23 23 pub async fn create_note( 24 - State(_state): State<SharedState>, _ctx: Option<Extension<UserContext>>, Json(_payload): Json<CreateNoteRequest>, 24 + State(state): State<SharedState>, ctx: Option<Extension<UserContext>>, Json(payload): Json<CreateNoteRequest>, 25 25 ) -> impl IntoResponse { 26 - // TODO: Implement database-backed note creation 27 - ( 28 - StatusCode::NOT_IMPLEMENTED, 29 - Json(json!({"error": "Note creation not yet implemented with database"})), 30 - ) 31 - .into_response() 26 + let user = match ctx { 27 + Some(Extension(user)) => user, 28 + None => return (StatusCode::UNAUTHORIZED, Json(json!({"error": "Unauthorized"}))).into_response(), 29 + }; 30 + 31 + let result = state 32 + .note_repo 33 + .create( 34 + &user.did, 35 + &payload.title, 36 + &payload.body, 37 + payload.tags, 38 + payload.visibility, 39 + ) 40 + .await; 41 + 42 + match result { 43 + Ok(note) => (StatusCode::CREATED, Json(note)).into_response(), 44 + Err(NoteRepoError::SerializationError(msg)) => { 45 + tracing::error!("Serialization error: {}", msg); 46 + ( 47 + StatusCode::INTERNAL_SERVER_ERROR, 48 + Json(json!({"error": "Failed to create note"})), 49 + ) 50 + .into_response() 51 + } 52 + Err(NoteRepoError::DatabaseError(msg)) => { 53 + tracing::error!("Database error: {}", msg); 54 + ( 55 + StatusCode::INTERNAL_SERVER_ERROR, 56 + Json(json!({"error": "Failed to create note"})), 57 + ) 58 + .into_response() 59 + } 60 + Err(NoteRepoError::NotFound(msg)) => (StatusCode::NOT_FOUND, Json(json!({"error": msg}))).into_response(), 61 + Err(NoteRepoError::InvalidArgument(msg)) => { 62 + (StatusCode::BAD_REQUEST, Json(json!({"error": msg}))).into_response() 63 + } 64 + } 32 65 } 33 66 34 - pub async fn list_notes(State(_state): State<SharedState>, _ctx: Option<Extension<UserContext>>) -> impl IntoResponse { 35 - // TODO: Implement database-backed note listing 36 - Json(Vec::<serde_json::Value>::new()).into_response() 67 + pub async fn list_notes(State(state): State<SharedState>, ctx: Option<Extension<UserContext>>) -> impl IntoResponse { 68 + let viewer_did = ctx.map(|Extension(u)| u.did); 69 + 70 + let result = state.note_repo.list(viewer_did.as_deref()).await; 71 + 72 + match result { 73 + Ok(notes) => Json(notes).into_response(), 74 + Err(NoteRepoError::SerializationError(msg)) => { 75 + tracing::error!("Serialization error: {}", msg); 76 + ( 77 + StatusCode::INTERNAL_SERVER_ERROR, 78 + Json(json!({"error": "Failed to retrieve notes"})), 79 + ) 80 + .into_response() 81 + } 82 + Err(NoteRepoError::DatabaseError(msg)) => { 83 + tracing::error!("Database error: {}", msg); 84 + ( 85 + StatusCode::INTERNAL_SERVER_ERROR, 86 + Json(json!({"error": "Failed to retrieve notes"})), 87 + ) 88 + .into_response() 89 + } 90 + Err(NoteRepoError::NotFound(msg)) => (StatusCode::NOT_FOUND, Json(json!({"error": msg}))).into_response(), 91 + Err(NoteRepoError::InvalidArgument(msg)) => { 92 + (StatusCode::BAD_REQUEST, Json(json!({"error": msg}))).into_response() 93 + } 94 + } 37 95 } 38 96 39 97 pub async fn get_note( 40 - State(_state): State<SharedState>, _ctx: Option<Extension<UserContext>>, Path(_id): Path<String>, 98 + State(state): State<SharedState>, ctx: Option<Extension<UserContext>>, Path(id): Path<String>, 41 99 ) -> impl IntoResponse { 42 - // TODO: Implement database-backed note retrieval 43 - (StatusCode::NOT_FOUND, Json(json!({"error": "Note not found"}))).into_response() 100 + let viewer_did = ctx.map(|Extension(u)| u.did); 101 + 102 + let result = state.note_repo.get(&id, viewer_did.as_deref()).await; 103 + 104 + match result { 105 + Ok(note) => Json(note).into_response(), 106 + Err(NoteRepoError::NotFound(msg)) => (StatusCode::NOT_FOUND, Json(json!({"error": msg}))).into_response(), 107 + Err(NoteRepoError::InvalidArgument(msg)) => { 108 + if msg.contains("Access denied") { 109 + (StatusCode::FORBIDDEN, Json(json!({"error": msg}))).into_response() 110 + } else { 111 + (StatusCode::BAD_REQUEST, Json(json!({"error": msg}))).into_response() 112 + } 113 + } 114 + Err(NoteRepoError::SerializationError(msg)) => { 115 + tracing::error!("Serialization error: {}", msg); 116 + ( 117 + StatusCode::INTERNAL_SERVER_ERROR, 118 + Json(json!({"error": "Failed to retrieve note"})), 119 + ) 120 + .into_response() 121 + } 122 + Err(NoteRepoError::DatabaseError(msg)) => { 123 + tracing::error!("Database error: {}", msg); 124 + ( 125 + StatusCode::INTERNAL_SERVER_ERROR, 126 + Json(json!({"error": "Failed to retrieve note"})), 127 + ) 128 + .into_response() 129 + } 130 + } 131 + } 132 + 133 + #[cfg(test)] 134 + mod tests { 135 + use super::*; 136 + use crate::middleware::auth::UserContext; 137 + use crate::repository::note::mock::MockNoteRepository; 138 + use crate::state::AppState; 139 + use malfestio_core::model::{Note, Visibility}; 140 + use std::sync::Arc; 141 + 142 + fn create_test_state() -> SharedState { 143 + let pool = crate::db::create_pool().unwrap_or_else(|_| panic!("For testing without DB, use mock pool")); 144 + let card_repo = Arc::new(crate::repository::card::mock::MockCardRepository::new()) 145 + as Arc<dyn crate::repository::card::CardRepository>; 146 + let note_repo = Arc::new(MockNoteRepository::new()) as Arc<dyn crate::repository::note::NoteRepository>; 147 + AppState::new_with_repos(pool, card_repo, note_repo) 148 + } 149 + 150 + #[tokio::test] 151 + async fn test_create_note_success() { 152 + let state = create_test_state(); 153 + let user = UserContext { did: "did:plc:test123".to_string(), handle: "test.handle".to_string() }; 154 + 155 + let payload = CreateNoteRequest { 156 + title: "Test Note".to_string(), 157 + body: "This is a test note".to_string(), 158 + tags: vec!["test".to_string()], 159 + visibility: Visibility::Private, 160 + }; 161 + 162 + let response = create_note(axum::extract::State(state), Some(Extension(user)), Json(payload)) 163 + .await 164 + .into_response(); 165 + 166 + assert_eq!(response.status(), StatusCode::CREATED); 167 + } 168 + 169 + #[tokio::test] 170 + async fn test_create_note_unauthorized() { 171 + let state = create_test_state(); 172 + 173 + let payload = CreateNoteRequest { 174 + title: "Test Note".to_string(), 175 + body: "This is a test note".to_string(), 176 + tags: vec!["test".to_string()], 177 + visibility: Visibility::Private, 178 + }; 179 + 180 + let response = create_note(axum::extract::State(state), None, Json(payload)) 181 + .await 182 + .into_response(); 183 + 184 + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); 185 + } 186 + 187 + #[tokio::test] 188 + async fn test_list_notes_with_visibility_filtering() { 189 + let pool = crate::db::create_pool().unwrap_or_else(|_| panic!("For testing without DB, use mock pool")); 190 + 191 + let test_notes = vec![ 192 + Note { 193 + id: "note-1".to_string(), 194 + owner_did: "did:plc:test".to_string(), 195 + title: "Public Note".to_string(), 196 + body: "Public content".to_string(), 197 + tags: vec![], 198 + visibility: Visibility::Public, 199 + published_at: None, 200 + links: vec![], 201 + }, 202 + Note { 203 + id: "note-2".to_string(), 204 + owner_did: "did:plc:test".to_string(), 205 + title: "Private Note".to_string(), 206 + body: "Private content".to_string(), 207 + tags: vec![], 208 + visibility: Visibility::Private, 209 + published_at: None, 210 + links: vec![], 211 + }, 212 + ]; 213 + 214 + let note_repo = 215 + Arc::new(MockNoteRepository::with_notes(test_notes)) as Arc<dyn crate::repository::note::NoteRepository>; 216 + let card_repo = Arc::new(crate::repository::card::mock::MockCardRepository::new()) 217 + as Arc<dyn crate::repository::card::CardRepository>; 218 + 219 + let state = AppState::new_with_repos(pool, card_repo, note_repo); 220 + 221 + let response = list_notes(axum::extract::State(state.clone()), None) 222 + .await 223 + .into_response(); 224 + 225 + assert_eq!(response.status(), StatusCode::OK); 226 + } 227 + 228 + #[tokio::test] 229 + async fn test_get_note_access_control() { 230 + let pool = crate::db::create_pool().unwrap_or_else(|_| panic!("For testing without DB, use mock pool")); 231 + 232 + let note_id = "test-note-id".to_string(); 233 + let test_notes = vec![Note { 234 + id: note_id.clone(), 235 + owner_did: "did:plc:owner".to_string(), 236 + title: "Private Note".to_string(), 237 + body: "Private content".to_string(), 238 + tags: vec![], 239 + visibility: Visibility::Private, 240 + published_at: None, 241 + links: vec![], 242 + }]; 243 + 244 + let note_repo = 245 + Arc::new(MockNoteRepository::with_notes(test_notes)) as Arc<dyn crate::repository::note::NoteRepository>; 246 + let card_repo = Arc::new(crate::repository::card::mock::MockCardRepository::new()) 247 + as Arc<dyn crate::repository::card::CardRepository>; 248 + 249 + let state = AppState::new_with_repos(pool, card_repo, note_repo); 250 + 251 + let owner = UserContext { did: "did:plc:owner".to_string(), handle: "owner.handle".to_string() }; 252 + 253 + let response = get_note( 254 + axum::extract::State(state.clone()), 255 + Some(Extension(owner)), 256 + Path(note_id.clone()), 257 + ) 258 + .await 259 + .into_response(); 260 + 261 + assert_eq!(response.status(), StatusCode::OK); 262 + 263 + let other_user = UserContext { did: "did:plc:other".to_string(), handle: "other.handle".to_string() }; 264 + let response = get_note(axum::extract::State(state), Some(Extension(other_user)), Path(note_id)) 265 + .await 266 + .into_response(); 267 + 268 + assert_eq!(response.status(), StatusCode::FORBIDDEN); 269 + } 44 270 }
+31
crates/server/src/db.rs
··· 1 1 use deadpool_postgres::{Config, Manager, ManagerConfig, Pool, RecyclingMethod}; 2 + use std::time::Duration; 2 3 use tokio_postgres::NoTls; 3 4 4 5 pub type DbPool = Pool; ··· 25 26 26 27 Ok(Pool::builder(mgr).max_size(16).build()?) 27 28 } 29 + 30 + /// Retry wrapper for getting database connections with exponential backoff 31 + pub async fn get_connection_with_retry( 32 + pool: &DbPool, max_retries: u32, 33 + ) -> Result<deadpool_postgres::Object, deadpool_postgres::PoolError> { 34 + let mut attempts = 0; 35 + let mut delay = Duration::from_millis(100); 36 + 37 + loop { 38 + match pool.get().await { 39 + Ok(conn) => return Ok(conn), 40 + Err(e) if attempts < max_retries => { 41 + attempts += 1; 42 + tracing::warn!( 43 + "Failed to get database connection (attempt {}/{}): {}. Retrying in {:?}...", 44 + attempts, 45 + max_retries, 46 + e, 47 + delay 48 + ); 49 + tokio::time::sleep(delay).await; 50 + delay = delay.saturating_mul(2).min(Duration::from_secs(5)); 51 + } 52 + Err(e) => { 53 + tracing::error!("Failed to get database connection after {} attempts: {}", attempts, e); 54 + return Err(e); 55 + } 56 + } 57 + } 58 + }
+1
crates/server/src/lib.rs
··· 1 1 pub mod api; 2 2 pub mod db; 3 3 pub mod middleware; 4 + pub mod repository; 4 5 pub mod state; 5 6 6 7 use axum::http::Method;
+224
crates/server/src/repository/card.rs
··· 1 + use async_trait::async_trait; 2 + use malfestio_core::model::Card; 3 + 4 + #[derive(Debug)] 5 + pub enum CardRepoError { 6 + DatabaseError(String), 7 + NotFound(String), 8 + InvalidArgument(String), 9 + } 10 + 11 + #[async_trait] 12 + pub trait CardRepository: Send + Sync { 13 + async fn create( 14 + &self, owner_did: &str, deck_id: &str, front: &str, back: &str, media_url: Option<&str>, 15 + ) -> Result<Card, CardRepoError>; 16 + 17 + async fn list_by_deck(&self, deck_id: &str) -> Result<Vec<Card>, CardRepoError>; 18 + 19 + async fn verify_deck_ownership(&self, deck_id: &str, owner_did: &str) -> Result<bool, CardRepoError>; 20 + } 21 + 22 + pub struct DbCardRepository { 23 + pool: crate::db::DbPool, 24 + } 25 + 26 + impl DbCardRepository { 27 + pub fn new(pool: crate::db::DbPool) -> Self { 28 + Self { pool } 29 + } 30 + } 31 + 32 + #[async_trait] 33 + impl CardRepository for DbCardRepository { 34 + async fn create( 35 + &self, owner_did: &str, deck_id: &str, front: &str, back: &str, media_url: Option<&str>, 36 + ) -> Result<Card, CardRepoError> { 37 + let client = self 38 + .pool 39 + .get() 40 + .await 41 + .map_err(|e| CardRepoError::DatabaseError(format!("Failed to get connection: {}", e)))?; 42 + 43 + let deck_uuid = uuid::Uuid::parse_str(deck_id) 44 + .map_err(|_| CardRepoError::InvalidArgument("Invalid deck ID".to_string()))?; 45 + 46 + // Verify deck exists and user owns it 47 + let deck_row = client 48 + .query_opt("SELECT owner_did FROM decks WHERE id = $1", &[&deck_uuid]) 49 + .await 50 + .map_err(|e| CardRepoError::DatabaseError(format!("Failed to query deck: {}", e)))? 51 + .ok_or_else(|| CardRepoError::NotFound("Deck not found".to_string()))?; 52 + 53 + let deck_owner: String = deck_row.get("owner_did"); 54 + if deck_owner != owner_did { 55 + return Err(CardRepoError::InvalidArgument( 56 + "Only deck owner can add cards".to_string(), 57 + )); 58 + } 59 + 60 + let card_id = uuid::Uuid::new_v4(); 61 + client 62 + .execute( 63 + "INSERT INTO cards (id, owner_did, deck_id, front, back, media_url) 64 + VALUES ($1, $2, $3, $4, $5, $6)", 65 + &[&card_id, &owner_did, &deck_uuid, &front, &back, &media_url], 66 + ) 67 + .await 68 + .map_err(|e| CardRepoError::DatabaseError(format!("Failed to insert card: {}", e)))?; 69 + 70 + Ok(Card { 71 + id: card_id.to_string(), 72 + owner_did: owner_did.to_string(), 73 + deck_id: deck_id.to_string(), 74 + front: front.to_string(), 75 + back: back.to_string(), 76 + media_url: media_url.map(String::from), 77 + }) 78 + } 79 + 80 + async fn list_by_deck(&self, deck_id: &str) -> Result<Vec<Card>, CardRepoError> { 81 + let client = self 82 + .pool 83 + .get() 84 + .await 85 + .map_err(|e| CardRepoError::DatabaseError(format!("Failed to get connection: {}", e)))?; 86 + 87 + let deck_uuid = uuid::Uuid::parse_str(deck_id) 88 + .map_err(|_| CardRepoError::InvalidArgument("Invalid deck ID".to_string()))?; 89 + 90 + // Verify deck exists 91 + let deck_exists = client 92 + .query_opt("SELECT id FROM decks WHERE id = $1", &[&deck_uuid]) 93 + .await 94 + .map_err(|e| CardRepoError::DatabaseError(format!("Failed to query deck: {}", e)))? 95 + .is_some(); 96 + 97 + if !deck_exists { 98 + return Err(CardRepoError::NotFound("Deck not found".to_string())); 99 + } 100 + 101 + let rows = client 102 + .query( 103 + "SELECT id, owner_did, deck_id, front, back, media_url 104 + FROM cards 105 + WHERE deck_id = $1 106 + ORDER BY created_at ASC", 107 + &[&deck_uuid], 108 + ) 109 + .await 110 + .map_err(|e| CardRepoError::DatabaseError(format!("Failed to query cards: {}", e)))?; 111 + 112 + let mut cards = Vec::new(); 113 + for row in rows { 114 + let id: uuid::Uuid = row.get("id"); 115 + let card_deck_id: uuid::Uuid = row.get("deck_id"); 116 + 117 + cards.push(Card { 118 + id: id.to_string(), 119 + owner_did: row.get("owner_did"), 120 + deck_id: card_deck_id.to_string(), 121 + front: row.get("front"), 122 + back: row.get("back"), 123 + media_url: row.get("media_url"), 124 + }); 125 + } 126 + 127 + Ok(cards) 128 + } 129 + 130 + async fn verify_deck_ownership(&self, deck_id: &str, owner_did: &str) -> Result<bool, CardRepoError> { 131 + let client = self 132 + .pool 133 + .get() 134 + .await 135 + .map_err(|e| CardRepoError::DatabaseError(format!("Failed to get connection: {}", e)))?; 136 + 137 + let deck_uuid = uuid::Uuid::parse_str(deck_id) 138 + .map_err(|_| CardRepoError::InvalidArgument("Invalid deck ID".to_string()))?; 139 + 140 + let row = client 141 + .query_opt("SELECT owner_did FROM decks WHERE id = $1", &[&deck_uuid]) 142 + .await 143 + .map_err(|e| CardRepoError::DatabaseError(format!("Failed to query deck: {}", e)))?; 144 + 145 + match row { 146 + Some(row) => { 147 + let deck_owner: String = row.get("owner_did"); 148 + Ok(deck_owner == owner_did) 149 + } 150 + None => Ok(false), 151 + } 152 + } 153 + } 154 + 155 + #[cfg(test)] 156 + pub mod mock { 157 + use super::*; 158 + use std::sync::{Arc, Mutex}; 159 + 160 + #[derive(Clone)] 161 + pub struct MockCardRepository { 162 + pub cards: Arc<Mutex<Vec<Card>>>, 163 + pub should_fail: Arc<Mutex<bool>>, 164 + } 165 + 166 + impl MockCardRepository { 167 + pub fn new() -> Self { 168 + Self { cards: Arc::new(Mutex::new(Vec::new())), should_fail: Arc::new(Mutex::new(false)) } 169 + } 170 + 171 + pub fn with_cards(cards: Vec<Card>) -> Self { 172 + Self { cards: Arc::new(Mutex::new(cards)), should_fail: Arc::new(Mutex::new(false)) } 173 + } 174 + 175 + pub fn set_should_fail(&self, should_fail: bool) { 176 + *self.should_fail.lock().unwrap() = should_fail; 177 + } 178 + } 179 + 180 + impl Default for MockCardRepository { 181 + fn default() -> Self { 182 + Self::new() 183 + } 184 + } 185 + 186 + #[async_trait] 187 + impl CardRepository for MockCardRepository { 188 + async fn create( 189 + &self, owner_did: &str, deck_id: &str, front: &str, back: &str, media_url: Option<&str>, 190 + ) -> Result<Card, CardRepoError> { 191 + if *self.should_fail.lock().unwrap() { 192 + return Err(CardRepoError::DatabaseError("Mock failure".to_string())); 193 + } 194 + 195 + let card = Card { 196 + id: uuid::Uuid::new_v4().to_string(), 197 + owner_did: owner_did.to_string(), 198 + deck_id: deck_id.to_string(), 199 + front: front.to_string(), 200 + back: back.to_string(), 201 + media_url: media_url.map(String::from), 202 + }; 203 + 204 + self.cards.lock().unwrap().push(card.clone()); 205 + Ok(card) 206 + } 207 + 208 + async fn list_by_deck(&self, deck_id: &str) -> Result<Vec<Card>, CardRepoError> { 209 + if *self.should_fail.lock().unwrap() { 210 + return Err(CardRepoError::DatabaseError("Mock failure".to_string())); 211 + } 212 + 213 + let cards = self.cards.lock().unwrap(); 214 + Ok(cards.iter().filter(|c| c.deck_id == deck_id).cloned().collect()) 215 + } 216 + 217 + async fn verify_deck_ownership(&self, _deck_id: &str, _owner_did: &str) -> Result<bool, CardRepoError> { 218 + if *self.should_fail.lock().unwrap() { 219 + return Err(CardRepoError::DatabaseError("Mock failure".to_string())); 220 + } 221 + Ok(true) 222 + } 223 + } 224 + }
+2
crates/server/src/repository/mod.rs
··· 1 + pub mod card; 2 + pub mod note;
+287
crates/server/src/repository/note.rs
··· 1 + use async_trait::async_trait; 2 + use malfestio_core::model::{Note, Visibility}; 3 + 4 + #[derive(Debug)] 5 + pub enum NoteRepoError { 6 + DatabaseError(String), 7 + NotFound(String), 8 + InvalidArgument(String), 9 + SerializationError(String), 10 + } 11 + 12 + #[async_trait] 13 + pub trait NoteRepository: Send + Sync { 14 + async fn create( 15 + &self, owner_did: &str, title: &str, body: &str, tags: Vec<String>, visibility: Visibility, 16 + ) -> Result<Note, NoteRepoError>; 17 + 18 + async fn list(&self, viewer_did: Option<&str>) -> Result<Vec<Note>, NoteRepoError>; 19 + 20 + async fn get(&self, id: &str, viewer_did: Option<&str>) -> Result<Note, NoteRepoError>; 21 + } 22 + 23 + pub struct DbNoteRepository { 24 + pool: crate::db::DbPool, 25 + } 26 + 27 + impl DbNoteRepository { 28 + pub fn new(pool: crate::db::DbPool) -> Self { 29 + Self { pool } 30 + } 31 + } 32 + 33 + #[async_trait] 34 + impl NoteRepository for DbNoteRepository { 35 + async fn create( 36 + &self, owner_did: &str, title: &str, body: &str, tags: Vec<String>, visibility: Visibility, 37 + ) -> Result<Note, NoteRepoError> { 38 + let client = self 39 + .pool 40 + .get() 41 + .await 42 + .map_err(|e| NoteRepoError::DatabaseError(format!("Failed to get connection: {}", e)))?; 43 + 44 + let note_id = uuid::Uuid::new_v4(); 45 + let visibility_json = serde_json::to_value(&visibility) 46 + .map_err(|e| NoteRepoError::SerializationError(format!("Failed to serialize visibility: {}", e)))?; 47 + 48 + client 49 + .execute( 50 + "INSERT INTO notes (id, owner_did, title, body, tags, visibility) 51 + VALUES ($1, $2, $3, $4, $5, $6)", 52 + &[&note_id, &owner_did, &title, &body, &tags, &visibility_json], 53 + ) 54 + .await 55 + .map_err(|e| NoteRepoError::DatabaseError(format!("Failed to insert note: {}", e)))?; 56 + 57 + Ok(Note { 58 + id: note_id.to_string(), 59 + owner_did: owner_did.to_string(), 60 + title: title.to_string(), 61 + body: body.to_string(), 62 + tags, 63 + visibility, 64 + published_at: None, 65 + links: Vec::new(), 66 + }) 67 + } 68 + 69 + async fn list(&self, viewer_did: Option<&str>) -> Result<Vec<Note>, NoteRepoError> { 70 + let client = self 71 + .pool 72 + .get() 73 + .await 74 + .map_err(|e| NoteRepoError::DatabaseError(format!("Failed to get connection: {}", e)))?; 75 + 76 + let query = if viewer_did.is_some() { 77 + "SELECT id, owner_did, title, body, tags, visibility, published_at, links, created_at, updated_at 78 + FROM notes 79 + WHERE owner_did = $1 80 + OR visibility->>'type' = 'Public' 81 + OR visibility->>'type' = 'Unlisted' 82 + OR (visibility->>'type' = 'SharedWith' AND visibility->'content' ? $1) 83 + ORDER BY created_at DESC" 84 + } else { 85 + "SELECT id, owner_did, title, body, tags, visibility, published_at, links, created_at, updated_at 86 + FROM notes 87 + WHERE visibility->>'type' IN ('Public', 'Unlisted') 88 + ORDER BY created_at DESC" 89 + }; 90 + 91 + let rows = if let Some(did) = viewer_did { 92 + client.query(query, &[&did]).await 93 + } else { 94 + client.query(query, &[]).await 95 + }; 96 + 97 + let rows = rows.map_err(|e| NoteRepoError::DatabaseError(format!("Failed to query notes: {}", e)))?; 98 + 99 + let mut notes = Vec::new(); 100 + for row in rows { 101 + let visibility_json: serde_json::Value = row.get("visibility"); 102 + let visibility: Visibility = serde_json::from_value(visibility_json) 103 + .map_err(|e| NoteRepoError::SerializationError(format!("Failed to deserialize visibility: {}", e)))?; 104 + 105 + let id: uuid::Uuid = row.get("id"); 106 + let links: Vec<String> = row.get("links"); 107 + 108 + notes.push(Note { 109 + id: id.to_string(), 110 + owner_did: row.get("owner_did"), 111 + title: row.get("title"), 112 + body: row.get("body"), 113 + tags: row.get("tags"), 114 + visibility, 115 + published_at: row 116 + .get::<_, Option<chrono::DateTime<chrono::Utc>>>("published_at") 117 + .map(|dt| dt.to_rfc3339()), 118 + links, 119 + }); 120 + } 121 + 122 + Ok(notes) 123 + } 124 + 125 + async fn get(&self, id: &str, viewer_did: Option<&str>) -> Result<Note, NoteRepoError> { 126 + let client = self 127 + .pool 128 + .get() 129 + .await 130 + .map_err(|e| NoteRepoError::DatabaseError(format!("Failed to get connection: {}", e)))?; 131 + 132 + let note_id = 133 + uuid::Uuid::parse_str(id).map_err(|_| NoteRepoError::InvalidArgument("Invalid note ID".to_string()))?; 134 + 135 + let row = client 136 + .query_opt( 137 + "SELECT id, owner_did, title, body, tags, visibility, published_at, links, created_at, updated_at 138 + FROM notes WHERE id = $1", 139 + &[&note_id], 140 + ) 141 + .await 142 + .map_err(|e| NoteRepoError::DatabaseError(format!("Failed to query note: {}", e)))? 143 + .ok_or_else(|| NoteRepoError::NotFound("Note not found".to_string()))?; 144 + 145 + let visibility_json: serde_json::Value = row.get("visibility"); 146 + let visibility: Visibility = serde_json::from_value(visibility_json) 147 + .map_err(|e| NoteRepoError::SerializationError(format!("Failed to deserialize visibility: {}", e)))?; 148 + 149 + let owner_did: String = row.get("owner_did"); 150 + let is_owner = viewer_did == Some(owner_did.as_str()); 151 + 152 + let has_access = match &visibility { 153 + Visibility::Public | Visibility::Unlisted => true, 154 + Visibility::Private => is_owner, 155 + Visibility::SharedWith(dids) => { 156 + is_owner || viewer_did.map(|did| dids.contains(&did.to_string())).unwrap_or(false) 157 + } 158 + }; 159 + 160 + if !has_access { 161 + return Err(NoteRepoError::InvalidArgument("Access denied".to_string())); 162 + } 163 + 164 + let uuid_id: uuid::Uuid = row.get("id"); 165 + let links: Vec<String> = row.get("links"); 166 + 167 + Ok(Note { 168 + id: uuid_id.to_string(), 169 + owner_did, 170 + title: row.get("title"), 171 + body: row.get("body"), 172 + tags: row.get("tags"), 173 + visibility, 174 + published_at: row 175 + .get::<_, Option<chrono::DateTime<chrono::Utc>>>("published_at") 176 + .map(|dt| dt.to_rfc3339()), 177 + links, 178 + }) 179 + } 180 + } 181 + 182 + #[cfg(test)] 183 + pub mod mock { 184 + use super::*; 185 + use std::sync::{Arc, Mutex}; 186 + 187 + #[derive(Clone)] 188 + pub struct MockNoteRepository { 189 + pub notes: Arc<Mutex<Vec<Note>>>, 190 + pub should_fail: Arc<Mutex<bool>>, 191 + } 192 + 193 + impl MockNoteRepository { 194 + pub fn new() -> Self { 195 + Self { notes: Arc::new(Mutex::new(Vec::new())), should_fail: Arc::new(Mutex::new(false)) } 196 + } 197 + 198 + pub fn with_notes(notes: Vec<Note>) -> Self { 199 + Self { notes: Arc::new(Mutex::new(notes)), should_fail: Arc::new(Mutex::new(false)) } 200 + } 201 + 202 + pub fn set_should_fail(&self, should_fail: bool) { 203 + *self.should_fail.lock().unwrap() = should_fail; 204 + } 205 + } 206 + 207 + impl Default for MockNoteRepository { 208 + fn default() -> Self { 209 + Self::new() 210 + } 211 + } 212 + 213 + #[async_trait] 214 + impl NoteRepository for MockNoteRepository { 215 + async fn create( 216 + &self, owner_did: &str, title: &str, body: &str, tags: Vec<String>, visibility: Visibility, 217 + ) -> Result<Note, NoteRepoError> { 218 + if *self.should_fail.lock().unwrap() { 219 + return Err(NoteRepoError::DatabaseError("Mock failure".to_string())); 220 + } 221 + 222 + let note = Note { 223 + id: uuid::Uuid::new_v4().to_string(), 224 + owner_did: owner_did.to_string(), 225 + title: title.to_string(), 226 + body: body.to_string(), 227 + tags, 228 + visibility, 229 + published_at: None, 230 + links: Vec::new(), 231 + }; 232 + 233 + self.notes.lock().unwrap().push(note.clone()); 234 + Ok(note) 235 + } 236 + 237 + async fn list(&self, viewer_did: Option<&str>) -> Result<Vec<Note>, NoteRepoError> { 238 + if *self.should_fail.lock().unwrap() { 239 + return Err(NoteRepoError::DatabaseError("Mock failure".to_string())); 240 + } 241 + 242 + let notes = self.notes.lock().unwrap(); 243 + let filtered: Vec<Note> = notes 244 + .iter() 245 + .filter(|note| { 246 + let is_owner = viewer_did == Some(note.owner_did.as_str()); 247 + match &note.visibility { 248 + Visibility::Public | Visibility::Unlisted => true, 249 + Visibility::Private => is_owner, 250 + Visibility::SharedWith(dids) => { 251 + is_owner || viewer_did.map(|did| dids.contains(&did.to_string())).unwrap_or(false) 252 + } 253 + } 254 + }) 255 + .cloned() 256 + .collect(); 257 + Ok(filtered) 258 + } 259 + 260 + async fn get(&self, id: &str, viewer_did: Option<&str>) -> Result<Note, NoteRepoError> { 261 + if *self.should_fail.lock().unwrap() { 262 + return Err(NoteRepoError::DatabaseError("Mock failure".to_string())); 263 + } 264 + 265 + let notes = self.notes.lock().unwrap(); 266 + let note = notes 267 + .iter() 268 + .find(|n| n.id == id) 269 + .ok_or_else(|| NoteRepoError::NotFound("Note not found".to_string()))?; 270 + 271 + let is_owner = viewer_did == Some(note.owner_did.as_str()); 272 + let has_access = match &note.visibility { 273 + Visibility::Public | Visibility::Unlisted => true, 274 + Visibility::Private => is_owner, 275 + Visibility::SharedWith(dids) => { 276 + is_owner || viewer_did.map(|did| dids.contains(&did.to_string())).unwrap_or(false) 277 + } 278 + }; 279 + 280 + if !has_access { 281 + return Err(NoteRepoError::InvalidArgument("Access denied".to_string())); 282 + } 283 + 284 + Ok(note.clone()) 285 + } 286 + } 287 + }
+15 -1
crates/server/src/state.rs
··· 1 1 use crate::db::DbPool; 2 + use crate::repository::card::{CardRepository, DbCardRepository}; 3 + use crate::repository::note::{DbNoteRepository, NoteRepository}; 2 4 use std::sync::Arc; 3 5 4 6 pub type SharedState = Arc<AppState>; 5 7 6 8 pub struct AppState { 7 9 pub pool: DbPool, 10 + pub card_repo: Arc<dyn CardRepository>, 11 + pub note_repo: Arc<dyn NoteRepository>, 8 12 } 9 13 10 14 impl AppState { 11 15 pub fn new(pool: DbPool) -> SharedState { 12 - Arc::new(Self { pool }) 16 + let card_repo = Arc::new(DbCardRepository::new(pool.clone())) as Arc<dyn CardRepository>; 17 + let note_repo = Arc::new(DbNoteRepository::new(pool.clone())) as Arc<dyn NoteRepository>; 18 + 19 + Arc::new(Self { pool, card_repo, note_repo }) 20 + } 21 + 22 + #[cfg(test)] 23 + pub fn new_with_repos( 24 + pool: DbPool, card_repo: Arc<dyn CardRepository>, note_repo: Arc<dyn NoteRepository>, 25 + ) -> SharedState { 26 + Arc::new(Self { pool, card_repo, note_repo }) 13 27 } 14 28 }