//! Proxies blobs from an author's own PDS. //! //! `GET /blob/{did}/{cid}` fetches a blob so pages can link to it without //! knowing which PDS the author's data lives on. //! //! `POST /blob` uploads a blob to the logged-in user's PDS and returns its //! blob ref as JSON. The caller still has to write it into a record. use axum::body::Bytes; use axum::extract::{Path, State}; use axum::http::header::{CACHE_CONTROL, CONTENT_TYPE}; use axum::http::{HeaderMap, HeaderValue}; use axum::response::{IntoResponse, Response}; use axum::routing::{get, post}; use axum::{Json, Router}; use serde::Serialize; use crate::atproto::actor::resolve_document; use crate::atproto::pds; use crate::error::AppError; use crate::oauth::session::SessionCookie; use crate::state::AppState; pub fn router() -> Router { Router::new() .route("/blob/{did}/{cid}", get(handle_get_blob)) .route("/blob", post(handle_post_blob)) } /// A blob is addressed by its own content hash, so once fetched it can never /// change under the same URL. const CACHE_FOREVER: &str = "public, max-age=31536000, immutable"; async fn handle_get_blob( Path((did, cid)): Path<(String, String)>, State(state): State, ) -> Result { let (content_type, bytes) = fetch(&state, &did, &cid) .await .map_err(AppError::Internal)? .ok_or(AppError::NotFound)?; Ok(( [ (CONTENT_TYPE, content_type), ( CACHE_CONTROL, CACHE_FOREVER.parse().expect("valid header value"), ), // Prevent other origins from embedding these blobs. ( "cross-origin-resource-policy" .parse() .expect("valid header name"), "same-origin".parse().expect("valid header value"), ), ], bytes, ) .into_response()) } /// Fetches one of `did`'s blobs from their PDS, with its content type, or /// `None` if they have no PDS or it has no such blob. pub async fn fetch( state: &AppState, did: &str, cid: &str, ) -> anyhow::Result> { let document = resolve_document(state, did).await?; let Some(pds_endpoint) = document.pds_endpoints().first().map(|s| s.to_string()) else { return Ok(None); }; let url = format!("{pds_endpoint}/xrpc/com.atproto.sync.getBlob?did={did}&cid={cid}"); let response = state.http_client.get(&url).send().await?; if !response.status().is_success() { return Ok(None); } let content_type = response .headers() .get(CONTENT_TYPE) .cloned() .unwrap_or_else(|| HeaderValue::from_static("application/octet-stream")); Ok(Some((content_type, response.bytes().await?))) } const MAX_BLOB_BYTES: usize = 10 * 1024 * 1024; #[derive(Serialize)] #[serde(rename_all = "camelCase")] pub struct BlobRef { pub cid: String, pub mime_type: String, pub size: u64, } async fn handle_post_blob( State(state): State, session: SessionCookie, headers: HeaderMap, bytes: Bytes, ) -> Result, AppError> { let mime_type = headers .get(CONTENT_TYPE) .and_then(|value| value.to_str().ok()) .unwrap_or("application/octet-stream"); if bytes.is_empty() { return Err(AppError::BadRequest("The file is empty".to_string())); } if bytes.len() > MAX_BLOB_BYTES { return Err(AppError::BadRequest( "The file is too large (10MB limit)".to_string(), )); } let blob = pds::upload_blob(&state, &session, bytes, mime_type) .await .map_err(AppError::Internal)?; Ok(Json(BlobRef { cid: blob.inner.ref_.link, mime_type: blob.inner.mime_type, size: blob.inner.size, })) }