Magazi is a content distribution platform that gates access to files using ATProtocol (Bluesky) identity and cryptographic proofs. download.ngerakines.me/
atprotocol appview atprotocol-attestations
11
fork

Configure Feed

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

refactor: code cleanup and refactoring

+349 -274
+8 -18
src/bin/catalog-manager.rs
··· 16 16 17 17 use cid::Cid; 18 18 use multihash_codetable::{Code, MultihashDigest}; 19 - use serde::{Deserialize, Serialize}; 20 19 21 - /// Raw codec for CID (0x55) 22 - const RAW_CODEC: u64 = 0x55; 23 - 24 - #[derive(Debug, Clone, Serialize, Deserialize)] 25 - struct CatalogEntry { 26 - id: String, 27 - name: String, 28 - description: String, 29 - #[serde(skip_serializing_if = "Option::is_none")] 30 - content_type: Option<String>, 31 - #[serde(skip_serializing_if = "Option::is_none")] 32 - icon: Option<String>, 33 - } 20 + use magazi::config::CatalogEntry; 21 + use magazi::constants::RAW_CODEC; 34 22 35 23 fn compute_raw_cid(data: &[u8]) -> String { 36 24 let hash = Code::Sha2_256.digest(data); ··· 87 75 description: description.clone(), 88 76 content_type: Some(content_type), 89 77 icon: icon.clone(), 78 + requirements: Default::default(), // Uses default_requirements() 90 79 }; 91 80 92 81 let catalog_path = files_dir.join("catalog.json"); ··· 99 88 }; 100 89 101 90 if let Some(existing) = catalog.iter_mut().find(|e| e.id == cid) { 102 - existing.name = new_entry.name; 103 - existing.description = new_entry.description; 104 - existing.content_type = new_entry.content_type; 105 - existing.icon = new_entry.icon; 91 + existing.name.clone_from(&new_entry.name); 92 + existing.description.clone_from(&new_entry.description); 93 + existing.content_type.clone_from(&new_entry.content_type); 94 + existing.icon.clone_from(&new_entry.icon); 95 + // Don't override existing requirements 106 96 println!("Updated existing entry with CID: {}", cid); 107 97 } else { 108 98 catalog.push(new_entry);
+5
src/config.rs
··· 101 101 pub fn external_url(&self) -> String { 102 102 format!("https://{}", self.http_external) 103 103 } 104 + 105 + /// Find a catalog entry by its ID (CID). 106 + pub fn find_catalog_entry(&self, id: &str) -> Option<&CatalogEntry> { 107 + self.catalog.iter().find(|e| e.id == id) 108 + } 104 109 }
+35
src/constants.rs
··· 1 + //! Shared constants used throughout the application. 2 + 3 + use std::time::Duration; 4 + 5 + /// Raw codec for CID (0x55) - used for content-addressed file storage. 6 + pub const RAW_CODEC: u64 = 0x55; 7 + 8 + /// Lexicon type identifiers for ATProtocol records. 9 + pub mod lexicon { 10 + /// Supporter record type - stored in user's PDS. 11 + pub const SUPPORTER: &str = "com.atprotofans.supporter"; 12 + 13 + /// Supporter proof record type - attestation from creator. 14 + pub const SUPPORTER_PROOF: &str = "com.atprotofans.supporterProof"; 15 + 16 + /// Broker proof record type - attestation from broker. 17 + pub const BROKER_PROOF: &str = "com.atprotofans.brokerProof"; 18 + } 19 + 20 + /// Cache time-to-live durations. 21 + pub mod cache_ttl { 22 + use super::Duration; 23 + 24 + /// TTL for entitlement context cache (per-user supporter records). 25 + pub const CONTEXT: Duration = Duration::from_secs(60); 26 + 27 + /// TTL for entitlement result cache (per-user, per-item access decisions). 28 + pub const ENTITLEMENT: Duration = Duration::from_secs(1800); // 30 minutes 29 + 30 + /// TTL for identity document cache (DID documents). 31 + pub const IDENTITY: Duration = Duration::from_secs(7200); // 2 hours 32 + 33 + /// TTL for record cache (fetched AT Protocol records). 34 + pub const RECORD: Duration = Duration::from_secs(1800); // 30 minutes 35 + }
+20 -64
src/handlers/handler_auth.rs
··· 21 21 use crate::session::{Session, SessionJar}; 22 22 use crate::state::AppState; 23 23 24 - pub async fn login_form(state: State<AppState>, jar: SessionJar) -> Html<String> { 25 - let session = jar.get_session(); 26 - let logged_in = session.is_some(); 27 - 28 - // Resolve creator's handle from their DID 24 + /// Render the login page with optional error message. 25 + /// 26 + /// This helper consolidates the login page rendering logic used by multiple handlers. 27 + async fn render_login_page(state: &AppState, logged_in: bool, error: Option<&str>) -> Html<String> { 29 28 let creator_did = &state.config.creator_identity; 30 - let creator_handle = state 31 - .identity_resolver 32 - .resolve(creator_did) 33 - .await 34 - .ok() 35 - .and_then(|doc| doc.also_known_as.first().cloned()) 36 - .and_then(|aka| aka.strip_prefix("at://").map(|s| s.to_string())) 37 - .unwrap_or_else(|| creator_did.clone()); 29 + let creator_handle = state.resolve_creator_handle().await; 30 + 31 + let template = state 32 + .templates 33 + .get_template("login.html") 34 + .expect("login.html template missing - check templates/ folder"); 38 35 39 - let template = state.templates.get_template("login.html").unwrap(); 40 36 let rendered = template 41 37 .render(context! { 42 38 logged_in => logged_in, 43 39 creator_did => creator_did, 44 40 creator_handle => creator_handle, 45 - error => None::<String>, 41 + error => error, 46 42 }) 47 43 .unwrap_or_else(|e| { 48 44 tracing::error!("Template render error: {}", e); ··· 50 46 }); 51 47 52 48 Html(rendered) 49 + } 50 + 51 + pub async fn login_form(state: State<AppState>, jar: SessionJar) -> Html<String> { 52 + let logged_in = jar.get_session().is_some(); 53 + render_login_page(&state, logged_in, None).await 53 54 } 54 55 55 56 #[derive(Deserialize)] ··· 185 186 _ => "An unexpected error occurred during login", 186 187 }; 187 188 188 - // Resolve creator's handle for template 189 - let creator_did = &state.config.creator_identity; 190 - let creator_handle = state 191 - .identity_resolver 192 - .resolve(creator_did) 193 - .await 194 - .ok() 195 - .and_then(|doc| doc.also_known_as.first().cloned()) 196 - .and_then(|aka| aka.strip_prefix("at://").map(|s| s.to_string())) 197 - .unwrap_or_else(|| creator_did.clone()); 198 - 199 - let template = state.templates.get_template("login.html").unwrap(); 200 - let rendered = template 201 - .render(context! { 202 - logged_in => false, 203 - creator_did => creator_did, 204 - creator_handle => creator_handle, 205 - error => Some(user_error), 206 - }) 207 - .unwrap_or_else(|e| { 208 - tracing::error!("Template render error: {}", e); 209 - "An error occurred while loading the page.".to_string() 210 - }); 211 - Err(Html(rendered)) 189 + Err(render_login_page(&state, false, Some(user_error)).await) 212 190 } 213 191 } 214 192 } ··· 317 295 // Log the actual error for debugging 318 296 tracing::warn!("Login callback failed: {}", e); 319 297 320 - // Resolve creator's handle for template 321 - let creator_did = &state.config.creator_identity; 322 - let creator_handle = state 323 - .identity_resolver 324 - .resolve(creator_did) 325 - .await 326 - .ok() 327 - .and_then(|doc| doc.also_known_as.first().cloned()) 328 - .and_then(|aka| aka.strip_prefix("at://").map(|s| s.to_string())) 329 - .unwrap_or_else(|| creator_did.clone()); 330 - 331 298 // Provide generic error message to user 332 299 let user_error = "Unable to complete login. Please try again."; 333 - 334 - let template = state.templates.get_template("login.html").unwrap(); 335 - let rendered = template 336 - .render(context! { 337 - logged_in => false, 338 - creator_did => creator_did, 339 - creator_handle => creator_handle, 340 - error => Some(user_error), 341 - }) 342 - .unwrap_or_else(|e| { 343 - tracing::error!("Template render error: {}", e); 344 - "An error occurred while loading the page.".to_string() 345 - }); 346 - Html(rendered).into_response() 300 + render_login_page(&state, false, Some(user_error)) 301 + .await 302 + .into_response() 347 303 } 348 304 } 349 305 }
+21 -74
src/handlers/handler_download.rs
··· 1 1 use atproto_oauth::jwt::verify; 2 2 use axum::{ 3 - body::Body, 4 3 extract::{Path, Query, State}, 5 - http::{StatusCode, header}, 4 + http::StatusCode, 6 5 response::Response, 7 6 }; 8 - use cid::Cid; 9 7 use serde::Deserialize; 10 - use tokio::fs::File; 11 - use tokio_util::io::ReaderStream; 12 8 13 9 use crate::handlers::util_entitlements::check_entitlement; 10 + use crate::handlers::util_files::{FileServeError, serve_file_as_download, validate_raw_cid}; 14 11 use crate::state::AppState; 15 12 16 13 #[derive(Deserialize)] ··· 18 15 jwt: Option<String>, 19 16 } 20 17 21 - /// Raw codec for CID (0x55) - same as used in catalog-manager 22 - const RAW_CODEC: u64 = 0x55; 23 - 24 18 pub async fn download_file( 25 19 state: State<AppState>, 26 20 Path(id): Path<String>, 27 21 Query(query): Query<DownloadQuery>, 28 22 ) -> Result<Response, StatusCode> { 29 - let parsed_cid = Cid::try_from(id.as_str()).map_err(|_| StatusCode::NOT_FOUND)?; 30 - if parsed_cid.codec() != RAW_CODEC { 31 - return Err(StatusCode::NOT_FOUND); 32 - } 23 + // Validate that the ID is a valid raw CID 24 + validate_raw_cid(&id).map_err(|_| StatusCode::NOT_FOUND)?; 33 25 34 26 // Validate access: either via JWT or anonymous entitlement 35 27 if let Some(jwt) = &query.jwt { ··· 59 51 } 60 52 } else { 61 53 // No JWT: check if anonymous users are entitled to this item 62 - let catalog_entry = state 63 - .config 64 - .catalog 65 - .iter() 66 - .find(|e| e.id == id) 67 - .ok_or_else(|| { 68 - tracing::warn!("Catalog entry not found: {}", id); 69 - StatusCode::NOT_FOUND 70 - })?; 54 + let catalog_entry = state.config.find_catalog_entry(&id).ok_or_else(|| { 55 + tracing::warn!("Catalog entry not found: {}", id); 56 + StatusCode::NOT_FOUND 57 + })?; 71 58 72 59 // Check entitlement with empty contexts (anonymous user) 73 60 let entitled = check_entitlement(&state, &[], catalog_entry).await; ··· 78 65 } 79 66 } 80 67 81 - let file_path = state.config.files_path.join(&id); 82 - 83 - if !file_path.starts_with(&state.config.files_path) { 84 - return Err(StatusCode::NOT_FOUND); 85 - } 86 - 87 - let file = File::open(&file_path).await.map_err(|e| { 88 - tracing::warn!("File not found: {} - {}", file_path.display(), e); 68 + // Look up catalog entry for file serving 69 + let catalog_entry = state.config.find_catalog_entry(&id).ok_or_else(|| { 70 + tracing::warn!("Catalog entry not found: {}", id); 89 71 StatusCode::NOT_FOUND 90 72 })?; 91 73 92 - let metadata = file.metadata().await.map_err(|_| StatusCode::NOT_FOUND)?; 93 - let file_size = metadata.len(); 94 - 95 - let stream = ReaderStream::new(file); 96 - let body = Body::from_stream(stream); 97 - 98 - let content_type = state 99 - .config 100 - .catalog 101 - .iter() 102 - .find(|e| e.id == id) 103 - .and_then(|e| e.content_type.clone()) 104 - .unwrap_or_else(|| { 105 - mime_guess::from_path(&file_path) 106 - .first_or_octet_stream() 107 - .to_string() 108 - }); 109 - 110 - let filename = file_path 111 - .file_name() 112 - .and_then(|n| n.to_str()) 113 - .unwrap_or(&id); 114 - 115 - // Sanitize filename for Content-Disposition header to prevent header injection 116 - let sanitized_filename: String = filename 117 - .chars() 118 - .filter(|c| c.is_alphanumeric() || *c == '-' || *c == '_' || *c == '.') 119 - .collect(); 120 - let sanitized_filename = if sanitized_filename.is_empty() { 121 - "download".to_string() 122 - } else { 123 - sanitized_filename 124 - }; 125 - 126 - let response = Response::builder() 127 - .header(header::CONTENT_TYPE, content_type) 128 - .header(header::CONTENT_LENGTH, file_size) 129 - .header( 130 - header::CONTENT_DISPOSITION, 131 - format!("attachment; filename=\"{}\"", sanitized_filename), 132 - ) 133 - .body(body) 134 - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; 135 - 136 - Ok(response) 74 + // Serve the file as a download 75 + serve_file_as_download(&state.config, catalog_entry) 76 + .await 77 + .map_err(|e| match e { 78 + FileServeError::NotFound => StatusCode::NOT_FOUND, 79 + FileServeError::Internal(msg) => { 80 + tracing::error!("Internal error serving file: {}", msg); 81 + StatusCode::INTERNAL_SERVER_ERROR 82 + } 83 + }) 137 84 }
+11 -44
src/handlers/handler_getblob.rs
··· 1 1 use atproto_xrpcs::authorization::Authorization; 2 2 use axum::{ 3 - body::Body, debug_handler, extract::{Query, State}, http::header, response::Response 3 + extract::{Query, State}, 4 + response::Response, 4 5 }; 5 - use cid::Cid; 6 6 use serde::Deserialize; 7 - use tokio::fs::File; 8 - use tokio_util::io::ReaderStream; 9 7 10 8 use crate::handlers::util_entitlements::{check_entitlement, get_entitlement_contexts}; 11 9 use crate::handlers::util_errors::GetBlobError; 10 + use crate::handlers::util_files::{FileServeError, serve_file, validate_raw_cid}; 12 11 use crate::state::AppState; 13 - 14 - /// Raw codec for CID (0x55) - same as used in catalog-manager 15 - const RAW_CODEC: u64 = 0x55; 16 12 17 13 #[derive(Deserialize)] 18 14 pub struct GetBlobParams { ··· 33 29 } 34 30 35 31 // 2. Validate CID is a valid raw CID 36 - let parsed_cid = Cid::try_from(params.cid.as_str()).map_err(|_| GetBlobError::BlobNotFound)?; 37 - if parsed_cid.codec() != RAW_CODEC { 38 - return Err(GetBlobError::BlobNotFound); 39 - } 32 + validate_raw_cid(&params.cid).map_err(|_| GetBlobError::BlobNotFound)?; 40 33 41 34 // 3. Validate cid exists in catalog 42 35 let catalog_entry = state 43 36 .config 44 - .catalog 45 - .iter() 46 - .find(|e| e.id == params.cid) 37 + .find_catalog_entry(&params.cid) 47 38 .ok_or(GetBlobError::BlobNotFound)?; 48 39 49 40 let invalid_auth = authorization.as_ref().is_some_and(|auth| !auth.3); ··· 52 43 } 53 44 54 45 // 4. Extract and validate Authorization header (if present) 55 - let caller_did = authorization.and_then(|auth| auth.identity().map(|val| val.to_string())); 46 + let caller_did = authorization.and_then(|auth| auth.identity().map(ToString::to_string)); 56 47 57 48 // 5. Build entitlement contexts and check entitlement 58 49 let is_entitled = if let Some(ref did) = caller_did { ··· 78 69 } 79 70 80 71 // 6. Serve the blob 81 - let file_path = state.config.files_path.join(&params.cid); 82 - 83 - // Security: ensure path doesn't escape files directory 84 - if !file_path.starts_with(&state.config.files_path) { 85 - return Err(GetBlobError::BlobNotFound); 86 - } 87 - 88 - let file = File::open(&file_path) 72 + serve_file(&state.config, catalog_entry) 89 73 .await 90 - .map_err(|_| GetBlobError::BlobNotFound)?; 91 - 92 - let metadata = file.metadata().await.map_err(|e| { 93 - tracing::error!(error = %e, "Failed to read blob metadata"); 94 - GetBlobError::Internal(e.to_string()) 95 - })?; 96 - 97 - let stream = ReaderStream::new(file); 98 - let body = Body::from_stream(stream); 99 - 100 - let content_type = catalog_entry.content_type.clone().unwrap_or_else(|| { 101 - mime_guess::from_path(&file_path) 102 - .first_or_octet_stream() 103 - .to_string() 104 - }); 105 - 106 - Response::builder() 107 - .header(header::CONTENT_TYPE, content_type) 108 - .header(header::CONTENT_LENGTH, metadata.len()) 109 - .body(body) 110 - .map_err(|e| GetBlobError::Internal(e.to_string())) 74 + .map_err(|e| match e { 75 + FileServeError::NotFound => GetBlobError::BlobNotFound, 76 + FileServeError::Internal(msg) => GetBlobError::Internal(msg), 77 + }) 111 78 }
+5 -9
src/handlers/handler_home.rs
··· 45 45 46 46 // Resolve creator's handle from their DID 47 47 let creator_did = &state.config.creator_identity; 48 - let creator_handle = state 49 - .identity_resolver 50 - .resolve(creator_did) 51 - .await 52 - .ok() 53 - .and_then(|doc| doc.also_known_as.first().cloned()) 54 - .and_then(|aka| aka.strip_prefix("at://").map(|s| s.to_string())) 55 - .unwrap_or_else(|| creator_did.clone()); 48 + let creator_handle = state.resolve_creator_handle().await; 56 49 57 50 // Build entitlement contexts if logged in 58 51 let contexts = if let Some(ref session) = session { ··· 97 90 }); 98 91 } 99 92 100 - let template = state.templates.get_template("home.html").unwrap(); 93 + let template = state 94 + .templates 95 + .get_template("home.html") 96 + .expect("home.html template missing - check templates/ folder"); 101 97 let rendered = template 102 98 .render(context! { 103 99 logged_in => logged_in,
+26 -30
src/handlers/handler_oauth.rs
··· 4 4 http::StatusCode, 5 5 response::{IntoResponse, Json, Response}, 6 6 }; 7 - use p256::elliptic_curve::sec1::ToEncodedPoint as _; 7 + use base64::Engine; 8 + use base64::engine::general_purpose::URL_SAFE_NO_PAD; 9 + use p256::elliptic_curve::sec1::{FromEncodedPoint, ToEncodedPoint as _}; 10 + use p256::{EncodedPoint, PublicKey}; 8 11 use serde_json::{Value, json}; 9 12 10 13 use crate::state::AppState; ··· 53 56 .into_response() 54 57 } 55 58 59 + /// Create a JWK JSON object from x and y coordinates. 60 + fn create_ec_jwk(x: &[u8], y: &[u8], kid: &str) -> Value { 61 + json!({ 62 + "kty": "EC", 63 + "crv": "P-256", 64 + "alg": "ES256", 65 + "use": "sig", 66 + "kid": kid, 67 + "x": URL_SAFE_NO_PAD.encode(x), 68 + "y": URL_SAFE_NO_PAD.encode(y) 69 + }) 70 + } 71 + 72 + /// Extract x and y coordinates from a public key and create a JWK. 56 73 fn key_data_to_jwk( 57 74 key_data: &atproto_identity::key::KeyData, 58 75 kid: &str, 59 76 ) -> Result<Value, &'static str> { 60 - use atproto_identity::key::to_public; 61 - use base64::Engine; 62 - use base64::engine::general_purpose::URL_SAFE_NO_PAD; 63 - 64 77 let public_key = to_public(key_data).map_err(|_| "Failed to derive public key")?; 65 78 let bytes = public_key.bytes(); 66 79 80 + // Handle uncompressed point format (0x04 prefix + 32 bytes x + 32 bytes y) 67 81 if bytes.len() == 65 && bytes[0] == 0x04 { 68 82 let x = &bytes[1..33]; 69 83 let y = &bytes[33..65]; 70 - 71 - Ok(json!({ 72 - "kty": "EC", 73 - "crv": "P-256", 74 - "alg": "ES256", 75 - "use": "sig", 76 - "kid": kid, 77 - "x": URL_SAFE_NO_PAD.encode(x), 78 - "y": URL_SAFE_NO_PAD.encode(y) 79 - })) 80 - } else if bytes.len() == 33 { 81 - use p256::elliptic_curve::sec1::FromEncodedPoint; 82 - use p256::{EncodedPoint, PublicKey}; 84 + return Ok(create_ec_jwk(x, y, kid)); 85 + } 83 86 87 + // Handle compressed point format (33 bytes) - decompress first 88 + if bytes.len() == 33 { 84 89 let encoded_point = 85 90 EncodedPoint::from_bytes(bytes).map_err(|_| "Invalid compressed point")?; 86 91 let public_key = PublicKey::from_encoded_point(&encoded_point) ··· 91 96 92 97 let x = &uncompressed_bytes[1..33]; 93 98 let y = &uncompressed_bytes[33..65]; 94 - 95 - Ok(json!({ 96 - "kty": "EC", 97 - "crv": "P-256", 98 - "alg": "ES256", 99 - "use": "sig", 100 - "kid": kid, 101 - "x": URL_SAFE_NO_PAD.encode(x), 102 - "y": URL_SAFE_NO_PAD.encode(y) 103 - })) 104 - } else { 105 - Err("Unexpected key format") 99 + return Ok(create_ec_jwk(x, y, kid)); 106 100 } 101 + 102 + Err("Unexpected key format") 107 103 }
+5 -1
src/handlers/handler_robots.rs
··· 13 13 "; 14 14 15 15 pub async fn robots_txt() -> Response { 16 - ([(header::CONTENT_TYPE, "text/plain; charset=utf-8")], ROBOTS_TXT).into_response() 16 + ( 17 + [(header::CONTENT_TYPE, "text/plain; charset=utf-8")], 18 + ROBOTS_TXT, 19 + ) 20 + .into_response() 17 21 }
+1
src/handlers/mod.rs
··· 7 7 8 8 pub mod util_entitlements; 9 9 pub mod util_errors; 10 + pub mod util_files;
+25 -17
src/handlers/util_entitlements.rs
··· 9 9 use datalogic_rs::DataLogic; 10 10 use serde::{Deserialize, Serialize}; 11 11 12 - use crate::config::CatalogEntry; 13 - use crate::entitlements::EntitlementContext; 14 12 use crate::resolvers::DidKeyResolver; 15 13 use crate::state::AppState; 14 + use magazi::config::CatalogEntry; 15 + use magazi::constants::lexicon; 16 + use magazi::entitlements::EntitlementContext; 16 17 17 18 /// Cache key used for anonymous (unauthenticated) visitors 18 19 const ANONYMOUS_CACHE_KEY: &str = ""; 19 20 21 + /// A proof record (attestation) - used for both supporter and broker proofs. 22 + /// 23 + /// Both `com.atprotofans.supporterProof` and `com.atprotofans.brokerProof` have 24 + /// the same structure, so we use a single generic type parameterized by the 25 + /// lexicon type string. 20 26 #[derive(Serialize, Deserialize, Clone, PartialEq)] 21 - struct SupporterProof { 27 + struct Proof { 22 28 cid: String, 23 29 signature: String, 24 30 #[serde(flatten)] 25 31 extra: HashMap<String, serde_json::Value>, 26 32 } 27 33 34 + /// Wrapper to provide LexiconType for supporter proofs. 35 + #[derive(Serialize, Deserialize, Clone, PartialEq)] 36 + #[serde(transparent)] 37 + struct SupporterProof(Proof); 38 + 28 39 impl LexiconType for SupporterProof { 29 40 fn lexicon_type() -> &'static str { 30 - "com.atprotofans.supporterProof" 41 + lexicon::SUPPORTER_PROOF 31 42 } 32 43 } 33 44 45 + /// Wrapper to provide LexiconType for broker proofs. 34 46 #[derive(Serialize, Deserialize, Clone, PartialEq)] 35 - struct BrokerProof { 36 - cid: String, 37 - signature: String, 38 - #[serde(flatten)] 39 - extra: HashMap<String, serde_json::Value>, 40 - } 47 + #[serde(transparent)] 48 + struct BrokerProof(Proof); 41 49 42 50 impl LexiconType for BrokerProof { 43 51 fn lexicon_type() -> &'static str { 44 - "com.atprotofans.brokerProof" 52 + lexicon::BROKER_PROOF 45 53 } 46 54 } 47 55 ··· 67 75 68 76 impl LexiconType for SupporterRecord { 69 77 fn lexicon_type() -> &'static str { 70 - "com.atprotofans.supporter" 78 + lexicon::SUPPORTER 71 79 } 72 80 } 73 81 ··· 111 119 // even if the user has no supporter records. 112 120 let mut contexts = vec![EntitlementContext::authenticated( 113 121 did.to_string(), 114 - handle.map(|s| s.to_string()), 122 + handle.map(ToString::to_string), 115 123 )]; 116 124 117 125 let params = ListRecordsParams::new().limit(100); ··· 121 129 &Auth::None, 122 130 pds_url, 123 131 did.to_string(), 124 - "com.atprotofans.supporter".to_string(), 132 + lexicon::SUPPORTER.to_string(), 125 133 params, 126 134 ) 127 135 .await ··· 138 146 let supporter_proof_ref = find_strong_ref( 139 147 &record.value, 140 148 &state.config.creator_identity, 141 - "com.atprotofans.supporterProof", 149 + lexicon::SUPPORTER_PROOF, 142 150 ); 143 151 144 152 // Find StrongRef for brokerProof from broker 145 153 let broker_proof_ref = find_strong_ref( 146 154 &record.value, 147 155 &state.config.broker_identity, 148 - "com.atprotofans.brokerProof", 156 + lexicon::BROKER_PROOF, 149 157 ); 150 158 151 159 // Skip records that don't have both StrongRefs ··· 182 190 183 191 contexts.push(EntitlementContext::with_supporter( 184 192 did.to_string(), 185 - handle.map(|s| s.to_string()), 193 + handle.map(ToString::to_string), 186 194 supporter_json, 187 195 sp, 188 196 bp,
+151
src/handlers/util_files.rs
··· 1 + //! Shared utilities for serving files from the catalog. 2 + 3 + use std::path::Path; 4 + 5 + use axum::{ 6 + body::Body, 7 + http::{StatusCode, header}, 8 + response::Response, 9 + }; 10 + use cid::Cid; 11 + use tokio::fs::File; 12 + use tokio_util::io::ReaderStream; 13 + 14 + use magazi::config::{CatalogEntry, Config}; 15 + use magazi::constants::RAW_CODEC; 16 + 17 + /// Error type for file serving operations. 18 + #[derive(Debug)] 19 + pub enum FileServeError { 20 + /// The requested CID is invalid or not found. 21 + NotFound, 22 + /// Internal error while reading the file. 23 + Internal(String), 24 + } 25 + 26 + impl From<FileServeError> for StatusCode { 27 + fn from(err: FileServeError) -> Self { 28 + match err { 29 + FileServeError::NotFound => StatusCode::NOT_FOUND, 30 + FileServeError::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR, 31 + } 32 + } 33 + } 34 + 35 + /// Validate that a CID string is a valid raw CID. 36 + pub fn validate_raw_cid(cid_str: &str) -> Result<Cid, FileServeError> { 37 + let parsed_cid = Cid::try_from(cid_str).map_err(|_| FileServeError::NotFound)?; 38 + if parsed_cid.codec() != RAW_CODEC { 39 + return Err(FileServeError::NotFound); 40 + } 41 + Ok(parsed_cid) 42 + } 43 + 44 + /// Get the content type for a catalog entry. 45 + /// 46 + /// Uses the explicit content_type if set, otherwise falls back to MIME guessing. 47 + pub fn get_content_type(catalog_entry: &CatalogEntry, file_path: &Path) -> String { 48 + catalog_entry.content_type.clone().unwrap_or_else(|| { 49 + mime_guess::from_path(file_path) 50 + .first_or_octet_stream() 51 + .to_string() 52 + }) 53 + } 54 + 55 + /// Serve a file from the catalog as a streaming response. 56 + /// 57 + /// This is used by both the download and getBlob handlers. 58 + pub async fn serve_file( 59 + config: &Config, 60 + catalog_entry: &CatalogEntry, 61 + ) -> Result<Response, FileServeError> { 62 + let file_path = config.files_path.join(&catalog_entry.id); 63 + 64 + // Security: ensure path doesn't escape files directory 65 + if !file_path.starts_with(&config.files_path) { 66 + return Err(FileServeError::NotFound); 67 + } 68 + 69 + let file = File::open(&file_path).await.map_err(|e| { 70 + tracing::warn!("File not found: {} - {}", file_path.display(), e); 71 + FileServeError::NotFound 72 + })?; 73 + 74 + let metadata = file.metadata().await.map_err(|e| { 75 + tracing::error!("Failed to read file metadata: {}", e); 76 + FileServeError::Internal(e.to_string()) 77 + })?; 78 + 79 + let stream = ReaderStream::new(file); 80 + let body = Body::from_stream(stream); 81 + 82 + let content_type = get_content_type(catalog_entry, &file_path); 83 + 84 + Response::builder() 85 + .header(header::CONTENT_TYPE, content_type) 86 + .header(header::CONTENT_LENGTH, metadata.len()) 87 + .body(body) 88 + .map_err(|e| FileServeError::Internal(e.to_string())) 89 + } 90 + 91 + /// Serve a file as an attachment download with Content-Disposition header. 92 + pub async fn serve_file_as_download( 93 + config: &Config, 94 + catalog_entry: &CatalogEntry, 95 + ) -> Result<Response, FileServeError> { 96 + let file_path = config.files_path.join(&catalog_entry.id); 97 + 98 + // Security: ensure path doesn't escape files directory 99 + if !file_path.starts_with(&config.files_path) { 100 + return Err(FileServeError::NotFound); 101 + } 102 + 103 + let file = File::open(&file_path).await.map_err(|e| { 104 + tracing::warn!("File not found: {} - {}", file_path.display(), e); 105 + FileServeError::NotFound 106 + })?; 107 + 108 + let metadata = file.metadata().await.map_err(|e| { 109 + tracing::error!("Failed to read file metadata: {}", e); 110 + FileServeError::Internal(e.to_string()) 111 + })?; 112 + 113 + let stream = ReaderStream::new(file); 114 + let body = Body::from_stream(stream); 115 + 116 + let content_type = get_content_type(catalog_entry, &file_path); 117 + 118 + // Get filename and sanitize for Content-Disposition header 119 + let filename = file_path 120 + .file_name() 121 + .and_then(|n| n.to_str()) 122 + .unwrap_or(&catalog_entry.id); 123 + 124 + let sanitized_filename = sanitize_filename(filename); 125 + 126 + Response::builder() 127 + .header(header::CONTENT_TYPE, content_type) 128 + .header(header::CONTENT_LENGTH, metadata.len()) 129 + .header( 130 + header::CONTENT_DISPOSITION, 131 + format!("attachment; filename=\"{}\"", sanitized_filename), 132 + ) 133 + .body(body) 134 + .map_err(|e| FileServeError::Internal(e.to_string())) 135 + } 136 + 137 + /// Sanitize a filename for use in Content-Disposition header. 138 + /// 139 + /// Removes any characters that could cause header injection or parsing issues. 140 + fn sanitize_filename(filename: &str) -> String { 141 + let sanitized: String = filename 142 + .chars() 143 + .filter(|c| c.is_alphanumeric() || *c == '-' || *c == '_' || *c == '.') 144 + .collect(); 145 + 146 + if sanitized.is_empty() { 147 + "download".to_string() 148 + } else { 149 + sanitized 150 + } 151 + }
+5
src/lib.rs
··· 1 + //! Magazi - Content distribution platform with ATProtocol identity and cryptographic proofs. 2 + 3 + pub mod config; 4 + pub mod constants; 5 + pub mod entitlements;
+2 -4
src/main.rs
··· 1 - mod config; 2 - mod entitlements; 3 1 mod error; 4 2 mod handlers; 5 3 mod oauth_storage; ··· 21 19 }; 22 20 use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; 23 21 24 - use crate::{config::Config, state::InnerAppState}; 25 - use crate::state::AppState; 22 + use magazi::config::Config; 23 + use state::{AppState, InnerAppState}; 26 24 27 25 #[tokio::main] 28 26 async fn main() -> anyhow::Result<()> {
+4 -3
src/resolvers.rs
··· 1 1 use std::sync::Arc; 2 - use std::time::Duration; 3 2 4 3 use anyhow::Result; 4 + 5 5 use async_trait::async_trait; 6 6 use atproto_client::RecordResolver; 7 7 use atproto_client::client::Auth; ··· 10 10 use atproto_identity::model::{Document, VerificationMethod}; 11 11 use atproto_identity::resolve::{HickoryDnsResolver, InnerIdentityResolver}; 12 12 use atproto_identity::traits::{IdentityResolver, KeyResolver}; 13 + use magazi::constants::cache_ttl; 13 14 use moka::future::Cache; 14 15 use reqwest::Client; 15 16 ··· 28 29 29 30 let cache = Cache::builder() 30 31 .max_capacity(1000) 31 - .time_to_live(Duration::from_hours(2)) 32 + .time_to_live(cache_ttl::IDENTITY) 32 33 .build(); 33 34 34 35 Self { inner, cache } ··· 59 60 pub fn new(http_client: Client, identity_resolver: Arc<dyn IdentityResolver>) -> Self { 60 61 let cache = Cache::builder() 61 62 .max_capacity(500) 62 - .time_to_live(Duration::from_mins(30)) 63 + .time_to_live(cache_ttl::RECORD) 63 64 .build(); 64 65 65 66 Self {
+25 -10
src/state.rs
··· 1 1 use std::ops::Deref; 2 2 use std::sync::Arc; 3 - use std::time::Duration; 4 3 5 4 use anyhow::Result; 6 5 use atproto_identity::resolve::HickoryDnsResolver; ··· 12 11 use moka::future::Cache; 13 12 use reqwest::Client; 14 13 15 - use crate::config::Config; 16 - use crate::entitlements::EntitlementContext; 17 14 use crate::oauth_storage::InMemoryOAuthStorage; 18 15 use crate::resolvers::{CachingIdentityResolver, CachingRecordResolver}; 19 16 use crate::session::cookie_key_from_bytes; 20 17 use crate::templates::create_template_env; 18 + use magazi::config::Config; 19 + use magazi::constants::cache_ttl; 20 + use magazi::entitlements::EntitlementContext; 21 21 22 22 pub struct InnerAppState { 23 23 pub config: Config, ··· 26 26 pub oauth_storage: Arc<InMemoryOAuthStorage>, 27 27 pub identity_resolver: Arc<dyn IdentityResolver>, 28 28 pub record_resolver: Arc<CachingRecordResolver>, 29 - /// Cache of entitlement contexts by DID - 30 min TTL 29 + /// Cache of entitlement contexts by DID 30 30 pub context_cache: Cache<String, Vec<EntitlementContext>>, 31 - /// Cache of entitlement results by (DID, catalog_id) - 5 min TTL 31 + /// Cache of entitlement results by (DID, catalog_id) 32 32 pub entitlement_cache: Cache<(String, String), bool>, 33 33 pub cookie_key: Key, 34 34 pub templates: Environment<'static>, ··· 63 63 identity_resolver.clone(), 64 64 )); 65 65 66 - // Cache entitlement contexts by DID - 30 min TTL 66 + // Cache entitlement contexts by DID 67 67 let context_cache = Cache::builder() 68 68 .max_capacity(10_000) 69 - .time_to_live(Duration::from_secs(60)) // 1 minute 69 + .time_to_live(cache_ttl::CONTEXT) 70 70 .build(); 71 71 72 - // Cache entitlement results by (DID, catalog_id) - 5 min TTL 72 + // Cache entitlement results by (DID, catalog_id) 73 73 let entitlement_cache = Cache::builder() 74 74 .max_capacity(50_000) 75 - .time_to_live(Duration::from_secs(1800)) // 30 minutes 75 + .time_to_live(cache_ttl::ENTITLEMENT) 76 76 .build(); 77 77 78 78 let cookie_key = cookie_key_from_bytes(&config.cookie_key); ··· 92 92 templates, 93 93 }) 94 94 } 95 + 96 + /// Resolve the creator's handle from their DID. 97 + /// 98 + /// Returns the handle (e.g., "creator.bsky.social") or falls back to the DID 99 + /// if resolution fails. 100 + pub async fn resolve_creator_handle(&self) -> String { 101 + let creator_did = &self.config.creator_identity; 102 + self.identity_resolver 103 + .resolve(creator_did) 104 + .await 105 + .ok() 106 + .and_then(|doc| doc.also_known_as.first().cloned()) 107 + .and_then(|aka| aka.strip_prefix("at://").map(|s| s.to_string())) 108 + .unwrap_or_else(|| creator_did.clone()) 109 + } 95 110 } 96 111 97 112 impl Deref for AppState { ··· 106 121 fn from_ref(context: &AppState) -> Self { 107 122 context.0.identity_resolver.clone() 108 123 } 109 - } 124 + }