//! S3-compatible blob storage. //! //! **MinIO-only.** The current implementation issues plain HTTP PUT / //! GET / DELETE against `${endpoint}/${key}` — which works against //! MinIO when the bucket is public-readable and the bucket has public //! ACLs enabled. It will *not* work against proper AWS S3 because AWS //! requires a `Signature V4` signature on every request. //! //! AWS support is on the roadmap (it needs an HMAC-SHA256 over the //! canonical request, signed with the access key); until then this //! module is intended for the local dev MinIO container defined in //! `docker-compose.yml`. The [`S3BlobStore::ping`] method lets the //! PDS startup path surface "MinIO unreachable" as a warning so //! operators see it before the first upload comes in. //! //! The single-PUT shape also implicitly assumes the bucket exists //! and the access key has `s3:PutObject` on it. There's no `MakeBucket` //! call here — operators are expected to provision the bucket //! out-of-band (the bundled MinIO config in `docker-compose.yml` does //! this via an init container). use anyhow::Result; use async_trait::async_trait; use at_crypto::cid::{cid_for_raw, sha256}; use base64::Engine; use bytes::Bytes; use reqwest::Client; use std::time::Duration; use tracing::warn; use super::store::{BlobInfo, BlobStore}; #[derive(Clone)] pub struct S3BlobStore { pub endpoint: String, pub region: String, pub access_key: String, pub secret_key: String, pub bucket: String, pub public_base: String, pub client: Client, } impl S3BlobStore { pub fn new( endpoint: String, region: String, access_key: String, secret_key: String, bucket: String, public_base: String, ) -> Self { Self { endpoint, region, access_key, secret_key, bucket, public_base, client: Client::builder() .timeout(Duration::from_secs(30)) .build() .unwrap(), } } /// Cheap reachability check used at PDS startup. Pings /// `${endpoint}/${bucket}` (a HEAD) and logs a warning if the /// bucket can't be reached. Returns `Ok(true)` on any HTTP /// response (including 404 — the bucket might not exist yet but /// the endpoint answered), `Ok(false)` on a network error or /// unreachable host. /// /// Best-effort: callers should not treat a non-OK ping as fatal /// because the dev setup tolerates a missing MinIO. pub async fn ping(&self) -> bool { let url = format!( "{}/{}", self.endpoint.trim_end_matches('/'), self.bucket ); match self.client.head(&url).send().await { Ok(r) => { let s = r.status(); if s.is_success() || s.as_u16() == 404 { true } else { warn!( endpoint = %self.endpoint, bucket = %self.bucket, status = %s, "s3 endpoint responded with non-success status" ); false } } Err(e) => { warn!( endpoint = %self.endpoint, bucket = %self.bucket, error = %e, "s3 endpoint unreachable; uploads will fall back to local-only storage" ); false } } } } #[async_trait] impl BlobStore for S3BlobStore { async fn put(&self, key: &str, data: Bytes, mime: &str) -> Result { let url = format!("{}/{}", self.endpoint.trim_end_matches('/'), key); let resp = self .client .put(&url) .header("x-amz-acl", "public-read") .header("Content-Type", mime) .body(data.clone()) .send() .await?; if !resp.status().is_success() { let s = resp.status(); let t = resp.text().await.unwrap_or_default(); anyhow::bail!("s3 put failed: {} {}", s, t); } let hash = sha256(&data); let cid = cid_for_raw(0x55, hash)?; Ok(BlobInfo { cid: cid.to_string(), mime_type: mime.to_string(), size: data.len() as u64, storage_key: key.to_string(), }) } async fn get(&self, key: &str) -> Result> { let url = format!("{}/{}", self.endpoint.trim_end_matches('/'), key); let resp = self.client.get(&url).send().await?; if !resp.status().is_success() { return Ok(None); } Ok(Some(resp.bytes().await?)) } async fn delete(&self, key: &str) -> Result<()> { let url = format!("{}/{}", self.endpoint.trim_end_matches('/'), key); let _ = self.client.delete(&url).send().await?; Ok(()) } async fn public_url(&self, key: &str) -> Result { Ok(format!( "{}/{}", self.public_base.trim_end_matches('/'), key )) } } #[allow(dead_code)] fn _unused_b64() { let _ = base64::engine::general_purpose::STANDARD.encode(b""); }