Something went wrong. Try again.
Paper Mario mod hosting site - AT Protocol appview starhaven.dev
appview
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232//! 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. With `?w=`, one of//! [`WIDTHS`], an image comes back as WebP no wider than that, which is far//! smaller than the image as uploaded.//!//! `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 std::fmt::Display;use std::io::Cursor;
use axum::body::Bytes;use axum::extract::{Path, Query, 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 image::codecs::png::PngDecoder;use image::codecs::webp::WebPDecoder;use image::ImageFormat;use serde::{Deserialize, 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<AppState> { Router::new() .route("/blob/{did}/{cid}", get(handle_get_blob)) .route("/blob", post(handle_post_blob))}
/// The URL `did`'s blob `cid` is served at.pub fn url(did: impl Display, cid: impl Display) -> String { format!("/blob/{did}/{cid}")}
/// The widths, in pixels, that `GET /blob` scales an image down to. Only/// these, so a cache holds no more than a few copies of each image.pub const WIDTHS: [u32; 5] = [128, 256, 512, 1024, 2048];
/// `url`, a blob's [`url`], scaled down to `width`, one of [`WIDTHS`].pub fn scaled_url(url: &str, width: u32) -> String { format!("{url}?w={width}")}
/// A `srcset` offering `url`, a blob's [`url`], at each of [`WIDTHS`], so a/// browser can pick the smallest that's sharp on its screen.pub fn srcset(url: &str) -> String { WIDTHS .map(|width| format!("{} {width}w", scaled_url(url, width))) .join(", ")}
#[derive(Deserialize)]struct BlobQuery { w: Option<u32>,}
/// 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)>, Query(query): Query<BlobQuery>, State(state): State<AppState>,) -> Result<Response, AppError> { if query.w.is_some_and(|width| !WIDTHS.contains(&width)) { return Err(AppError::NotFound); } let (content_type, bytes) = fetch(&state, &did, &cid) .await .map_err(AppError::Internal)? .ok_or(AppError::NotFound)?; let scaled = match query.w { Some(width) => { let original = bytes.clone(); tokio::task::spawn_blocking(move || scale(&original, width)) .await .map_err(|err| AppError::Internal(err.into()))? } None => None, }; let (content_type, bytes) = match scaled { Some(webp) => (HeaderValue::from_static("image/webp"), webp.into()), None => (content_type, bytes), };
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<Option<(HeaderValue, Bytes)>> { 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?)))}
/// `image` as WebP, scaled down to `width` if it's wider. None if it isn't a/// PNG, JPEG, or WebP, or is animated, since only its first frame would be/// kept.fn scale(image: &[u8], width: u32) -> Option<Vec<u8>> { let format = image::guess_format(image).ok()?; let animated = match format { ImageFormat::Jpeg => false, ImageFormat::Png => PngDecoder::new(Cursor::new(image)).ok()?.is_apng().ok()?, ImageFormat::WebP => WebPDecoder::new(Cursor::new(image)).ok()?.has_animation(), _ => return None, }; if animated { return None; }
let mut decoded = image::load_from_memory_with_format(image, format).ok()?; if decoded.width() > width { decoded = decoded.thumbnail(width, u32::MAX); } let (width, height) = (decoded.width(), decoded.height()); let webp = if decoded.color().has_alpha() { webp::Encoder::from_rgba(&decoded.to_rgba8(), width, height).encode(80.0) } else { webp::Encoder::from_rgb(&decoded.to_rgb8(), width, height).encode(80.0) }; Some(webp.to_vec())}
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<AppState>, session: SessionCookie, headers: HeaderMap, bytes: Bytes,) -> Result<Json<BlobRef>, 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, }))}
#[cfg(test)]mod tests { use super::*;
#[test] fn scales_a_wide_png_down_to_webp() { let mut png = Vec::new(); image::RgbImage::new(1000, 500) .write_to(&mut Cursor::new(&mut png), ImageFormat::Png) .unwrap();
let webp = scale(&png, 256).unwrap();
let scaled = image::load_from_memory_with_format(&webp, ImageFormat::WebP).unwrap(); assert_eq!((scaled.width(), scaled.height()), (256, 128)); }
#[test] fn leaves_what_it_cannot_scale_alone() { assert!(scale(b"not an image", 256).is_none()); }}