AT Protocol PDS + AppView + Tauri Desktop Client, 160-char post limit. - PDS (Rust + axum + sqlx) - Auth: createAccount, createSession, refreshSession - Records: createRecord, deleteRecord (race-safe via SELECT FOR UPDATE) - Feed: feed.like.create, feed.repost.create - Sync: getRepo, getBlocks, getLatestCommit, getRecord (with MST proof), listRepos - Identity: resolveHandle - MST: spec-conformant (at-mst crate, 27 tests) - Repo: signed commits, TID counter (monotonic, 4096 wrap safe) - AppView (Rust + axum + sqlx) - Jetstream consumer (WebSocket, exponential backoff, 38k+ events indexed) - REST API: timeline/home (graph-aware), profile, search, post (with thread hydration) - Handle-sync worker (did:plc + did:web) - JSONB embed storage + thread columns (migration 0003) - Like/repost counter cache (migration 0004) - Tauri 2 + Svelte 5 Desktop Client - System tray (Show/Compose/Quit menu) - OS notifications (tauri-plugin-notification) - Auto-update (tauri-plugin-updater, placeholder endpoint) - Window-state (tauri-plugin-window-state) - 160-char compose with live counter - Image/Link embed rendering - LocalStorage-persisted like state - Timeline with poll (prepend new posts) - Custom TitleBar (transparent, no decorations) - Orange/IBM Plex Mono maarcade design Tests: 231 Rust + 9 vitest = 240 passed.
164 lines
5.2 KiB
Rust
164 lines
5.2 KiB
Rust
//! 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<BlobInfo> {
|
|
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<Option<Bytes>> {
|
|
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<String> {
|
|
Ok(format!(
|
|
"{}/{}",
|
|
self.public_base.trim_end_matches('/'),
|
|
key
|
|
))
|
|
}
|
|
}
|
|
|
|
#[allow(dead_code)]
|
|
fn _unused_b64() {
|
|
let _ = base64::engine::general_purpose::STANDARD.encode(b"");
|
|
} |