Select the types of activity you want to include in your feed.
Magazi is a content distribution platform that gates access to files using ATProtocol (Bluesky) identity and cryptographic proofs.
download.ngerakines.me/
···7788pub mod util_entitlements;
99pub mod util_errors;
1010+pub mod util_files;
+25-17
src/handlers/util_entitlements.rs
···99use datalogic_rs::DataLogic;
1010use serde::{Deserialize, Serialize};
11111212-use crate::config::CatalogEntry;
1313-use crate::entitlements::EntitlementContext;
1412use crate::resolvers::DidKeyResolver;
1513use crate::state::AppState;
1414+use magazi::config::CatalogEntry;
1515+use magazi::constants::lexicon;
1616+use magazi::entitlements::EntitlementContext;
16171718/// Cache key used for anonymous (unauthenticated) visitors
1819const ANONYMOUS_CACHE_KEY: &str = "";
19202121+/// A proof record (attestation) - used for both supporter and broker proofs.
2222+///
2323+/// Both `com.atprotofans.supporterProof` and `com.atprotofans.brokerProof` have
2424+/// the same structure, so we use a single generic type parameterized by the
2525+/// lexicon type string.
2026#[derive(Serialize, Deserialize, Clone, PartialEq)]
2121-struct SupporterProof {
2727+struct Proof {
2228 cid: String,
2329 signature: String,
2430 #[serde(flatten)]
2531 extra: HashMap<String, serde_json::Value>,
2632}
27333434+/// Wrapper to provide LexiconType for supporter proofs.
3535+#[derive(Serialize, Deserialize, Clone, PartialEq)]
3636+#[serde(transparent)]
3737+struct SupporterProof(Proof);
3838+2839impl LexiconType for SupporterProof {
2940 fn lexicon_type() -> &'static str {
3030- "com.atprotofans.supporterProof"
4141+ lexicon::SUPPORTER_PROOF
3142 }
3243}
33444545+/// Wrapper to provide LexiconType for broker proofs.
3446#[derive(Serialize, Deserialize, Clone, PartialEq)]
3535-struct BrokerProof {
3636- cid: String,
3737- signature: String,
3838- #[serde(flatten)]
3939- extra: HashMap<String, serde_json::Value>,
4040-}
4747+#[serde(transparent)]
4848+struct BrokerProof(Proof);
41494250impl LexiconType for BrokerProof {
4351 fn lexicon_type() -> &'static str {
4444- "com.atprotofans.brokerProof"
5252+ lexicon::BROKER_PROOF
4553 }
4654}
4755···67756876impl LexiconType for SupporterRecord {
6977 fn lexicon_type() -> &'static str {
7070- "com.atprotofans.supporter"
7878+ lexicon::SUPPORTER
7179 }
7280}
7381···111119 // even if the user has no supporter records.
112120 let mut contexts = vec![EntitlementContext::authenticated(
113121 did.to_string(),
114114- handle.map(|s| s.to_string()),
122122+ handle.map(ToString::to_string),
115123 )];
116124117125 let params = ListRecordsParams::new().limit(100);
···121129 &Auth::None,
122130 pds_url,
123131 did.to_string(),
124124- "com.atprotofans.supporter".to_string(),
132132+ lexicon::SUPPORTER.to_string(),
125133 params,
126134 )
127135 .await
···138146 let supporter_proof_ref = find_strong_ref(
139147 &record.value,
140148 &state.config.creator_identity,
141141- "com.atprotofans.supporterProof",
149149+ lexicon::SUPPORTER_PROOF,
142150 );
143151144152 // Find StrongRef for brokerProof from broker
145153 let broker_proof_ref = find_strong_ref(
146154 &record.value,
147155 &state.config.broker_identity,
148148- "com.atprotofans.brokerProof",
156156+ lexicon::BROKER_PROOF,
149157 );
150158151159 // Skip records that don't have both StrongRefs
···182190183191 contexts.push(EntitlementContext::with_supporter(
184192 did.to_string(),
185185- handle.map(|s| s.to_string()),
193193+ handle.map(ToString::to_string),
186194 supporter_json,
187195 sp,
188196 bp,
+151
src/handlers/util_files.rs
···11+//! Shared utilities for serving files from the catalog.
22+33+use std::path::Path;
44+55+use axum::{
66+ body::Body,
77+ http::{StatusCode, header},
88+ response::Response,
99+};
1010+use cid::Cid;
1111+use tokio::fs::File;
1212+use tokio_util::io::ReaderStream;
1313+1414+use magazi::config::{CatalogEntry, Config};
1515+use magazi::constants::RAW_CODEC;
1616+1717+/// Error type for file serving operations.
1818+#[derive(Debug)]
1919+pub enum FileServeError {
2020+ /// The requested CID is invalid or not found.
2121+ NotFound,
2222+ /// Internal error while reading the file.
2323+ Internal(String),
2424+}
2525+2626+impl From<FileServeError> for StatusCode {
2727+ fn from(err: FileServeError) -> Self {
2828+ match err {
2929+ FileServeError::NotFound => StatusCode::NOT_FOUND,
3030+ FileServeError::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
3131+ }
3232+ }
3333+}
3434+3535+/// Validate that a CID string is a valid raw CID.
3636+pub fn validate_raw_cid(cid_str: &str) -> Result<Cid, FileServeError> {
3737+ let parsed_cid = Cid::try_from(cid_str).map_err(|_| FileServeError::NotFound)?;
3838+ if parsed_cid.codec() != RAW_CODEC {
3939+ return Err(FileServeError::NotFound);
4040+ }
4141+ Ok(parsed_cid)
4242+}
4343+4444+/// Get the content type for a catalog entry.
4545+///
4646+/// Uses the explicit content_type if set, otherwise falls back to MIME guessing.
4747+pub fn get_content_type(catalog_entry: &CatalogEntry, file_path: &Path) -> String {
4848+ catalog_entry.content_type.clone().unwrap_or_else(|| {
4949+ mime_guess::from_path(file_path)
5050+ .first_or_octet_stream()
5151+ .to_string()
5252+ })
5353+}
5454+5555+/// Serve a file from the catalog as a streaming response.
5656+///
5757+/// This is used by both the download and getBlob handlers.
5858+pub async fn serve_file(
5959+ config: &Config,
6060+ catalog_entry: &CatalogEntry,
6161+) -> Result<Response, FileServeError> {
6262+ let file_path = config.files_path.join(&catalog_entry.id);
6363+6464+ // Security: ensure path doesn't escape files directory
6565+ if !file_path.starts_with(&config.files_path) {
6666+ return Err(FileServeError::NotFound);
6767+ }
6868+6969+ let file = File::open(&file_path).await.map_err(|e| {
7070+ tracing::warn!("File not found: {} - {}", file_path.display(), e);
7171+ FileServeError::NotFound
7272+ })?;
7373+7474+ let metadata = file.metadata().await.map_err(|e| {
7575+ tracing::error!("Failed to read file metadata: {}", e);
7676+ FileServeError::Internal(e.to_string())
7777+ })?;
7878+7979+ let stream = ReaderStream::new(file);
8080+ let body = Body::from_stream(stream);
8181+8282+ let content_type = get_content_type(catalog_entry, &file_path);
8383+8484+ Response::builder()
8585+ .header(header::CONTENT_TYPE, content_type)
8686+ .header(header::CONTENT_LENGTH, metadata.len())
8787+ .body(body)
8888+ .map_err(|e| FileServeError::Internal(e.to_string()))
8989+}
9090+9191+/// Serve a file as an attachment download with Content-Disposition header.
9292+pub async fn serve_file_as_download(
9393+ config: &Config,
9494+ catalog_entry: &CatalogEntry,
9595+) -> Result<Response, FileServeError> {
9696+ let file_path = config.files_path.join(&catalog_entry.id);
9797+9898+ // Security: ensure path doesn't escape files directory
9999+ if !file_path.starts_with(&config.files_path) {
100100+ return Err(FileServeError::NotFound);
101101+ }
102102+103103+ let file = File::open(&file_path).await.map_err(|e| {
104104+ tracing::warn!("File not found: {} - {}", file_path.display(), e);
105105+ FileServeError::NotFound
106106+ })?;
107107+108108+ let metadata = file.metadata().await.map_err(|e| {
109109+ tracing::error!("Failed to read file metadata: {}", e);
110110+ FileServeError::Internal(e.to_string())
111111+ })?;
112112+113113+ let stream = ReaderStream::new(file);
114114+ let body = Body::from_stream(stream);
115115+116116+ let content_type = get_content_type(catalog_entry, &file_path);
117117+118118+ // Get filename and sanitize for Content-Disposition header
119119+ let filename = file_path
120120+ .file_name()
121121+ .and_then(|n| n.to_str())
122122+ .unwrap_or(&catalog_entry.id);
123123+124124+ let sanitized_filename = sanitize_filename(filename);
125125+126126+ Response::builder()
127127+ .header(header::CONTENT_TYPE, content_type)
128128+ .header(header::CONTENT_LENGTH, metadata.len())
129129+ .header(
130130+ header::CONTENT_DISPOSITION,
131131+ format!("attachment; filename=\"{}\"", sanitized_filename),
132132+ )
133133+ .body(body)
134134+ .map_err(|e| FileServeError::Internal(e.to_string()))
135135+}
136136+137137+/// Sanitize a filename for use in Content-Disposition header.
138138+///
139139+/// Removes any characters that could cause header injection or parsing issues.
140140+fn sanitize_filename(filename: &str) -> String {
141141+ let sanitized: String = filename
142142+ .chars()
143143+ .filter(|c| c.is_alphanumeric() || *c == '-' || *c == '_' || *c == '.')
144144+ .collect();
145145+146146+ if sanitized.is_empty() {
147147+ "download".to_string()
148148+ } else {
149149+ sanitized
150150+ }
151151+}
+5
src/lib.rs
···11+//! Magazi - Content distribution platform with ATProtocol identity and cryptographic proofs.
22+33+pub mod config;
44+pub mod constants;
55+pub mod entitlements;