maarcadetweet: initial commit
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.
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
[package]
|
||||
name = "pds-server"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
description = "maarcadetweet PDS server (bin)"
|
||||
|
||||
[lints.rust]
|
||||
unsafe_code = "forbid"
|
||||
|
||||
[[bin]]
|
||||
name = "pds-server"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
tokio = { workspace = true }
|
||||
axum = { workspace = true }
|
||||
tower = { workspace = true }
|
||||
tower-http = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
tracing-subscriber = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
sqlx = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
at-shared = { workspace = true }
|
||||
at-crypto = { workspace = true }
|
||||
at-identity = { workspace = true }
|
||||
at-lexicon = { workspace = true }
|
||||
at-repo = { workspace = true }
|
||||
at-mst = { workspace = true }
|
||||
at-blob = { workspace = true }
|
||||
argon2 = { workspace = true }
|
||||
ciborium = { workspace = true }
|
||||
hex = { workspace = true }
|
||||
rand = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
bytes = { workspace = true }
|
||||
cid = { workspace = true }
|
||||
k256 = { workspace = true }
|
||||
p256 = { workspace = true }
|
||||
unsigned-varint = "0.8"
|
||||
url = { workspace = true }
|
||||
reqwest = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { workspace = true }
|
||||
reqwest = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
cid = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
hex = { workspace = true }
|
||||
sqlx = { workspace = true }
|
||||
at-crypto = { workspace = true }
|
||||
@@ -0,0 +1,186 @@
|
||||
//! PDS-side client that pushes local commits into the AppView's
|
||||
//! `/internal/ingest-commit` endpoint.
|
||||
//!
|
||||
//! Why
|
||||
//!
|
||||
//! The AppView normally learns about a record via the Jetstream
|
||||
//! round-trip. That's a few seconds of latency and a second moving
|
||||
//! part to debug when it's down. Pushing directly from the PDS makes
|
||||
//! the user's own writes visible in their own timeline the instant
|
||||
//! they hit `POST /xrpc/com.atproto.repo.createRecord`.
|
||||
//!
|
||||
//! Failure model
|
||||
//!
|
||||
//! The push is best-effort. We never block a record write on the
|
||||
//! AppView being reachable — if the AppView is down, the record is
|
||||
//! already committed in the PDS's repo + blockstore, and the next
|
||||
//! Jetstream replay will eventually pick it up. The push is logged
|
||||
//! so an operator can detect persistent AppView outages.
|
||||
//!
|
||||
//! The PDS and AppView share a `X-Ingest-Secret` token (configured via
|
||||
//! `APPVIEW_INGEST_SECRET` on both sides). When unset on the AppView
|
||||
//! side the endpoint accepts anonymous requests (dev mode), so the
|
||||
//! client doesn't bother sending the header in that case either.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use reqwest::header::HeaderMap;
|
||||
use reqwest::Client;
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
use std::time::Duration;
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct IngestCommitBody<'a> {
|
||||
did: &'a str,
|
||||
collection: &'a str,
|
||||
action: &'a str,
|
||||
rkey: &'a str,
|
||||
cid: Option<&'a str>,
|
||||
record: Option<&'a Value>,
|
||||
subject_did: Option<&'a str>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppViewPushClient {
|
||||
base_url: String,
|
||||
secret: Option<String>,
|
||||
client: Client,
|
||||
}
|
||||
|
||||
impl AppViewPushClient {
|
||||
pub fn new(base_url: impl Into<String>, secret: Option<String>) -> Self {
|
||||
Self {
|
||||
base_url: base_url.into(),
|
||||
secret,
|
||||
client: Client::builder()
|
||||
.timeout(Duration::from_secs(5))
|
||||
.build()
|
||||
.unwrap(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Push a `create` event to the AppView. `record` should be the full
|
||||
/// AT-Protocol record value as JSON — the AppView's indexer reads
|
||||
/// `embed` / `reply` off it, which is why we can't just send the CID.
|
||||
///
|
||||
/// Returns `Ok(true)` if the AppView applied the commit, `Ok(false)`
|
||||
/// if it returned a non-2xx status (logged as warn), and `Err(_)` if
|
||||
/// the request itself failed. The caller should treat any non-Ok as
|
||||
/// "the AppView will learn about this via Jetstream eventually".
|
||||
pub async fn push_create(
|
||||
&self,
|
||||
did: &str,
|
||||
collection: &str,
|
||||
rkey: &str,
|
||||
cid: &str,
|
||||
record: &Value,
|
||||
) -> Result<bool> {
|
||||
self.push(
|
||||
did,
|
||||
collection,
|
||||
"create",
|
||||
rkey,
|
||||
Some(cid),
|
||||
Some(record),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn push_delete(
|
||||
&self,
|
||||
did: &str,
|
||||
collection: &str,
|
||||
rkey: &str,
|
||||
) -> Result<bool> {
|
||||
self.push(did, collection, "delete", rkey, None, None, None)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn push_follow_create(
|
||||
&self,
|
||||
did: &str,
|
||||
rkey: &str,
|
||||
subject_did: &str,
|
||||
record: &Value,
|
||||
) -> Result<bool> {
|
||||
self.push(
|
||||
did,
|
||||
"app.bsky.graph.follow",
|
||||
"create",
|
||||
rkey,
|
||||
None,
|
||||
Some(record),
|
||||
Some(subject_did),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn push_follow_delete(
|
||||
&self,
|
||||
did: &str,
|
||||
rkey: &str,
|
||||
subject_did: &str,
|
||||
) -> Result<bool> {
|
||||
self.push(
|
||||
did,
|
||||
"app.bsky.graph.follow",
|
||||
"delete",
|
||||
rkey,
|
||||
None,
|
||||
None,
|
||||
Some(subject_did),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn push(
|
||||
&self,
|
||||
did: &str,
|
||||
collection: &str,
|
||||
action: &str,
|
||||
rkey: &str,
|
||||
cid: Option<&str>,
|
||||
record: Option<&Value>,
|
||||
subject_did: Option<&str>,
|
||||
) -> Result<bool> {
|
||||
let url = format!("{}/internal/ingest-commit", self.base_url);
|
||||
let body = IngestCommitBody {
|
||||
did,
|
||||
collection,
|
||||
action,
|
||||
rkey,
|
||||
cid,
|
||||
record,
|
||||
subject_did,
|
||||
};
|
||||
let mut req = self.client.post(&url).json(&body);
|
||||
if let Some(secret) = self.secret.as_deref() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
"x-ingest-secret",
|
||||
secret.parse().context("invalid ingest secret header value")?,
|
||||
);
|
||||
req = req.headers(headers);
|
||||
}
|
||||
let resp = req
|
||||
.send()
|
||||
.await
|
||||
.context("appview: ingest-commit send failed")?;
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
tracing::warn!(
|
||||
status = status.as_u16(),
|
||||
body,
|
||||
did,
|
||||
collection,
|
||||
action,
|
||||
rkey,
|
||||
"appview: ingest-commit returned non-success"
|
||||
);
|
||||
return Ok(false);
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,479 @@
|
||||
//! CAR v1 writer for atproto sync endpoints.
|
||||
//!
|
||||
//! The on-the-wire format follows
|
||||
//! <https://ipld.io/specs/transport/car/carv1/> and is the same format used by
|
||||
//! `com.atproto.sync.getRepo`, `getBlocks`, `getLatestCommit` and
|
||||
//! `getRecord`.
|
||||
//!
|
||||
//! Layout:
|
||||
//!
|
||||
//! ```text
|
||||
//! [ varint: header_len | DAG-CBOR header block ] (header)
|
||||
//! [ varint: section_len | CID | block bytes ] (block 1)
|
||||
//! [ varint: section_len | CID | block bytes ] (block 2)
|
||||
//! ...
|
||||
//! ```
|
||||
//!
|
||||
//! The header is `{ version: 1, roots: [CID, ...] }` encoded as DAG-CBOR. In
|
||||
//! DAG-CBOR CID links carry the IANA-registered CBOR tag `42`, which the
|
||||
//! `ciborium` crate does not emit for `cid::Cid` (it uses serde newtype-struct
|
||||
//! tagging instead). We hand-encode the header bytes to keep the file
|
||||
//! spec-compliant: a `Map(2)` with text keys `"version"` and `"roots"`, an
|
||||
//! unsigned int `1` for the version, and a tagged byte string for each root
|
||||
//! CID.
|
||||
//!
|
||||
//! Per the spec, CAR v1 stores the raw CID bytes (varint version + codec +
|
||||
//! multihash) prefixed to every block, with a leading varint giving the total
|
||||
//! length of the section (CID + block).
|
||||
|
||||
use anyhow::Result;
|
||||
use cid::Cid;
|
||||
|
||||
/// Encode an unsigned CBOR head (major type in upper 3 bits) with a value.
|
||||
///
|
||||
/// Supports values up to `u32::MAX` which is more than enough for any realistic
|
||||
/// header or array length.
|
||||
fn cbor_head(out: &mut Vec<u8>, major: u8, n: u64) {
|
||||
let m = (major & 0x07) << 5;
|
||||
if n < 24 {
|
||||
out.push(m | n as u8);
|
||||
} else if n < 0x100 {
|
||||
out.push(m | 24);
|
||||
out.push(n as u8);
|
||||
} else if n < 0x10000 {
|
||||
out.push(m | 25);
|
||||
out.push((n >> 8) as u8);
|
||||
out.push(n as u8);
|
||||
} else if n < 0x100_0000 {
|
||||
out.push(m | 26);
|
||||
out.push((n >> 16) as u8);
|
||||
out.push((n >> 8) as u8);
|
||||
out.push(n as u8);
|
||||
} else {
|
||||
out.push(m | 27);
|
||||
out.push((n >> 24) as u8);
|
||||
out.push((n >> 16) as u8);
|
||||
out.push((n >> 8) as u8);
|
||||
out.push(n as u8);
|
||||
}
|
||||
}
|
||||
|
||||
/// Append a CBOR text string.
|
||||
fn cbor_text(out: &mut Vec<u8>, s: &str) {
|
||||
cbor_head(out, 3, s.len() as u64);
|
||||
out.extend_from_slice(s.as_bytes());
|
||||
}
|
||||
|
||||
/// Append a CBOR byte string.
|
||||
fn cbor_bytes(out: &mut Vec<u8>, b: &[u8]) {
|
||||
cbor_head(out, 2, b.len() as u64);
|
||||
out.extend_from_slice(b);
|
||||
}
|
||||
|
||||
/// Append a CBOR tag wrapping the following value.
|
||||
fn cbor_tag(out: &mut Vec<u8>, tag: u64) {
|
||||
cbor_head(out, 6, tag);
|
||||
}
|
||||
|
||||
/// Encode the CAR v1 DAG-CBOR header `{ version: 1, roots: [CID, ...] }`.
|
||||
///
|
||||
/// CIDs are encoded as `tag(42) + bytes(<raw-cid-bytes>)` per the DAG-CBOR
|
||||
/// spec. This is the canonical IPLD CID-link form.
|
||||
pub fn encode_header(roots: &[Cid]) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
// Map(2): { "version": 1, "roots": [...] }
|
||||
cbor_head(&mut out, 5, 2);
|
||||
cbor_text(&mut out, "version");
|
||||
cbor_head(&mut out, 0, 1);
|
||||
cbor_text(&mut out, "roots");
|
||||
cbor_head(&mut out, 4, roots.len() as u64);
|
||||
for cid in roots {
|
||||
cbor_tag(&mut out, 42);
|
||||
cbor_bytes(&mut out, &cid.to_bytes());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Append a varint to `out` using LEB128 unsigned encoding.
|
||||
fn write_varint(out: &mut Vec<u8>, n: u64) {
|
||||
let mut buf = unsigned_varint::encode::u64_buffer();
|
||||
let bytes = unsigned_varint::encode::u64(n, &mut buf);
|
||||
out.extend_from_slice(bytes);
|
||||
}
|
||||
|
||||
/// A single (CID, block_bytes) pair held in a [`CarWriter`].
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Block {
|
||||
pub cid: Cid,
|
||||
pub data: Vec<u8>,
|
||||
}
|
||||
|
||||
/// Buffer for assembling a CAR v1 file.
|
||||
///
|
||||
/// Usage:
|
||||
///
|
||||
/// ```ignore
|
||||
/// let mut w = CarWriter::new();
|
||||
/// w.append(cid_a, &block_a);
|
||||
/// w.append(cid_b, &block_b);
|
||||
/// let bytes = w.finish(&[head_commit_cid]);
|
||||
/// ```
|
||||
///
|
||||
/// The header's `roots` is provided at `finish` time so callers can defer
|
||||
/// deciding what the root is until all blocks are queued.
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct CarWriter {
|
||||
blocks: Vec<Block>,
|
||||
}
|
||||
|
||||
impl CarWriter {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Append a (CID, block) pair. Duplicate CIDs are de-duplicated: the first
|
||||
/// occurrence wins. CAR v1 allows duplicate blocks in principle but for
|
||||
/// repo exports the spec says the root CID is unique and our callers don't
|
||||
/// need to write the same block twice.
|
||||
pub fn append(&mut self, cid: Cid, data: &[u8]) {
|
||||
if self.blocks.iter().any(|b| b.cid == cid) {
|
||||
return;
|
||||
}
|
||||
self.blocks.push(Block {
|
||||
cid,
|
||||
data: data.to_vec(),
|
||||
});
|
||||
}
|
||||
|
||||
/// Finalize the CAR stream. Writes the header followed by every queued
|
||||
/// block as a length-prefixed CID+data section.
|
||||
pub fn finish(&self, roots: &[Cid]) -> Vec<u8> {
|
||||
let header = encode_header(roots);
|
||||
let mut out = Vec::with_capacity(header.len() + self.blocks.len() * 64);
|
||||
write_varint(&mut out, header.len() as u64);
|
||||
out.extend_from_slice(&header);
|
||||
for b in &self.blocks {
|
||||
let cid_bytes = b.cid.to_bytes();
|
||||
// Section length is the combined length of CID bytes + block data.
|
||||
let section_len = (cid_bytes.len() + b.data.len()) as u64;
|
||||
write_varint(&mut out, section_len);
|
||||
out.extend_from_slice(&cid_bytes);
|
||||
out.extend_from_slice(&b.data);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn len(&self) -> usize {
|
||||
self.blocks.len()
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.blocks.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
// -- minimal CAR reader (for tests / debug) --------------------------------
|
||||
|
||||
/// Header parsed out of a CAR file. `roots` are kept as raw CID byte vectors
|
||||
/// so callers can re-parse them however they like.
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CarHeader {
|
||||
pub version: u64,
|
||||
pub roots: Vec<Cid>,
|
||||
}
|
||||
|
||||
/// A block parsed from a CAR file.
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CarBlock {
|
||||
pub cid: Cid,
|
||||
pub data: Vec<u8>,
|
||||
}
|
||||
|
||||
/// Parse a CAR v1 file. Returns the header and the list of blocks in order.
|
||||
///
|
||||
/// This is intentionally minimal — it does not validate CIDs, codec, or
|
||||
/// DAG-CBOR, only structure. Used in unit/integration tests to round-trip
|
||||
/// CAR files we just produced.
|
||||
#[allow(dead_code)]
|
||||
pub fn parse(bytes: &[u8]) -> Result<(CarHeader, Vec<CarBlock>)> {
|
||||
let mut p = 0usize;
|
||||
|
||||
let (header_len, n) = read_varint(bytes, p)?;
|
||||
p += n;
|
||||
let header_end = p + header_len as usize;
|
||||
if header_end > bytes.len() {
|
||||
anyhow::bail!("CAR header length exceeds file");
|
||||
}
|
||||
let header_bytes = &bytes[p..header_end];
|
||||
let header = decode_header(header_bytes)?;
|
||||
p = header_end;
|
||||
|
||||
let mut blocks = Vec::new();
|
||||
while p < bytes.len() {
|
||||
let (section_len, n) = read_varint(bytes, p)?;
|
||||
p += n;
|
||||
let section_end = p + section_len as usize;
|
||||
if section_end > bytes.len() {
|
||||
anyhow::bail!("CAR section length exceeds file at offset {}", p - n);
|
||||
}
|
||||
let section = &bytes[p..section_end];
|
||||
let (cid, data) = read_section(section)?;
|
||||
blocks.push(CarBlock { cid, data });
|
||||
p = section_end;
|
||||
}
|
||||
|
||||
Ok((header, blocks))
|
||||
}
|
||||
|
||||
fn read_varint(bytes: &[u8], offset: usize) -> Result<(u64, usize)> {
|
||||
let mut value: u64 = 0;
|
||||
let mut shift = 0u32;
|
||||
let mut i = offset;
|
||||
loop {
|
||||
if i >= bytes.len() {
|
||||
anyhow::bail!("varint extends past end of input");
|
||||
}
|
||||
let b = bytes[i];
|
||||
i += 1;
|
||||
value |= ((b & 0x7f) as u64) << shift;
|
||||
if b & 0x80 == 0 {
|
||||
return Ok((value, i - offset));
|
||||
}
|
||||
shift += 7;
|
||||
if shift >= 64 {
|
||||
anyhow::bail!("varint too long");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn read_section(section: &[u8]) -> Result<(Cid, Vec<u8>)> {
|
||||
let cid = Cid::read_bytes(section)
|
||||
.map_err(|e| anyhow::anyhow!("invalid CID in CAR section: {e}"))?;
|
||||
let cid_len = cid.encoded_len();
|
||||
if cid_len > section.len() {
|
||||
anyhow::bail!("section too short for CID");
|
||||
}
|
||||
let data = section[cid_len..].to_vec();
|
||||
Ok((cid, data))
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn decode_header(bytes: &[u8]) -> Result<CarHeader> {
|
||||
// The header is a tiny DAG-CBOR map. We decode only the structure we emit.
|
||||
let mut p = 0usize;
|
||||
let (n_items, consumed) = read_head_and_uint(bytes, p, 5)?;
|
||||
p += consumed;
|
||||
if n_items != 2 {
|
||||
anyhow::bail!("CAR header must have 2 keys, got {n_items}");
|
||||
}
|
||||
|
||||
let mut version: Option<u64> = None;
|
||||
let mut roots: Vec<Cid> = Vec::new();
|
||||
|
||||
for _ in 0..2 {
|
||||
let (key, consumed) = read_head_and_text(bytes, p)?;
|
||||
p += consumed;
|
||||
match key.as_str() {
|
||||
"version" => {
|
||||
let (v, c) = read_head_and_uint(bytes, p, 0)?;
|
||||
p += c;
|
||||
version = Some(v);
|
||||
}
|
||||
"roots" => {
|
||||
let (n_roots, c) = read_head_and_uint(bytes, p, 4)?;
|
||||
p += c;
|
||||
for _ in 0..n_roots {
|
||||
// tag(42)
|
||||
let (_, c) = read_head_and_uint(bytes, p, 6)?;
|
||||
p += c;
|
||||
// bytes
|
||||
let (n, c) = read_head_and_uint(bytes, p, 2)?;
|
||||
p += c;
|
||||
if p + n as usize > bytes.len() {
|
||||
anyhow::bail!("CAR root CID bytes exceed header");
|
||||
}
|
||||
let cid_bytes = &bytes[p..p + n as usize];
|
||||
let cid = Cid::read_bytes(cid_bytes)
|
||||
.map_err(|e| anyhow::anyhow!("invalid root CID bytes: {e}"))?;
|
||||
p += n as usize;
|
||||
roots.push(cid);
|
||||
}
|
||||
}
|
||||
other => anyhow::bail!("unknown CAR header key `{other}`"),
|
||||
}
|
||||
}
|
||||
|
||||
Ok(CarHeader {
|
||||
version: version.unwrap_or(0),
|
||||
roots,
|
||||
})
|
||||
}
|
||||
|
||||
/// Read a CBOR head (single byte for value < 24, otherwise head + varint
|
||||
/// extension) and decode its value. Validates that the major type is
|
||||
/// `expected_major`. Returns the decoded value and the number of bytes
|
||||
/// consumed (head + any extension).
|
||||
#[allow(dead_code)]
|
||||
fn read_head_and_uint(
|
||||
bytes: &[u8],
|
||||
offset: usize,
|
||||
expected_major: u8,
|
||||
) -> Result<(u64, usize)> {
|
||||
if offset >= bytes.len() {
|
||||
anyhow::bail!("CBOR read past end of input");
|
||||
}
|
||||
let first = bytes[offset];
|
||||
let major = first >> 5;
|
||||
if major != expected_major {
|
||||
anyhow::bail!(
|
||||
"expected CBOR major {}, got {}",
|
||||
expected_major,
|
||||
major
|
||||
);
|
||||
}
|
||||
let low = first & 0x1f;
|
||||
let (value, extra) = match low {
|
||||
0..=23 => (low as u64, 0usize),
|
||||
24 => {
|
||||
if offset + 2 > bytes.len() {
|
||||
anyhow::bail!("truncated CBOR uint8");
|
||||
}
|
||||
(bytes[offset + 1] as u64, 1)
|
||||
}
|
||||
25 => {
|
||||
if offset + 3 > bytes.len() {
|
||||
anyhow::bail!("truncated CBOR uint16");
|
||||
}
|
||||
(
|
||||
((bytes[offset + 1] as u64) << 8) | (bytes[offset + 2] as u64),
|
||||
2,
|
||||
)
|
||||
}
|
||||
26 => {
|
||||
if offset + 5 > bytes.len() {
|
||||
anyhow::bail!("truncated CBOR uint32");
|
||||
}
|
||||
let n = ((bytes[offset + 1] as u64) << 24)
|
||||
| ((bytes[offset + 2] as u64) << 16)
|
||||
| ((bytes[offset + 3] as u64) << 8)
|
||||
| (bytes[offset + 4] as u64);
|
||||
(n, 4)
|
||||
}
|
||||
27 => {
|
||||
if offset + 9 > bytes.len() {
|
||||
anyhow::bail!("truncated CBOR uint64");
|
||||
}
|
||||
let mut n = 0u64;
|
||||
for i in 0..8 {
|
||||
n = (n << 8) | (bytes[offset + 1 + i] as u64);
|
||||
}
|
||||
(n, 8)
|
||||
}
|
||||
other => anyhow::bail!("unsupported CBOR uint tag {other}"),
|
||||
};
|
||||
Ok((value, 1 + extra))
|
||||
}
|
||||
|
||||
/// Read a CBOR text string with major type 3, returning the string and the
|
||||
/// total number of bytes consumed.
|
||||
#[allow(dead_code)]
|
||||
fn read_head_and_text(
|
||||
bytes: &[u8],
|
||||
offset: usize,
|
||||
) -> Result<(String, usize)> {
|
||||
let (n, c) = read_head_and_uint(bytes, offset, 3)?;
|
||||
if offset + c + n as usize > bytes.len() {
|
||||
anyhow::bail!("CBOR text string exceeds buffer");
|
||||
}
|
||||
let s = std::str::from_utf8(&bytes[offset + c..offset + c + n as usize])
|
||||
.map_err(|e| anyhow::anyhow!("invalid UTF-8 in CBOR text: {e}"))?;
|
||||
Ok((s.to_string(), c + n as usize))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use at_crypto::cid::cid_for_cbor;
|
||||
|
||||
#[test]
|
||||
fn header_encodes_cids_with_tag_42() {
|
||||
let c1 = cid_for_cbor(b"a").unwrap();
|
||||
let c2 = cid_for_cbor(b"b").unwrap();
|
||||
let bytes = encode_header(&[c1, c2]);
|
||||
// First byte: map(2) = 0xA2
|
||||
assert_eq!(bytes[0], 0xA2, "first byte must be map(2)");
|
||||
// Round-trip via our parser.
|
||||
let h = decode_header(&bytes).unwrap();
|
||||
assert_eq!(h.version, 1);
|
||||
assert_eq!(h.roots, vec![c1, c2]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn car_round_trip_with_one_block() {
|
||||
let cid = cid_for_cbor(b"hello world").unwrap();
|
||||
let mut w = CarWriter::new();
|
||||
w.append(cid, b"hello world");
|
||||
let car = w.finish(&[cid]);
|
||||
let (h, blocks) = parse(&car).unwrap();
|
||||
assert_eq!(h.version, 1);
|
||||
assert_eq!(h.roots, vec![cid]);
|
||||
assert_eq!(blocks.len(), 1);
|
||||
assert_eq!(blocks[0].cid, cid);
|
||||
assert_eq!(blocks[0].data, b"hello world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn car_round_trip_with_many_blocks_and_no_dupes() {
|
||||
let cids: Vec<Cid> = (0..5)
|
||||
.map(|i| cid_for_cbor(format!("block-{i}").as_bytes()).unwrap())
|
||||
.collect();
|
||||
let mut w = CarWriter::new();
|
||||
for (i, c) in cids.iter().enumerate() {
|
||||
w.append(*c, format!("block-{i}").as_bytes());
|
||||
}
|
||||
// Re-appending the same CID should be a no-op.
|
||||
w.append(cids[0], b"ignored");
|
||||
assert_eq!(w.len(), 5);
|
||||
let car = w.finish(&[cids[2]]);
|
||||
let (h, blocks) = parse(&car).unwrap();
|
||||
assert_eq!(h.roots, vec![cids[2]]);
|
||||
assert_eq!(blocks.len(), 5);
|
||||
for (i, b) in blocks.iter().enumerate() {
|
||||
assert_eq!(b.cid, cids[i]);
|
||||
assert_eq!(b.data, format!("block-{i}").as_bytes());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn car_with_empty_roots() {
|
||||
let cid = cid_for_cbor(b"only block").unwrap();
|
||||
let mut w = CarWriter::new();
|
||||
w.append(cid, b"only block");
|
||||
let car = w.finish(&[]);
|
||||
let (h, blocks) = parse(&car).unwrap();
|
||||
assert_eq!(h.version, 1);
|
||||
assert!(h.roots.is_empty());
|
||||
assert_eq!(blocks.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn block_cid_verifies_under_sha256() {
|
||||
// For DAG-CBOR blocks the CID is the SHA-256 of the bytes. Verify the
|
||||
// CID we put in the CAR header matches a re-computed CID over the
|
||||
// block data.
|
||||
let data = b"some record bytes".to_vec();
|
||||
let cid = cid_for_cbor(&data).unwrap();
|
||||
let mut w = CarWriter::new();
|
||||
w.append(cid, &data);
|
||||
let car = w.finish(&[cid]);
|
||||
let (_h, blocks) = parse(&car).unwrap();
|
||||
for b in &blocks {
|
||||
let recomputed = cid_for_cbor(&b.data).unwrap();
|
||||
assert_eq!(b.cid, recomputed);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
use anyhow::Result;
|
||||
use at_crypto::jwt::JwtClaims;
|
||||
use at_crypto::ecdsa::P256Keypair;
|
||||
use at_shared::config::AppConfig;
|
||||
|
||||
pub fn server_p256_keypair(cfg: &AppConfig) -> Result<P256Keypair> {
|
||||
use p256::elliptic_curve::sec1::ToEncodedPoint;
|
||||
let raw = hex::decode(cfg.pds_jwt_secret.trim_start_matches("0x"))?;
|
||||
if raw.len() < 32 {
|
||||
anyhow::bail!("PDS_JWT_SECRET must be ≥ 32 bytes for P-256 key");
|
||||
}
|
||||
let mut bytes = [0u8; 32];
|
||||
bytes.copy_from_slice(&raw[..32]);
|
||||
let sk = p256::SecretKey::from_bytes((&bytes).into())
|
||||
.map_err(|e| anyhow::anyhow!("p256 sk: {e}"))?;
|
||||
let vk = sk.public_key();
|
||||
let pt = vk.to_encoded_point(false);
|
||||
let mut mb_raw = vec![0x80u8, 0x12u8];
|
||||
mb_raw.extend_from_slice(pt.x().unwrap());
|
||||
mb_raw.extend_from_slice(pt.y().unwrap());
|
||||
let secret_hex = hex::encode(sk.to_bytes());
|
||||
let public_multibase = at_crypto::multibase_util::encode_b58btc(&mb_raw);
|
||||
Ok(P256Keypair {
|
||||
secret_hex,
|
||||
public_multibase,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn server_p256_public_multibase(cfg: &AppConfig) -> Result<String> {
|
||||
Ok(server_p256_keypair(cfg)?.public_multibase)
|
||||
}
|
||||
|
||||
pub fn issue_access_jwt(
|
||||
cfg: &AppConfig,
|
||||
did: &str,
|
||||
_handle: &str,
|
||||
) -> Result<(String, i64)> {
|
||||
let kp = server_p256_keypair(cfg)?;
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
let exp = now + 3600;
|
||||
let claims = JwtClaims {
|
||||
iss: format!("did:web:{}", cfg.pds_public_url.trim_start_matches("http://").trim_start_matches("https://")),
|
||||
sub: did.to_string(),
|
||||
aud: "did:web:appview.maarcadetweet.local".into(),
|
||||
iat: now,
|
||||
exp,
|
||||
jti: Some(uuid::Uuid::new_v4().to_string()),
|
||||
scope: Some("com.atproto.access".into()),
|
||||
};
|
||||
let token = at_crypto::jwt::issue_jwt(&kp, &claims)?;
|
||||
Ok((token, exp))
|
||||
}
|
||||
|
||||
pub fn issue_refresh_jwt(
|
||||
cfg: &AppConfig,
|
||||
did: &str,
|
||||
) -> Result<(String, i64)> {
|
||||
let kp = server_p256_keypair(cfg)?;
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
let exp = now + 90 * 24 * 3600;
|
||||
let claims = JwtClaims {
|
||||
iss: "did:web:refresh.maarcadetweet.local".into(),
|
||||
sub: did.to_string(),
|
||||
aud: "did:web:refresh.maarcadetweet.local".into(),
|
||||
iat: now,
|
||||
exp,
|
||||
jti: Some(uuid::Uuid::new_v4().to_string()),
|
||||
scope: Some("com.atproto.refresh".into()),
|
||||
};
|
||||
let token = at_crypto::jwt::issue_jwt(&kp, &claims)?;
|
||||
Ok((token, exp))
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
use anyhow::Result;
|
||||
use at_crypto::did_key::verifying_key_to_multibase;
|
||||
use at_crypto::ecdsa::K256Keypair;
|
||||
use k256::ecdsa::SigningKey;
|
||||
use rand::rngs::OsRng;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CreatedUser {
|
||||
pub did: String,
|
||||
pub handle: String,
|
||||
pub signing_pubkey_multibase: String,
|
||||
pub rotation_pubkey_multibase: String,
|
||||
pub k256_signing: K256Keypair,
|
||||
pub k256_rotation: K256Keypair,
|
||||
}
|
||||
|
||||
pub fn generate_user_keys() -> Result<CreatedUser> {
|
||||
let signing = K256Keypair::generate()?;
|
||||
let rotation = K256Keypair::generate()?;
|
||||
Ok(CreatedUser {
|
||||
did: String::new(),
|
||||
handle: String::new(),
|
||||
signing_pubkey_multibase: signing.public_multibase.clone(),
|
||||
rotation_pubkey_multibase: rotation.public_multibase.clone(),
|
||||
k256_signing: signing,
|
||||
k256_rotation: rotation,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn derive_did_from_signing(k256_signing: &K256Keypair) -> String {
|
||||
use at_crypto::did_key::pubkey_to_multibase;
|
||||
use k256::PublicKey;
|
||||
let sk = k256_signing.secret_key().unwrap();
|
||||
let pk: PublicKey = sk.verifying_key().into();
|
||||
let mb = pubkey_to_multibase(&pk).unwrap();
|
||||
format!("did:key:{}", mb)
|
||||
}
|
||||
|
||||
pub fn random_signing_key() -> SigningKey {
|
||||
SigningKey::random(&mut OsRng)
|
||||
}
|
||||
|
||||
pub fn verifying_key_mb(signing: &SigningKey) -> Result<String> {
|
||||
Ok(verifying_key_to_multibase(signing.verifying_key())?)
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
mod appview_push;
|
||||
mod car;
|
||||
mod jwt_issuer;
|
||||
mod keys;
|
||||
mod password;
|
||||
mod routes;
|
||||
mod state;
|
||||
|
||||
use crate::routes::types::DescribeServerResp;
|
||||
use crate::state::AppState;
|
||||
use axum::extract::State;
|
||||
use axum::routing::{get, post};
|
||||
use axum::{Json, Router};
|
||||
use serde_json::json;
|
||||
use tracing::{info, warn};
|
||||
use tracing_subscriber::EnvFilter;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")))
|
||||
.init();
|
||||
|
||||
let cfg = at_shared::config::AppConfig::from_env()?;
|
||||
let db = sqlx::postgres::PgPoolOptions::new()
|
||||
.max_connections(32)
|
||||
.min_connections(2)
|
||||
.acquire_timeout(std::time::Duration::from_secs(10))
|
||||
.connect(&cfg.database_url_pds)
|
||||
.await?;
|
||||
sqlx::migrate!("../../migrations/pds").run(&db).await?;
|
||||
|
||||
let blob = at_blob::S3BlobStore::new(
|
||||
cfg.s3_endpoint.clone(),
|
||||
cfg.s3_region.clone(),
|
||||
cfg.s3_access_key.clone(),
|
||||
cfg.s3_secret_key.clone(),
|
||||
cfg.s3_bucket_pds.clone(),
|
||||
cfg.pds_public_url.clone(),
|
||||
);
|
||||
|
||||
// Best-effort reachability check for the configured S3 endpoint.
|
||||
// The PDS continues to operate if MinIO is unreachable — `uploadBlob`
|
||||
// falls back to local-only storage and the S3 push is logged at
|
||||
// warn level — but we want this surfaced loudly at startup so
|
||||
// operators notice in dev. See `at_blob::s3` for the
|
||||
// MinIO-only limitation.
|
||||
if !blob.ping().await {
|
||||
warn!(
|
||||
endpoint = %cfg.s3_endpoint,
|
||||
bucket = %cfg.s3_bucket_pds,
|
||||
"s3 ping failed at startup; uploadBlob will serve from local blockstore only"
|
||||
);
|
||||
}
|
||||
|
||||
let state = AppState::new(cfg.clone(), db, blob).await;
|
||||
let app = router(state);
|
||||
|
||||
let addr: std::net::SocketAddr = format!("{}:{}", cfg.pds_host, cfg.pds_port).parse()?;
|
||||
info!("pds-server listening on http://{addr}");
|
||||
let listener = tokio::net::TcpListener::bind(addr).await?;
|
||||
axum::serve(listener, app).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn router(state: AppState) -> Router {
|
||||
Router::new()
|
||||
.route("/", get(root))
|
||||
.route("/healthz", get(healthz))
|
||||
.route(
|
||||
"/xrpc/com.atproto.server.describeServer",
|
||||
get(describe_server),
|
||||
)
|
||||
.route(
|
||||
"/xrpc/com.atproto.server.createAccount",
|
||||
post(routes::auth::create_account),
|
||||
)
|
||||
.route(
|
||||
"/xrpc/com.atproto.server.createSession",
|
||||
post(routes::auth::create_session),
|
||||
)
|
||||
.route(
|
||||
"/xrpc/com.atproto.server.refreshSession",
|
||||
post(routes::auth::refresh_session),
|
||||
)
|
||||
.route(
|
||||
"/xrpc/com.atproto.identity.resolveHandle",
|
||||
post(routes::identity::resolve_handle),
|
||||
)
|
||||
.route(
|
||||
"/xrpc/com.atproto.repo.createRecord",
|
||||
post(routes::repo::create_record),
|
||||
)
|
||||
.route(
|
||||
"/xrpc/com.atproto.repo.deleteRecord",
|
||||
post(routes::feed::delete_record),
|
||||
)
|
||||
.route(
|
||||
"/xrpc/com.atproto.feed.like.create",
|
||||
post(routes::feed::create_like),
|
||||
)
|
||||
.route(
|
||||
"/xrpc/com.atproto.uploadBlob",
|
||||
post(routes::blob::upload_blob)
|
||||
.layer(routes::blob::upload_blob_body_limit())
|
||||
.layer(axum::middleware::from_fn(routes::blob::body_limit_fallback)),
|
||||
)
|
||||
.route(
|
||||
"/xrpc/com.atproto.sync.getRepo",
|
||||
get(routes::sync::get_repo),
|
||||
)
|
||||
.route(
|
||||
"/xrpc/com.atproto.sync.getBlocks",
|
||||
get(routes::sync::get_blocks),
|
||||
)
|
||||
.route(
|
||||
"/xrpc/com.atproto.sync.getLatestCommit",
|
||||
get(routes::sync::get_latest_commit),
|
||||
)
|
||||
.route(
|
||||
"/xrpc/com.atproto.sync.getRecord",
|
||||
get(routes::sync::get_record),
|
||||
)
|
||||
.route(
|
||||
"/xrpc/com.atproto.sync.listRepos",
|
||||
get(routes::sync::list_repos),
|
||||
)
|
||||
.route(
|
||||
"/xrpc/com.atproto.sync.getBlob",
|
||||
get(routes::blob::get_blob),
|
||||
)
|
||||
.route(
|
||||
"/blob/:cid",
|
||||
get(routes::blob::get_blob_by_cid),
|
||||
)
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
async fn root() -> Json<serde_json::Value> {
|
||||
Json(json!({
|
||||
"name": "maarcadetweet-pds",
|
||||
"version": env!("CARGO_PKG_VERSION"),
|
||||
}))
|
||||
}
|
||||
|
||||
async fn healthz() -> Json<serde_json::Value> {
|
||||
Json(json!({ "ok": true }))
|
||||
}
|
||||
|
||||
async fn describe_server(State(state): State<AppState>) -> Json<DescribeServerResp> {
|
||||
Json(DescribeServerResp {
|
||||
did: "did:web:pds.maarcadetweet.local".into(),
|
||||
available_user_domains: vec![state
|
||||
.cfg
|
||||
.pds_handle_dns_zone
|
||||
.trim_start_matches('.')
|
||||
.to_string()],
|
||||
invite_code_required: false,
|
||||
links: json!({
|
||||
"termsOfService": null,
|
||||
"privacyPolicy": null,
|
||||
}),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
use anyhow::Result;
|
||||
use argon2::password_hash::SaltString;
|
||||
use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier};
|
||||
use rand::rngs::OsRng;
|
||||
|
||||
pub fn hash_password(plain: &str) -> Result<String> {
|
||||
let salt = SaltString::generate(&mut OsRng);
|
||||
let argon2 = Argon2::default();
|
||||
let hash = argon2
|
||||
.hash_password(plain.as_bytes(), &salt)
|
||||
.map_err(|e| anyhow::anyhow!("argon2 hash: {e}"))?
|
||||
.to_string();
|
||||
Ok(hash)
|
||||
}
|
||||
|
||||
pub fn verify_password(plain: &str, hash: &str) -> Result<bool> {
|
||||
let parsed = PasswordHash::new(hash).map_err(|e| anyhow::anyhow!("argon2 parse: {e}"))?;
|
||||
Ok(Argon2::default()
|
||||
.verify_password(plain.as_bytes(), &parsed)
|
||||
.is_ok())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn hash_and_verify() {
|
||||
let hash = hash_password("hunter2").unwrap();
|
||||
assert!(verify_password("hunter2", &hash).unwrap());
|
||||
assert!(!verify_password("hunter3", &hash).unwrap());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
use crate::jwt_issuer;
|
||||
use crate::keys::{derive_did_from_signing, generate_user_keys};
|
||||
use crate::password::hash_password;
|
||||
use crate::routes::types::{
|
||||
CreateAccountReq, CreateAccountResp, CreateSessionReq, CreateSessionResp, RefreshSessionReq,
|
||||
RefreshSessionResp,
|
||||
};
|
||||
use crate::state::AppState;
|
||||
use at_crypto::plc_op::{create_unsigned_op, sign_op, PlcOperation};
|
||||
use axum::extract::State;
|
||||
use axum::http::StatusCode;
|
||||
use axum::Json;
|
||||
use serde_json::json;
|
||||
use tracing::{info, warn};
|
||||
|
||||
pub async fn create_account(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<CreateAccountReq>,
|
||||
) -> Result<Json<CreateAccountResp>, (StatusCode, Json<crate::routes::types::ErrorBody>)> {
|
||||
if let Some(pw) = &req.password {
|
||||
if pw.len() < 8 {
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(crate::routes::types::ErrorBody::new(
|
||||
"InvalidPassword",
|
||||
Some("password must be ≥ 8 chars".into()),
|
||||
)),
|
||||
));
|
||||
}
|
||||
}
|
||||
if !req
|
||||
.handle
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_')
|
||||
{
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(crate::routes::types::ErrorBody::new(
|
||||
"InvalidHandle",
|
||||
Some("handle contains invalid chars".into()),
|
||||
)),
|
||||
));
|
||||
}
|
||||
if req.handle.len() < 3 || req.handle.len() > 64 {
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(crate::routes::types::ErrorBody::new(
|
||||
"InvalidHandle",
|
||||
Some("handle length out of range".into()),
|
||||
)),
|
||||
));
|
||||
}
|
||||
|
||||
let existing = sqlx::query_scalar::<_, i64>(
|
||||
"SELECT COUNT(*) FROM users WHERE handle = $1",
|
||||
)
|
||||
.bind(&req.handle)
|
||||
.fetch_one(&state.db)
|
||||
.await
|
||||
.map_err(|e| internal(e))?;
|
||||
if existing > 0 {
|
||||
return Err((
|
||||
StatusCode::CONFLICT,
|
||||
Json(crate::routes::types::ErrorBody::new(
|
||||
"HandleAlreadyTaken",
|
||||
Some(format!("handle '{}' is taken", req.handle)),
|
||||
)),
|
||||
));
|
||||
}
|
||||
|
||||
let keys = generate_user_keys().map_err(|e| internal(e))?;
|
||||
let did = derive_did_from_signing(&keys.k256_signing);
|
||||
let pwd_hash = match &req.password {
|
||||
Some(pw) => Some(hash_password(pw).map_err(|e| internal(e))?),
|
||||
None => None,
|
||||
};
|
||||
|
||||
let _signing_pub = keys.k256_signing.verifying_key().unwrap();
|
||||
let _rotation_pub = keys.k256_rotation.verifying_key().unwrap();
|
||||
|
||||
let mut tx = state.db.begin().await.map_err(|e| internal(e))?;
|
||||
|
||||
sqlx::query(
|
||||
r#"INSERT INTO users (did, handle, email, password_hash, signing_key, rotation_key)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)"#,
|
||||
)
|
||||
.bind(&did)
|
||||
.bind(&req.handle)
|
||||
.bind(&req.email)
|
||||
.bind(&pwd_hash)
|
||||
.bind(hex::decode(&keys.k256_signing.secret_hex).unwrap())
|
||||
.bind(hex::decode(&keys.k256_rotation.secret_hex).unwrap())
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| internal(e))?;
|
||||
|
||||
sqlx::query(
|
||||
r#"INSERT INTO repos (did, rev, head_cid, head_commit) VALUES ($1, $2, $3, $4)"#,
|
||||
)
|
||||
.bind(&did)
|
||||
.bind("0")
|
||||
.bind(&[0u8; 32][..])
|
||||
.bind(&[0u8; 32][..])
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| internal(e))?;
|
||||
|
||||
tx.commit().await.map_err(|e| internal(e))?;
|
||||
|
||||
let plc_op = PlcOperation::create(
|
||||
&req.handle,
|
||||
&keys.k256_signing.secret_key().unwrap(),
|
||||
&keys.k256_rotation.public_multibase,
|
||||
&state.cfg.pds_public_url,
|
||||
)
|
||||
.map_err(|e| internal(e))?;
|
||||
let plc_cid = match state.plc.submit(&did, &plc_op).await {
|
||||
Ok(c) => {
|
||||
info!("plc op submitted: cid={}", c);
|
||||
Some(c)
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("plc submit failed (dev ok): {e:#}");
|
||||
None
|
||||
}
|
||||
};
|
||||
let _ = plc_cid;
|
||||
|
||||
let (access_jwt, access_exp) = jwt_issuer::issue_access_jwt(&state.cfg, &did, &req.handle)
|
||||
.map_err(|e| internal(e))?;
|
||||
let (refresh_jwt, refresh_exp) = jwt_issuer::issue_refresh_jwt(&state.cfg, &did)
|
||||
.map_err(|e| internal(e))?;
|
||||
|
||||
let session_id = uuid::Uuid::new_v4();
|
||||
sqlx::query(
|
||||
r#"INSERT INTO sessions (id, did, access_jwt, refresh_jwt, access_expires_at, refresh_expires_at)
|
||||
VALUES ($1, $2, $3, $4, to_timestamp($5), to_timestamp($6))"#,
|
||||
)
|
||||
.bind(session_id)
|
||||
.bind(&did)
|
||||
.bind(&access_jwt)
|
||||
.bind(&refresh_jwt)
|
||||
.bind(access_exp as f64)
|
||||
.bind(refresh_exp as f64)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
.map_err(|e| internal(e))?;
|
||||
|
||||
let did_doc = json!({
|
||||
"id": did,
|
||||
"verificationMethod": [{
|
||||
"id": format!("{}#atproto", did),
|
||||
"type": "Multikey",
|
||||
"controller": did,
|
||||
"publicKeyMultibase": keys.k256_signing.public_multibase,
|
||||
}],
|
||||
"rotationKeys": [keys.k256_rotation.public_multibase],
|
||||
"alsoKnownAs": [format!("at://{}", req.handle)],
|
||||
"service": [{
|
||||
"id": "#atproto_pds",
|
||||
"type": "AtprotoPersonalDataServer",
|
||||
"serviceEndpoint": state.cfg.pds_public_url,
|
||||
}],
|
||||
});
|
||||
|
||||
Ok(Json(CreateAccountResp {
|
||||
did,
|
||||
handle: req.handle,
|
||||
access_jwt,
|
||||
refresh_jwt,
|
||||
did_doc,
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn create_session(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<CreateSessionReq>,
|
||||
) -> Result<Json<CreateSessionResp>, (StatusCode, Json<crate::routes::types::ErrorBody>)> {
|
||||
let row: Option<(String, String, Option<String>)> = sqlx::query_as(
|
||||
"SELECT did, handle, password_hash FROM users WHERE handle = $1",
|
||||
)
|
||||
.bind(&req.identifier)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
.map_err(|e| internal(e))?;
|
||||
|
||||
let (did, handle, pwd_hash) = match row {
|
||||
Some(r) => r,
|
||||
None => {
|
||||
return Err((
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(crate::routes::types::ErrorBody::new(
|
||||
"AuthenticationRequired",
|
||||
Some("invalid identifier or password".into()),
|
||||
)),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
let pwd_hash = match pwd_hash {
|
||||
Some(h) => h,
|
||||
None => {
|
||||
return Err((
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(crate::routes::types::ErrorBody::new(
|
||||
"AuthenticationRequired",
|
||||
Some("account has no password (did:web)".into()),
|
||||
)),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
let ok = crate::password::verify_password(&req.password, &pwd_hash)
|
||||
.map_err(|e| internal(e))?;
|
||||
if !ok {
|
||||
return Err((
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(crate::routes::types::ErrorBody::new(
|
||||
"AuthenticationRequired",
|
||||
Some("invalid identifier or password".into()),
|
||||
)),
|
||||
));
|
||||
}
|
||||
|
||||
let (access_jwt, _access_exp) = jwt_issuer::issue_access_jwt(&state.cfg, &did, &handle)
|
||||
.map_err(|e| internal(e))?;
|
||||
let (refresh_jwt, refresh_exp) = jwt_issuer::issue_refresh_jwt(&state.cfg, &did)
|
||||
.map_err(|e| internal(e))?;
|
||||
|
||||
let session_id = uuid::Uuid::new_v4();
|
||||
sqlx::query(
|
||||
r#"INSERT INTO sessions (id, did, access_jwt, refresh_jwt, access_expires_at, refresh_expires_at)
|
||||
VALUES ($1, $2, $3, $4, to_timestamp($5), to_timestamp($6))"#,
|
||||
)
|
||||
.bind(session_id)
|
||||
.bind(&did)
|
||||
.bind(&access_jwt)
|
||||
.bind(&refresh_jwt)
|
||||
.bind(_access_exp as f64)
|
||||
.bind(refresh_exp as f64)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
.map_err(|e| internal(e))?;
|
||||
|
||||
Ok(Json(CreateSessionResp {
|
||||
did,
|
||||
handle,
|
||||
access_jwt,
|
||||
refresh_jwt,
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn refresh_session(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<RefreshSessionReq>,
|
||||
) -> Result<Json<RefreshSessionResp>, (StatusCode, Json<crate::routes::types::ErrorBody>)> {
|
||||
let server_pk = crate::jwt_issuer::server_p256_public_multibase(&state.cfg)
|
||||
.map_err(|e| internal(e))?;
|
||||
let claims = at_crypto::jwt::verify_jwt(&req.refresh_jwt, &server_pk).map_err(|_| {
|
||||
(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(crate::routes::types::ErrorBody::new(
|
||||
"TokenInvalid",
|
||||
Some("refresh token invalid or expired".into()),
|
||||
)),
|
||||
)
|
||||
})?;
|
||||
let did = claims.sub.clone();
|
||||
|
||||
let handle: String = sqlx::query_scalar("SELECT handle FROM users WHERE did = $1")
|
||||
.bind(&did)
|
||||
.fetch_one(&state.db)
|
||||
.await
|
||||
.map_err(|e| internal(e))?;
|
||||
|
||||
let (access_jwt, _access_exp) = jwt_issuer::issue_access_jwt(&state.cfg, &did, &handle)
|
||||
.map_err(|e| internal(e))?;
|
||||
let (refresh_jwt, _refresh_exp) = jwt_issuer::issue_refresh_jwt(&state.cfg, &did)
|
||||
.map_err(|e| internal(e))?;
|
||||
|
||||
Ok(Json(RefreshSessionResp {
|
||||
access_jwt,
|
||||
refresh_jwt,
|
||||
handle,
|
||||
did,
|
||||
}))
|
||||
}
|
||||
|
||||
fn internal(e: impl std::fmt::Display) -> (StatusCode, Json<crate::routes::types::ErrorBody>) {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(crate::routes::types::ErrorBody::new(
|
||||
"InternalServerError",
|
||||
Some(e.to_string()),
|
||||
)),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,692 @@
|
||||
//! `com.atproto.uploadBlob`, `com.atproto.sync.getBlob`, and the
|
||||
//! Tauri-only `/blob/{cid}` shortcut.
|
||||
//!
|
||||
//! ### `com.atproto.sync.getBlob` and `/blob/{cid}`
|
||||
//!
|
||||
//! Spec: <https://atproto.com/specs/sync#getblob>
|
||||
//!
|
||||
//! For each PDS-hosted user, blob payload bytes are addressed by CID
|
||||
//! just like the rest of the repo: the value is stored as a block in
|
||||
//! `repo_blocks` keyed by `(did, cid)`. This endpoint looks that row
|
||||
//! up and streams it back as raw bytes.
|
||||
//!
|
||||
//! MIME-type resolution proceeds in this order:
|
||||
//!
|
||||
//! 1. The `mime_type` column on `repo_blocks` (populated by
|
||||
//! `uploadBlob` from the request `Content-Type` header or a sniff
|
||||
//! fallback). The spec endpoint can do a `(did, cid)` lookup; the
|
||||
//! `/blob/{cid}` shortcut scans by CID alone.
|
||||
//! 2. Magic-byte sniffing via [`at_blob::detect_mime`] on the block
|
||||
//! bytes, so blobs uploaded before `mime_type` was populated still
|
||||
//! get the right `Content-Type`.
|
||||
//! 3. `application/octet-stream` as the last resort.
|
||||
//!
|
||||
//! If neither the local blockstore nor S3 has the block, we return
|
||||
//! 400 `BlobNotFound`. S3 is checked as a fallback so blobs that were
|
||||
//! uploaded by another node in a future clustered deployment are
|
||||
//! still servable from this PDS.
|
||||
//!
|
||||
//! These endpoints are unauthenticated; in production they should be
|
||||
//! gated behind a "blob serve" middleware (rate limit, referer check,
|
||||
//! etc.). For dev we follow the same permissive policy as the other
|
||||
//! `com.atproto.sync.*` reads.
|
||||
//!
|
||||
//! ### `com.atproto.uploadBlob`
|
||||
//!
|
||||
//! Spec: <https://atproto.com/specs/blob>
|
||||
//!
|
||||
//! Accepts the raw binary body (up to [`MAX_BLOB_SIZE`] bytes),
|
||||
//! computes a CIDv1-raw SHA-256 over the payload, persists the block
|
||||
//! in `repo_blocks` alongside its MIME type, and (best-effort) pushes
|
||||
//! the same bytes to the configured S3 / MinIO bucket. The DID is
|
||||
//! taken from the authenticated session — the request body carries
|
||||
//! no identity information.
|
||||
|
||||
use crate::routes::helpers::{err, load_user_blockstore};
|
||||
use crate::state::AppState;
|
||||
use at_blob::{detect_mime, BlobStore};
|
||||
use at_crypto::cid::{cid_for_raw, cid_to_bytes, sha256, RAW_CODEC};
|
||||
use at_repo::blockstore::Blockstore;
|
||||
#[cfg(test)]
|
||||
use at_crypto::cid::cid_from_multihash_bytes;
|
||||
use axum::extract::{DefaultBodyLimit, Path, Query, State};
|
||||
use axum::http::{header, HeaderMap, HeaderValue, StatusCode};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::Json;
|
||||
use bytes::Bytes;
|
||||
use cid::Cid;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
use std::str::FromStr;
|
||||
use tracing::info;
|
||||
use tracing::warn;
|
||||
|
||||
// -- constants --------------------------------------------------------------
|
||||
|
||||
/// Hard cap on `com.atproto.uploadBlob` request bodies. Anything
|
||||
/// larger than this is rejected with `413 Payload Too Large` before
|
||||
/// we touch the body extractor. 1 MiB matches the size limit the
|
||||
/// reference PDS (Bluesky) advertises for profile / post images.
|
||||
pub const MAX_BLOB_SIZE: usize = 1024 * 1024;
|
||||
|
||||
/// Default MIME used when neither the stored `mime_type` column nor
|
||||
/// magic-byte sniffing recognises the block.
|
||||
const DEFAULT_MIME: &str = "application/octet-stream";
|
||||
|
||||
// -- query / response types -------------------------------------------------
|
||||
|
||||
/// `GET /xrpc/com.atproto.sync.getBlob?did=<did>&cid=<cid>`
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct BlobQuery {
|
||||
pub did: String,
|
||||
pub cid: String,
|
||||
}
|
||||
|
||||
// -- MIME resolution helpers ------------------------------------------------
|
||||
|
||||
/// Pull the `mime_type` column out of `repo_blocks` for the given
|
||||
/// `(did, cid)`. Returns `None` if the row is missing, the column is
|
||||
/// NULL (pre-Phase-7 row), or the column is empty.
|
||||
async fn lookup_stored_mime(
|
||||
state: &AppState,
|
||||
did: &str,
|
||||
cid: &Cid,
|
||||
) -> Option<String> {
|
||||
let cid_bytes = cid_to_bytes(cid);
|
||||
let row: Option<(Option<String>,)> = sqlx::query_as(
|
||||
"SELECT mime_type FROM repo_blocks WHERE did = $1 AND cid = $2",
|
||||
)
|
||||
.bind(did)
|
||||
.bind(cid_bytes.as_slice())
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
row.and_then(|(m,)| m).filter(|s| !s.is_empty())
|
||||
}
|
||||
|
||||
/// Pull the `mime_type` column out of `repo_blocks` for an arbitrary
|
||||
/// CID (no DID filter). Used by the `/blob/{cid}` shortcut endpoint.
|
||||
async fn lookup_stored_mime_by_cid(state: &AppState, cid: &Cid) -> Option<String> {
|
||||
let cid_bytes = cid_to_bytes(cid);
|
||||
let row: Option<(Option<String>,)> = sqlx::query_as(
|
||||
"SELECT mime_type FROM repo_blocks WHERE cid = $1 LIMIT 1",
|
||||
)
|
||||
.bind(cid_bytes.as_slice())
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
row.and_then(|(m,)| m).filter(|s| !s.is_empty())
|
||||
}
|
||||
|
||||
/// Resolve the `Content-Type` for a served blob, in priority order:
|
||||
/// stored column → sniffed magic bytes → `application/octet-stream`.
|
||||
async fn resolve_mime(
|
||||
state: &AppState,
|
||||
did: Option<&str>,
|
||||
cid: &Cid,
|
||||
bytes: &[u8],
|
||||
) -> String {
|
||||
if let Some(d) = did {
|
||||
if let Some(m) = lookup_stored_mime(state, d, cid).await {
|
||||
return m;
|
||||
}
|
||||
} else if let Some(m) = lookup_stored_mime_by_cid(state, cid).await {
|
||||
return m;
|
||||
}
|
||||
if let Some(m) = detect_mime(bytes) {
|
||||
return m.as_str().to_string();
|
||||
}
|
||||
DEFAULT_MIME.to_string()
|
||||
}
|
||||
|
||||
/// Normalise a client-supplied `Content-Type` header to a value we
|
||||
/// can store + serve. Strips parameters (e.g. `; charset=utf-8`)
|
||||
/// because we don't preserve client-supplied charset hints — we'd
|
||||
/// rather serve the value we sniffed — and lowercases the result for
|
||||
/// canonical storage.
|
||||
fn normalize_content_type(raw: &str) -> Option<String> {
|
||||
let main = raw.split(';').next()?.trim();
|
||||
if main.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(main.to_ascii_lowercase())
|
||||
}
|
||||
|
||||
/// Pull a bearer JWT from the request, verify it against the PDS
|
||||
/// server key, and return the `sub` claim (the DID the token is
|
||||
/// minted for). Mirrors the auth flow in `routes::repo::create_record`
|
||||
/// and `routes::feed::create_like` so behaviour stays consistent.
|
||||
///
|
||||
/// Synchronous because `at_crypto::jwt::verify_jwt` is synchronous
|
||||
/// (P-256 verification is fast enough to not need a worker thread) —
|
||||
/// keeping this helper non-`async` matches the style of the existing
|
||||
/// auth helpers in `repo::create_record`.
|
||||
fn authenticate_upload(
|
||||
state: &AppState,
|
||||
headers: &HeaderMap,
|
||||
) -> Result<String, (StatusCode, Json<crate::routes::types::ErrorBody>)> {
|
||||
let token = headers
|
||||
.get("authorization")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|s| s.strip_prefix("Bearer "))
|
||||
.ok_or_else(|| {
|
||||
err(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"Unauthenticated",
|
||||
"missing Authorization: Bearer header",
|
||||
)
|
||||
})?;
|
||||
let server_pk = crate::jwt_issuer::server_p256_public_multibase(&state.cfg)
|
||||
.map_err(|e| {
|
||||
err(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"InternalServerError",
|
||||
e.to_string(),
|
||||
)
|
||||
})?;
|
||||
let claims = at_crypto::jwt::verify_jwt(token, &server_pk).map_err(|e| {
|
||||
err(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"TokenInvalid",
|
||||
format!("invalid token: {e}"),
|
||||
)
|
||||
})?;
|
||||
Ok(claims.sub)
|
||||
}
|
||||
|
||||
// -- response helpers -------------------------------------------------------
|
||||
|
||||
/// Wrap the raw bytes in an HTTP response with the resolved
|
||||
/// `Content-Type` header. Caller has already validated that the block
|
||||
/// is present.
|
||||
fn blob_response(bytes: Vec<u8>, mime: &str) -> Response {
|
||||
let mut resp = (StatusCode::OK, Bytes::from(bytes)).into_response();
|
||||
if let Ok(value) = HeaderValue::from_str(mime) {
|
||||
resp.headers_mut().insert(header::CONTENT_TYPE, value);
|
||||
}
|
||||
resp
|
||||
}
|
||||
|
||||
/// Build a `com.atproto.uploadBlob` success response.
|
||||
fn upload_response(cid: &Cid, mime: &str, size: u64) -> Json<Value> {
|
||||
Json(json!({
|
||||
"blob": {
|
||||
"$type": "blob",
|
||||
"ref": { "$link": cid.to_string() },
|
||||
"mimeType": mime,
|
||||
"size": size,
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
/// Look up the blob for `did + cid` in the user's blockstore (which
|
||||
/// we hydrate from `repo_blocks`).
|
||||
async fn fetch_block_for_did(
|
||||
state: &AppState,
|
||||
did: &str,
|
||||
cid: &Cid,
|
||||
) -> Result<Option<Vec<u8>>, (StatusCode, Json<crate::routes::types::ErrorBody>)> {
|
||||
let blockstore = load_user_blockstore(state, did).await?;
|
||||
let block = match blockstore.get(cid).await {
|
||||
Ok(Some(b)) => Some(b.to_vec()),
|
||||
Ok(None) => None,
|
||||
Err(e) => {
|
||||
return Err(err(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"InternalServerError",
|
||||
format!("blockstore get: {e:#}"),
|
||||
));
|
||||
}
|
||||
};
|
||||
Ok(block)
|
||||
}
|
||||
|
||||
/// S3 fallback used when the local blockstore doesn't have the CID.
|
||||
/// We only know the bucket key (and the stored mime type from the
|
||||
/// `repo_blocks` row) at this point, so we hand off to the configured
|
||||
/// `S3BlobStore` and trust whatever it returns.
|
||||
///
|
||||
/// Returns `Ok(None)` for any "not found" / network error so the
|
||||
/// caller can produce a clean `BlobNotFound`.
|
||||
async fn fetch_block_from_s3(
|
||||
state: &AppState,
|
||||
did: &str,
|
||||
cid: &Cid,
|
||||
) -> Option<Vec<u8>> {
|
||||
let key = format!("{did}/{cid}");
|
||||
match state.blob.get(&key).await {
|
||||
Ok(Some(b)) => Some(b.to_vec()),
|
||||
Ok(None) => None,
|
||||
Err(e) => {
|
||||
warn!(
|
||||
error = %e,
|
||||
did = %did,
|
||||
cid = %cid,
|
||||
"s3 fallback fetch failed; serving 404"
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -- handlers ---------------------------------------------------------------
|
||||
|
||||
/// `POST /xrpc/com.atproto.uploadBlob`
|
||||
///
|
||||
/// Body: raw binary content. The caller MUST set a `Content-Type`
|
||||
/// header; we use it as the authoritative MIME type for the stored
|
||||
/// blob. If the header is missing or unrecognised we fall back to
|
||||
/// magic-byte sniffing via [`detect_mime`]; if that also fails we
|
||||
/// store `application/octet-stream` so the row is still servable.
|
||||
///
|
||||
/// The DID is taken from the authenticated JWT `sub` claim. We do
|
||||
/// not accept a `did` query parameter or body field — `uploadBlob`
|
||||
/// is per-user by definition (the spec defines it that way).
|
||||
///
|
||||
/// Steps:
|
||||
/// 1. Authenticate the bearer JWT, extract the DID.
|
||||
/// 2. Read + size-check the body (axum's `DefaultBodyLimit` enforces
|
||||
/// [`MAX_BLOB_SIZE`] at the extractor layer — anything larger is
|
||||
/// rejected with 413 before we see the body).
|
||||
/// 3. Resolve the MIME type (header → sniff → `octet-stream`).
|
||||
/// 4. Compute the CIDv1-raw SHA-256 over the payload.
|
||||
/// 5. Upsert into `repo_blocks` (keyed by `(did, cid)`).
|
||||
/// 6. Best-effort push to S3 with key `${did}/${cid}`. Failures are
|
||||
/// logged but don't fail the upload — the local blockstore row is
|
||||
/// the authoritative store from the PDS's perspective.
|
||||
/// 7. Return `{ blob: { $type, ref: { $link }, mimeType, size } }`.
|
||||
pub async fn upload_blob(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
body: Bytes,
|
||||
) -> Result<Json<Value>, (StatusCode, Json<crate::routes::types::ErrorBody>)> {
|
||||
let did = authenticate_upload(&state, &headers)?;
|
||||
|
||||
if body.len() > MAX_BLOB_SIZE {
|
||||
return Err(err(
|
||||
StatusCode::PAYLOAD_TOO_LARGE,
|
||||
"BlobTooLarge",
|
||||
format!(
|
||||
"blob is {} bytes; max is {}",
|
||||
body.len(),
|
||||
MAX_BLOB_SIZE
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
// Resolve the MIME type. The `Content-Type` request header is
|
||||
// authoritative; if absent we sniff; if neither works we store
|
||||
// `application/octet-stream` so the row is still servable.
|
||||
let header_mime = headers
|
||||
.get(header::CONTENT_TYPE)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(normalize_content_type);
|
||||
let mime = match header_mime {
|
||||
Some(m) => m,
|
||||
None => detect_mime(&body)
|
||||
.map(|m| m.as_str().to_string())
|
||||
.unwrap_or_else(|| DEFAULT_MIME.to_string()),
|
||||
};
|
||||
|
||||
// Compute the CIDv1-raw SHA-256.
|
||||
let hash = sha256(&body);
|
||||
let cid = cid_for_raw(RAW_CODEC, hash).map_err(|e| {
|
||||
err(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"InternalServerError",
|
||||
format!("cid: {e}"),
|
||||
)
|
||||
})?;
|
||||
let cid_bytes = cid.to_bytes();
|
||||
let size = body.len() as u64;
|
||||
|
||||
// Persist into `repo_blocks`. We use `ON CONFLICT (did, cid) DO
|
||||
// UPDATE SET mime_type = EXCLUDED.mime_type` so re-uploading the
|
||||
// same bytes (or uploading a different blob that hashes to the
|
||||
// same CID) updates the stored mime type rather than failing
|
||||
// outright.
|
||||
sqlx::query(
|
||||
r#"INSERT INTO repo_blocks (did, cid, block, size, mime_type)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
ON CONFLICT (did, cid) DO UPDATE
|
||||
SET mime_type = EXCLUDED.mime_type"#,
|
||||
)
|
||||
.bind(&did)
|
||||
.bind(cid_bytes.as_slice())
|
||||
.bind(body.as_ref())
|
||||
.bind(size as i32)
|
||||
.bind(&mime)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
err(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"InternalServerError",
|
||||
format!("repo_blocks insert: {e}"),
|
||||
)
|
||||
})?;
|
||||
|
||||
// Best-effort S3 push. Failures are logged but don't fail the
|
||||
// upload — the local row is the authoritative store from the
|
||||
// PDS's perspective, and a future `getBlob` that hits this CID
|
||||
// will find it locally before ever consulting S3.
|
||||
let s3_key = format!("{did}/{cid}");
|
||||
let blob_store = state.blob.clone();
|
||||
let mime_for_s3 = mime.clone();
|
||||
let body_for_s3 = body.clone();
|
||||
tokio::spawn(async move {
|
||||
match blob_store
|
||||
.put(&s3_key, body_for_s3, &mime_for_s3)
|
||||
.await
|
||||
{
|
||||
Ok(info) => {
|
||||
info!(
|
||||
key = %s3_key,
|
||||
cid = %info.cid,
|
||||
"blob pushed to s3"
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(
|
||||
error = %e,
|
||||
key = %s3_key,
|
||||
"s3 push failed; serving from local blockstore only"
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
info!(
|
||||
did = %did,
|
||||
cid = %cid,
|
||||
size = size,
|
||||
mime = %mime,
|
||||
"blob uploaded"
|
||||
);
|
||||
|
||||
Ok(upload_response(&cid, &mime, size))
|
||||
}
|
||||
|
||||
/// `GET /xrpc/com.atproto.sync.getBlob?did=<did>&cid=<cid>`
|
||||
///
|
||||
/// Spec-shaped handler. Returns the raw blob bytes addressed by the
|
||||
/// CID, or 400 `BlobNotFound` if no such block exists for the user.
|
||||
/// Looks up the block in the in-process blockstore first; on miss,
|
||||
/// falls back to S3.
|
||||
pub async fn get_blob(
|
||||
State(state): State<AppState>,
|
||||
Query(q): Query<BlobQuery>,
|
||||
) -> Result<Response, (StatusCode, Json<crate::routes::types::ErrorBody>)> {
|
||||
if q.did.is_empty() || q.cid.is_empty() {
|
||||
return Err(err(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"InvalidRequest",
|
||||
"`did` and `cid` are required",
|
||||
));
|
||||
}
|
||||
let parsed = Cid::from_str(&q.cid).map_err(|e| {
|
||||
err(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"InvalidRequest",
|
||||
format!("invalid cid `{}`: {e}", q.cid),
|
||||
)
|
||||
})?;
|
||||
|
||||
// Confirm the user has a repo at all (a non-zero head_commit).
|
||||
// We do this cheaply by counting repo_blocks rows for the DID —
|
||||
// if the user has no blocks, the blob can't possibly be there.
|
||||
let row_count: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM repo_blocks WHERE did = $1",
|
||||
)
|
||||
.bind(&q.did)
|
||||
.fetch_one(&state.db)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
err(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"InternalServerError",
|
||||
format!("repo_blocks count: {e}"),
|
||||
)
|
||||
})?;
|
||||
if row_count == 0 {
|
||||
return Err(err(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"RepoNotFound",
|
||||
format!("no blocks for did `{}`", q.did),
|
||||
));
|
||||
}
|
||||
|
||||
let bytes = match fetch_block_for_did(&state, &q.did, &parsed).await? {
|
||||
Some(b) => b,
|
||||
None => match fetch_block_from_s3(&state, &q.did, &parsed).await {
|
||||
Some(b) => b,
|
||||
None => {
|
||||
return Err(err(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"BlobNotFound",
|
||||
format!("no blob for cid `{}` in repo `{}`", q.cid, q.did),
|
||||
));
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
let mime = resolve_mime(&state, Some(&q.did), &parsed, &bytes).await;
|
||||
Ok(blob_response(bytes, &mime))
|
||||
}
|
||||
|
||||
/// `GET /blob/{cid}`
|
||||
///
|
||||
/// Shorter URL form used by the Tauri shell. We treat `/blob/{cid}`
|
||||
/// as "look up the blob in *any* repo we host" — the spec endpoint
|
||||
/// requires a `did`, but the desktop client always knows the DID of
|
||||
/// the user whose media it's rendering (the post's author) and
|
||||
/// passing it as a path segment keeps the `Image.src` attribute
|
||||
/// short and the object-URL cache key stable.
|
||||
///
|
||||
/// For now this resolves the blob by scanning `repo_blocks` for the
|
||||
/// CID across all hosted users. If multiple users happen to upload
|
||||
/// the same bytes (extremely unlikely for personal feeds) the first
|
||||
/// match wins. This is intentionally a Tauri-only fast path.
|
||||
pub async fn get_blob_by_cid(
|
||||
State(state): State<AppState>,
|
||||
Path(cid): Path<String>,
|
||||
) -> Result<Response, (StatusCode, Json<crate::routes::types::ErrorBody>)> {
|
||||
if cid.is_empty() {
|
||||
return Err(err(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"InvalidRequest",
|
||||
"`cid` path segment is required",
|
||||
));
|
||||
}
|
||||
let parsed = Cid::from_str(&cid).map_err(|e| {
|
||||
err(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"InvalidRequest",
|
||||
format!("invalid cid `{cid}`: {e}"),
|
||||
)
|
||||
})?;
|
||||
let cid_bytes = cid_to_bytes(&parsed);
|
||||
|
||||
let row: Option<(Vec<u8>,)> = sqlx::query_as(
|
||||
"SELECT block FROM repo_blocks WHERE cid = $1 LIMIT 1",
|
||||
)
|
||||
.bind(cid_bytes.as_slice())
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
err(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"InternalServerError",
|
||||
format!("repo_blocks lookup: {e}"),
|
||||
)
|
||||
})?;
|
||||
|
||||
let bytes = match row {
|
||||
Some((b,)) => b,
|
||||
None => {
|
||||
return Err(err(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"BlobNotFound",
|
||||
format!("no blob for cid `{cid}`"),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
let mime = resolve_mime(&state, None, &parsed, &bytes).await;
|
||||
Ok(blob_response(bytes, &mime))
|
||||
}
|
||||
|
||||
/// Body-limit layer applied to `com.atproto.uploadBlob`. Exposed as a
|
||||
/// function so `main.rs` can `.layer()` it onto the route without
|
||||
/// having to know the constant.
|
||||
pub fn upload_blob_body_limit() -> DefaultBodyLimit {
|
||||
DefaultBodyLimit::max(MAX_BLOB_SIZE)
|
||||
}
|
||||
|
||||
/// axum's `DefaultBodyLimit` returns a plain `text/plain` 413 when the
|
||||
/// limit is exceeded — the XRPC spec requires a JSON error envelope
|
||||
/// instead, so we wrap the route with this fallback that catches the
|
||||
/// axum error and returns the canonical shape.
|
||||
pub async fn body_limit_fallback(
|
||||
req: axum::http::Request<axum::body::Body>,
|
||||
next: axum::middleware::Next,
|
||||
) -> axum::response::Response {
|
||||
let resp = next.run(req).await;
|
||||
if resp.status() == axum::http::StatusCode::PAYLOAD_TOO_LARGE {
|
||||
return (
|
||||
axum::http::StatusCode::PAYLOAD_TOO_LARGE,
|
||||
axum::Json(serde_json::json!({
|
||||
"error": "BlobTooLarge",
|
||||
"message": format!("body exceeds {} bytes", MAX_BLOB_SIZE),
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
resp
|
||||
}
|
||||
|
||||
// -- tests ------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::routes::types::ErrorBody;
|
||||
|
||||
#[test]
|
||||
fn blob_query_parses_did_and_cid() {
|
||||
let q: BlobQuery = serde_json::from_value(serde_json::json!({
|
||||
"did": "did:plc:abc",
|
||||
"cid": "bafyreig",
|
||||
}))
|
||||
.unwrap();
|
||||
assert_eq!(q.did, "did:plc:abc");
|
||||
assert_eq!(q.cid, "bafyreig");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blob_query_rejects_missing_fields() {
|
||||
let v: Result<BlobQuery, _> = serde_json::from_value(serde_json::json!({}));
|
||||
assert!(v.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blob_response_sets_content_type() {
|
||||
let resp = blob_response(b"hello".to_vec(), "image/png");
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let ct = resp
|
||||
.headers()
|
||||
.get(header::CONTENT_TYPE)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("");
|
||||
assert_eq!(ct, "image/png");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blob_response_falls_back_to_default_mime() {
|
||||
let resp = blob_response(b"\xff\xd8\xff\xe0".to_vec(), DEFAULT_MIME);
|
||||
assert_eq!(
|
||||
resp.headers()
|
||||
.get(header::CONTENT_TYPE)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or(""),
|
||||
DEFAULT_MIME
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cid_bytes_roundtrip_helper() {
|
||||
// Build a CID via sha256 of empty bytes (so this test is
|
||||
// deterministic and doesn't depend on a fixture CID).
|
||||
let cid = at_crypto::cid::cid_for_raw(0x55, [0u8; 32]).unwrap();
|
||||
let raw = cid_to_bytes(&cid);
|
||||
assert!(!raw.is_empty());
|
||||
// Round-trip back through `cid_from_multihash_bytes`.
|
||||
let back = cid_from_multihash_bytes(&raw).unwrap();
|
||||
assert_eq!(back, cid);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn error_body_blob_not_found_format() {
|
||||
let (_code, json): (StatusCode, Json<ErrorBody>) = err(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"BlobNotFound",
|
||||
"no blob for cid",
|
||||
);
|
||||
let v = serde_json::to_value(&json.0).unwrap();
|
||||
assert_eq!(v["error"], serde_json::json!("BlobNotFound"));
|
||||
assert!(v["message"].is_string());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_content_type_strips_parameters() {
|
||||
assert_eq!(
|
||||
normalize_content_type("image/png; charset=binary"),
|
||||
Some("image/png".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_content_type("text/plain; charset=utf-8"),
|
||||
Some("text/plain".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_content_type("image/jpeg"),
|
||||
Some("image/jpeg".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_content_type_lowercases() {
|
||||
assert_eq!(
|
||||
normalize_content_type("IMAGE/PNG"),
|
||||
Some("image/png".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_content_type("Image/Jpeg"),
|
||||
Some("image/jpeg".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_content_type_rejects_empty() {
|
||||
assert_eq!(normalize_content_type(""), None);
|
||||
assert_eq!(normalize_content_type(";"), None);
|
||||
assert_eq!(normalize_content_type(" "), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upload_response_shape_matches_spec() {
|
||||
let cid = at_crypto::cid::cid_for_raw(0x55, [7u8; 32]).unwrap();
|
||||
let json = upload_response(&cid, "image/png", 1024);
|
||||
let v = serde_json::to_value(&json.0).unwrap();
|
||||
assert_eq!(v["blob"]["$type"], "blob");
|
||||
assert!(v["blob"]["ref"]["$link"].is_string());
|
||||
assert_eq!(v["blob"]["mimeType"], "image/png");
|
||||
assert_eq!(v["blob"]["size"], 1024);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn max_blob_size_is_one_mib() {
|
||||
assert_eq!(MAX_BLOB_SIZE, 1024 * 1024);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,471 @@
|
||||
//! `com.atproto.feed.like.*` and `com.atproto.repo.deleteRecord` endpoints.
|
||||
//!
|
||||
//! Likes & reposts share the same wire shape (a record value of
|
||||
//! `{ subject: strongRef, createdAt: datetime }`), so the like
|
||||
//! handler accepts either a fully-qualified `createRecord`-shaped body
|
||||
//! or a flat BSky-style body. The hardcoded collection is
|
||||
//! `app.bsky.feed.like`; the Tauri client doesn't need to know about
|
||||
//! XRPC details — it just calls
|
||||
//! `app.bsky.feed.like.create` with `subject.uri` + `subject.cid` and
|
||||
//! gets back the new record's URI + CID.
|
||||
//!
|
||||
//! `com.atproto.repo.deleteRecord` is a generic XRPC handler — it
|
||||
//! accepts any `collection` and `rkey` for the caller's own repo. The
|
||||
//! Tauri client uses it for both unlike and unrepost, simply by
|
||||
//! passing `collection = "app.bsky.feed.like"` or
|
||||
//! `"app.bsky.feed.repost"`. The repo is loaded, the entry is
|
||||
//! removed from the MST, a new commit is signed, the AppView is
|
||||
//! told to drop the row, and we return the new commit CID + rev.
|
||||
|
||||
use crate::routes::helpers::{apply_repo_write, err, to_sqlx_error, RepoWriteOutcome};
|
||||
use at_repo::blockstore::Blockstore;
|
||||
use crate::routes::types::ErrorBody;
|
||||
use crate::state::AppState;
|
||||
use at_crypto::cid::cid_for_cbor;
|
||||
use at_repo::rev::Tid;
|
||||
use axum::extract::State;
|
||||
use axum::http::{HeaderMap, StatusCode};
|
||||
use axum::Json;
|
||||
use bytes::Bytes;
|
||||
use cid::Cid;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use tracing::info;
|
||||
|
||||
const LIKE_COLLECTION: &str = "app.bsky.feed.like";
|
||||
|
||||
/// `POST /xrpc/com.atproto.feed.like.create`
|
||||
///
|
||||
/// Accepts either:
|
||||
/// * `{ repo, collection, record: { subject, createdAt } }` — the
|
||||
/// generic `com.atproto.repo.createRecord` body shape. We
|
||||
/// validate `collection == "app.bsky.feed.like"`.
|
||||
/// * `{ subject, createdAt }` — the flat BSky shape. `repo`
|
||||
/// is taken from the JWT `sub`.
|
||||
///
|
||||
/// Returns `{ uri, cid }` of the new record.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateLikeReq {
|
||||
/// Optional in the flat shape; required to match the JWT in the
|
||||
/// generic shape.
|
||||
pub repo: Option<String>,
|
||||
/// Ignored if present in the flat shape; validated to be
|
||||
/// `app.bsky.feed.like` in the generic shape.
|
||||
pub collection: Option<String>,
|
||||
/// Generic shape: full record value.
|
||||
pub record: Option<Value>,
|
||||
/// Flat shape: `{ uri, cid }` reference to the post being liked.
|
||||
pub subject: Option<Value>,
|
||||
/// Flat shape: ISO-8601 client timestamp.
|
||||
#[serde(rename = "createdAt")]
|
||||
pub created_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct CreateLikeResp {
|
||||
pub uri: String,
|
||||
pub cid: String,
|
||||
pub commit: Value,
|
||||
}
|
||||
|
||||
/// `POST /xrpc/com.atproto.repo.deleteRecord`
|
||||
///
|
||||
/// Removes a record from the caller's own repo. Idempotent: deleting
|
||||
/// a non-existent rkey is a 200 with an empty commit (we just sign
|
||||
/// over the unchanged repo).
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct DeleteRecordReq {
|
||||
pub repo: String,
|
||||
pub collection: String,
|
||||
pub rkey: String,
|
||||
/// Optional optimistic-concurrency token. We don't implement
|
||||
/// swap semantics yet; ignored if present.
|
||||
#[serde(rename = "swapCommit")]
|
||||
#[allow(dead_code)]
|
||||
pub swap_commit: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct DeleteRecordResp {
|
||||
pub commit: Value,
|
||||
}
|
||||
|
||||
// -- helpers ----------------------------------------------------------------
|
||||
|
||||
/// Pull the bearer token, verify it, and check the `sub` claim
|
||||
/// matches the `repo` field in the body. Centralises the auth flow
|
||||
/// for the like/delete handlers so we don't duplicate the boilerplate.
|
||||
fn authenticate_request(
|
||||
state: &AppState,
|
||||
headers: &HeaderMap,
|
||||
repo: &str,
|
||||
) -> Result<(), (StatusCode, Json<ErrorBody>)> {
|
||||
let token = headers
|
||||
.get("authorization")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|s| s.strip_prefix("Bearer "))
|
||||
.ok_or_else(|| {
|
||||
err(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"Unauthenticated",
|
||||
"missing Authorization: Bearer header",
|
||||
)
|
||||
})?;
|
||||
let server_pk = crate::jwt_issuer::server_p256_public_multibase(&state.cfg)
|
||||
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, "InternalServerError", e.to_string()))?;
|
||||
let claims = at_crypto::jwt::verify_jwt(token, &server_pk).map_err(|e| {
|
||||
err(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"TokenInvalid",
|
||||
format!("invalid token: {e}"),
|
||||
)
|
||||
})?;
|
||||
if claims.sub != repo {
|
||||
return Err(err(
|
||||
StatusCode::FORBIDDEN,
|
||||
"Forbidden",
|
||||
"token sub does not match repo",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Build the canonical like record value. We accept the record in
|
||||
/// two shapes and normalise into `{ subject, createdAt }` here.
|
||||
fn build_like_record(req: &CreateLikeReq) -> Result<Value, (StatusCode, Json<ErrorBody>)> {
|
||||
// Shape 1: `record` is the full value already.
|
||||
if let Some(rec) = req.record.as_ref() {
|
||||
if !rec.is_object() {
|
||||
return Err(err(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"InvalidRequest",
|
||||
"record must be an object",
|
||||
));
|
||||
}
|
||||
return Ok(rec.clone());
|
||||
}
|
||||
// Shape 2: `subject` and `createdAt` at the top level.
|
||||
let subject = req.subject.as_ref().ok_or_else(|| {
|
||||
err(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"InvalidRequest",
|
||||
"missing `subject` (or `record`)",
|
||||
)
|
||||
})?;
|
||||
if !subject.is_object() {
|
||||
return Err(err(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"InvalidRequest",
|
||||
"`subject` must be an object {uri,cid}",
|
||||
));
|
||||
}
|
||||
let created_at = req.created_at.as_deref().ok_or_else(|| {
|
||||
err(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"InvalidRequest",
|
||||
"missing `createdAt` (or `record.createdAt`)",
|
||||
)
|
||||
})?;
|
||||
if chrono::DateTime::parse_from_rfc3339(created_at).is_err() {
|
||||
return Err(err(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"InvalidRequest",
|
||||
format!("invalid createdAt: {created_at}"),
|
||||
));
|
||||
}
|
||||
Ok(json!({
|
||||
"subject": subject,
|
||||
"createdAt": created_at,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Load the user's signing key + blocks, reconstruct the `Repo`,
|
||||
/// apply `f(repo)`, sign a new commit, persist the resulting blocks,
|
||||
/// and update the `repos` head row. Returns the new head commit CID +
|
||||
/// signed bytes for downstream use (the AppView push, etc.).
|
||||
///
|
||||
/// (Moved to `routes::helpers::apply_repo_write` in Phase 5b review
|
||||
/// fix C1 so the entire read/modify/write cycle runs inside a
|
||||
/// Postgres transaction with `SELECT … FOR UPDATE` on the user's
|
||||
/// `repos` row. Concurrent writers for the same DID now serialise
|
||||
/// behind the row lock instead of clobbering each other.)
|
||||
async fn apply_and_commit<F>(
|
||||
state: &AppState,
|
||||
did: &str,
|
||||
f: F,
|
||||
) -> Result<at_repo::commit::Commit, (StatusCode, Json<ErrorBody>)>
|
||||
where
|
||||
F: for<'b> FnOnce(
|
||||
&'b mut at_repo::repo::Repo<at_repo::blockstore::MemoryBlockstore>,
|
||||
) -> std::pin::Pin<
|
||||
Box<dyn std::future::Future<Output = Result<RepoWriteOutcome, sqlx::Error>> + Send + 'b>,
|
||||
>,
|
||||
{
|
||||
apply_repo_write(state, did, f).await.map(|o| o.commit)
|
||||
}
|
||||
|
||||
// -- handlers ---------------------------------------------------------------
|
||||
|
||||
pub async fn create_like(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Json(req): Json<CreateLikeReq>,
|
||||
) -> Result<Json<CreateLikeResp>, (StatusCode, Json<ErrorBody>)> {
|
||||
// Normalise the two request shapes.
|
||||
let record = build_like_record(&req)?;
|
||||
|
||||
// Resolve the repo: explicit body value, or fall back to the
|
||||
// session subject (which we haven't yet verified). We have to
|
||||
// authenticate first to know the session sub; the auth helper
|
||||
// takes `repo` as a hint, so we require either an explicit repo
|
||||
// in the body or we use a placeholder and re-check below.
|
||||
//
|
||||
// Simpler: require the body to either include `repo` (and we
|
||||
// verify it matches the JWT) or omit it (and we take the JWT sub
|
||||
// as canonical). To keep the auth helper signature unchanged we
|
||||
// pick the candidate repo here, then verify the JWT.
|
||||
let candidate_repo = req.repo.clone().unwrap_or_default();
|
||||
if candidate_repo.is_empty() {
|
||||
return Err(err(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"InvalidRequest",
|
||||
"missing `repo` (no JWT-derived fallback for this endpoint)",
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(coll) = req.collection.as_deref() {
|
||||
if coll != LIKE_COLLECTION {
|
||||
return Err(err(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"InvalidRequest",
|
||||
format!("collection must be `{LIKE_COLLECTION}`; got `{coll}`"),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
authenticate_request(&state, &headers, &candidate_repo)?;
|
||||
let did = candidate_repo;
|
||||
|
||||
// Compute the record value CID. We need it before mutating the
|
||||
// repo so we can pass it to `put_record` and to the AppView push.
|
||||
let mut record_buf = Vec::new();
|
||||
ciborium::into_writer(&record, &mut record_buf).map_err(|e| {
|
||||
err(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"InternalServerError",
|
||||
format!("cbor: {e}"),
|
||||
)
|
||||
})?;
|
||||
let value_cid: Cid = cid_for_cbor(&record_buf).map_err(|e| {
|
||||
err(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"InternalServerError",
|
||||
format!("cid: {e}"),
|
||||
)
|
||||
})?;
|
||||
|
||||
// TID for the rkey — deterministic clock-based id, like every
|
||||
// other createRecord in this server.
|
||||
let rkey = Tid::new().as_str().to_string();
|
||||
|
||||
let push_handle = state.appview.clone();
|
||||
let push_did = did.clone();
|
||||
let value_cid_str = value_cid.to_string();
|
||||
let push_record = record.clone();
|
||||
let push_rkey = rkey.clone();
|
||||
|
||||
let commit = apply_and_commit(&state, &did, move |repo| {
|
||||
let value_cid = value_cid;
|
||||
let rkey = rkey;
|
||||
let record_buf = record_buf;
|
||||
Box::pin(async move {
|
||||
// Repo assumes the value block is already in the
|
||||
// blockstore — that's the caller's responsibility, same
|
||||
// as in `create_record`.
|
||||
repo.blockstore
|
||||
.put(&value_cid, Bytes::from(record_buf))
|
||||
.await
|
||||
.map_err(to_sqlx_error)?;
|
||||
repo.put_record(LIKE_COLLECTION, &rkey, value_cid)
|
||||
.await
|
||||
.map_err(to_sqlx_error)?;
|
||||
let commit = repo.commit().await.map_err(to_sqlx_error)?;
|
||||
let head_cid_bytes = commit.cid.to_bytes().to_vec();
|
||||
let head_commit_bytes = commit.signed_bytes.clone();
|
||||
Ok(RepoWriteOutcome {
|
||||
commit,
|
||||
head_cid_bytes,
|
||||
head_commit_bytes,
|
||||
})
|
||||
})
|
||||
})
|
||||
.await?;
|
||||
|
||||
info!(
|
||||
collection = LIKE_COLLECTION,
|
||||
rkey = %push_rkey,
|
||||
cid = %value_cid,
|
||||
commit = %commit.cid,
|
||||
"like created"
|
||||
);
|
||||
|
||||
// Best-effort push to the AppView. Spawned so a slow / missing
|
||||
// AppView never blocks the write response.
|
||||
let push_cid_owned = value_cid_str.clone();
|
||||
let push_rkey_owned = push_rkey.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = push_handle
|
||||
.push_create(
|
||||
&push_did,
|
||||
LIKE_COLLECTION,
|
||||
&push_rkey_owned,
|
||||
&push_cid_owned,
|
||||
&push_record,
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(error = %e, did = %push_did, "appview push_create failed; jetstream will replay");
|
||||
}
|
||||
});
|
||||
|
||||
let uri = format!("at://{did}/{LIKE_COLLECTION}/{push_rkey}");
|
||||
Ok(Json(CreateLikeResp {
|
||||
uri,
|
||||
cid: value_cid_str,
|
||||
commit: json!({
|
||||
"cid": commit.cid.to_string(),
|
||||
"rev": commit.rev,
|
||||
}),
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn delete_record(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Json(req): Json<DeleteRecordReq>,
|
||||
) -> Result<Json<DeleteRecordResp>, (StatusCode, Json<ErrorBody>)> {
|
||||
if req.collection.is_empty() || req.rkey.is_empty() {
|
||||
return Err(err(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"InvalidRequest",
|
||||
"`collection` and `rkey` are required",
|
||||
));
|
||||
}
|
||||
authenticate_request(&state, &headers, &req.repo)?;
|
||||
let did = req.repo.clone();
|
||||
let collection = req.collection.clone();
|
||||
let rkey = req.rkey.clone();
|
||||
|
||||
let push_handle = state.appview.clone();
|
||||
let push_did = did.clone();
|
||||
let push_collection = collection.clone();
|
||||
let push_rkey = rkey.clone();
|
||||
|
||||
// `Repo::delete_record` is idempotent at the MST level (returns
|
||||
// an unchanged tree if the key isn't present), so we always
|
||||
// sign a new commit — the spec says 200 on a no-op delete.
|
||||
let commit = apply_and_commit(&state, &did, move |repo| {
|
||||
let collection = collection;
|
||||
let rkey = rkey;
|
||||
Box::pin(async move {
|
||||
repo.delete_record(&collection, &rkey)
|
||||
.await
|
||||
.map_err(to_sqlx_error)?;
|
||||
let commit = repo.commit().await.map_err(to_sqlx_error)?;
|
||||
let head_cid_bytes = commit.cid.to_bytes().to_vec();
|
||||
let head_commit_bytes = commit.signed_bytes.clone();
|
||||
Ok(RepoWriteOutcome {
|
||||
commit,
|
||||
head_cid_bytes,
|
||||
head_commit_bytes,
|
||||
})
|
||||
})
|
||||
})
|
||||
.await?;
|
||||
|
||||
info!(
|
||||
collection = %push_collection,
|
||||
rkey = %push_rkey,
|
||||
commit = %commit.cid,
|
||||
"record deleted"
|
||||
);
|
||||
|
||||
// Best-effort AppView push.
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = push_handle
|
||||
.push_delete(&push_did, &push_collection, &push_rkey)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(error = %e, did = %push_did, "appview push_delete failed; jetstream will replay");
|
||||
}
|
||||
});
|
||||
|
||||
Ok(Json(DeleteRecordResp {
|
||||
commit: json!({
|
||||
"cid": commit.cid.to_string(),
|
||||
"rev": commit.rev,
|
||||
}),
|
||||
}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn build_like_record_from_flat_shape() {
|
||||
let req = CreateLikeReq {
|
||||
repo: None,
|
||||
collection: None,
|
||||
record: None,
|
||||
subject: Some(json!({"uri": "at://x/y/z", "cid": "bafy"})),
|
||||
created_at: Some("2026-07-04T12:00:00Z".to_string()),
|
||||
};
|
||||
let v = build_like_record(&req).unwrap();
|
||||
assert_eq!(v["subject"]["uri"], "at://x/y/z");
|
||||
assert_eq!(v["subject"]["cid"], "bafy");
|
||||
assert_eq!(v["createdAt"], "2026-07-04T12:00:00Z");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_like_record_from_generic_shape() {
|
||||
let req = CreateLikeReq {
|
||||
repo: Some("did:plc:abc".into()),
|
||||
collection: Some("app.bsky.feed.like".into()),
|
||||
record: Some(json!({
|
||||
"subject": {"uri": "at://x/y/z", "cid": "bafy"},
|
||||
"createdAt": "2026-07-04T12:00:00Z"
|
||||
})),
|
||||
subject: None,
|
||||
created_at: None,
|
||||
};
|
||||
let v = build_like_record(&req).unwrap();
|
||||
assert_eq!(v["subject"]["uri"], "at://x/y/z");
|
||||
assert_eq!(v["createdAt"], "2026-07-04T12:00:00Z");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_like_record_rejects_missing_subject() {
|
||||
let req = CreateLikeReq {
|
||||
repo: Some("did:plc:abc".into()),
|
||||
collection: None,
|
||||
record: None,
|
||||
subject: None,
|
||||
created_at: Some("2026-07-04T12:00:00Z".into()),
|
||||
};
|
||||
assert!(build_like_record(&req).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_like_record_rejects_bad_datetime() {
|
||||
let req = CreateLikeReq {
|
||||
repo: None,
|
||||
collection: None,
|
||||
record: None,
|
||||
subject: Some(json!({"uri": "x", "cid": "y"})),
|
||||
created_at: Some("yesterday".into()),
|
||||
};
|
||||
assert!(build_like_record(&req).is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,456 @@
|
||||
//! Shared helpers for the PDS route handlers.
|
||||
//!
|
||||
//! These are used by both `repo.rs` (mutable repo operations) and `sync.rs`
|
||||
//! (read-only sync endpoints). They handle the boilerplate of:
|
||||
//!
|
||||
//! * Loading every block belonging to a user from the `repo_blocks` table
|
||||
//! into an in-memory [`MemoryBlockstore`].
|
||||
//! * Loading the user's secp256k1 signing key from `users.signing_key`.
|
||||
//! * Detecting the all-zero placeholder we use for a fresh account that has
|
||||
//! no commits yet.
|
||||
//! * Serialising a write path under a Postgres row lock so concurrent
|
||||
//! writers for the same DID can't trample each other's MST updates
|
||||
//! (Phase 5b review C1).
|
||||
|
||||
use crate::routes::types::ErrorBody;
|
||||
use crate::state::AppState;
|
||||
use at_crypto::cid::cid_from_multihash_bytes;
|
||||
use at_repo::blockstore::{Blockstore, MemoryBlockstore};
|
||||
use at_repo::repo::Repo;
|
||||
use axum::http::StatusCode;
|
||||
use axum::Json;
|
||||
use bytes::Bytes;
|
||||
use cid::Cid;
|
||||
use k256::ecdsa::SigningKey;
|
||||
use k256::SecretKey;
|
||||
use sqlx::Postgres;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Load every block belonging to `did` from the `repo_blocks` table into a
|
||||
/// fresh in-memory blockstore. Used to reconstruct a [`crate::at_repo::Repo`]
|
||||
/// for either mutation or read-only inspection.
|
||||
pub async fn load_user_blockstore(
|
||||
state: &AppState,
|
||||
did: &str,
|
||||
) -> Result<Arc<MemoryBlockstore>, (StatusCode, Json<ErrorBody>)> {
|
||||
let bs = MemoryBlockstore::new();
|
||||
let rows: Vec<(Vec<u8>, Vec<u8>)> = sqlx::query_as(
|
||||
"SELECT cid, block FROM repo_blocks WHERE did = $1",
|
||||
)
|
||||
.bind(did)
|
||||
.fetch_all(&state.db)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
err(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"InternalServerError",
|
||||
format!("repo_blocks load: {e}"),
|
||||
)
|
||||
})?;
|
||||
for (cid_bytes, block) in rows {
|
||||
let cid = cid_from_multihash_bytes(&cid_bytes).map_err(|e| {
|
||||
err(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"InternalServerError",
|
||||
format!("invalid cid in repo_blocks: {e}"),
|
||||
)
|
||||
})?;
|
||||
bs.put(&cid, Bytes::from(block))
|
||||
.await
|
||||
.map_err(|e| {
|
||||
err(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"InternalServerError",
|
||||
format!("blockstore put: {e}"),
|
||||
)
|
||||
})?;
|
||||
}
|
||||
Ok(Arc::new(bs))
|
||||
}
|
||||
|
||||
/// Hex sentinel stored in `head_cid` / `head_commit` for a fresh account
|
||||
/// (no commits yet).
|
||||
pub fn is_zero_blob(b: &[u8]) -> bool {
|
||||
!b.is_empty() && b.iter().all(|x| *x == 0)
|
||||
}
|
||||
|
||||
/// Construct the user's `SigningKey` from `users.signing_key` (raw k256
|
||||
/// secret-bytes).
|
||||
pub fn load_signing_key(
|
||||
bytes: &[u8],
|
||||
) -> Result<SigningKey, (StatusCode, Json<ErrorBody>)> {
|
||||
let secret = SecretKey::from_slice(bytes).map_err(|e| {
|
||||
err(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"InternalServerError",
|
||||
format!("invalid signing key bytes: {e}"),
|
||||
)
|
||||
})?;
|
||||
Ok(SigningKey::from(secret))
|
||||
}
|
||||
|
||||
/// Load the head commit CID + signed commit block for `did`. Returns
|
||||
/// `Ok(None)` if the account has no commits yet.
|
||||
pub async fn load_head_commit(
|
||||
state: &AppState,
|
||||
did: &str,
|
||||
) -> Result<Option<(Cid, Vec<u8>)>, (StatusCode, Json<ErrorBody>)> {
|
||||
let row: Option<(Vec<u8>, Vec<u8>)> = sqlx::query_as(
|
||||
"SELECT head_cid, head_commit FROM repos WHERE did = $1",
|
||||
)
|
||||
.bind(did)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
err(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"InternalServerError",
|
||||
format!("repos read: {e}"),
|
||||
)
|
||||
})?;
|
||||
let (head_cid_blob, head_commit_blob) = match row {
|
||||
Some(r) => r,
|
||||
None => return Ok(None),
|
||||
};
|
||||
if is_zero_blob(&head_cid_blob) || is_zero_blob(&head_commit_blob) {
|
||||
return Ok(None);
|
||||
}
|
||||
let cid = cid_from_multihash_bytes(&head_cid_blob).map_err(|e| {
|
||||
err(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"InternalServerError",
|
||||
format!("invalid head_cid bytes: {e}"),
|
||||
)
|
||||
})?;
|
||||
Ok(Some((cid, head_commit_blob)))
|
||||
}
|
||||
|
||||
/// Construct an XRPC-shaped error tuple used by all the route handlers.
|
||||
pub fn err(
|
||||
code: StatusCode,
|
||||
name: &str,
|
||||
msg: impl Into<String>,
|
||||
) -> (StatusCode, Json<ErrorBody>) {
|
||||
(
|
||||
code,
|
||||
Json(ErrorBody::new(name, Some(msg.into()))),
|
||||
)
|
||||
}
|
||||
|
||||
/// Convert an `anyhow::Error` (the error type returned by `at_repo`'s
|
||||
/// repo methods) into a `sqlx::Error` so the closure handed to
|
||||
/// [`apply_repo_write`] can return its outcome via `Result<_, sqlx::Error>`.
|
||||
///
|
||||
/// `anyhow::Error` doesn't implement `sqlx::DatabaseError`, so we
|
||||
/// can't use `?` directly — the conversion wraps the original error
|
||||
/// into `sqlx::Error::Decode` which preserves the source via
|
||||
/// `Box<dyn std::error::Error + Send + Sync>`. `anyhow::Error` doesn't
|
||||
/// implement `std::error::Error` itself, so we downcast its source
|
||||
/// chain to a `String` (losing fidelity but never panicking on the
|
||||
/// unknown source type).
|
||||
pub fn to_sqlx_error(e: anyhow::Error) -> sqlx::Error {
|
||||
// Walk the anyhow chain and surface the first source that
|
||||
// implements StdError; fall back to a string wrapper.
|
||||
let dyn_err: Box<dyn std::error::Error + Send + Sync> =
|
||||
match e.downcast::<Box<dyn std::error::Error + Send + Sync>>() {
|
||||
Ok(boxed) => boxed,
|
||||
Err(other) => {
|
||||
let s = format!("{other:#}");
|
||||
Box::<dyn std::error::Error + Send + Sync>::from(s)
|
||||
}
|
||||
};
|
||||
sqlx::Error::Decode(dyn_err)
|
||||
}
|
||||
|
||||
// -- repo write helper (Phase 5b review C1) ---------------------------------
|
||||
//
|
||||
// The previous code did:
|
||||
// 1. SELECT head_commit FROM repos WHERE did = $1 -- non-locking read
|
||||
// 2. Build an in-memory MST + apply the operation
|
||||
// 3. INSERT blocks into repo_blocks
|
||||
// 4. UPDATE repos SET head_cid = ...
|
||||
//
|
||||
// Two concurrent writers could both read the same head_commit, both build a
|
||||
// valid child commit, and the second UPDATE would silently overwrite the
|
||||
// first. The first writer's MST changes would survive in `repo_blocks` but
|
||||
// become unreachable from `head_commit`, so a follow-up load+save on the
|
||||
// repo would still see them — and then `Mst::put` would either no-op
|
||||
// (because the rkey already exists) or branch off into a stale tree,
|
||||
// depending on which blocks landed.
|
||||
//
|
||||
// The fix is to take a row-level write lock on `repos` for the duration of
|
||||
// the in-memory mutation + commit + persist. Postgres `SELECT … FOR UPDATE`
|
||||
// inside a transaction does exactly that: the lock is released when the
|
||||
// transaction commits or rolls back, so concurrent writers serialise
|
||||
// behind the holder rather than racing on the head_commit column.
|
||||
|
||||
/// Result of a successful repo write: the new signed commit, the CID
|
||||
/// pointing at the freshly-written head block, and the new revision
|
||||
/// string. Callers use the commit for AppView ingest pushes.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RepoWriteOutcome {
|
||||
pub commit: at_repo::commit::Commit,
|
||||
pub head_cid_bytes: Vec<u8>,
|
||||
pub head_commit_bytes: Vec<u8>,
|
||||
}
|
||||
|
||||
/// Apply a write to the user's repo under a row-level lock on the
|
||||
/// `repos` row, then commit. Concurrent writers for the same DID block
|
||||
/// behind the holder and proceed serially.
|
||||
///
|
||||
/// The flow:
|
||||
/// 1. `BEGIN`
|
||||
/// 2. `SELECT head_commit FROM repos WHERE did = $1 FOR UPDATE`
|
||||
/// 3. Hydrate the `Repo` from `repo_blocks` + the locked head commit.
|
||||
/// 4. Run the user's closure (`put_record`, `delete_record`, …) with
|
||||
/// a mutable reference to the repo. The closure returns a
|
||||
/// `RepoWriteOutcome` once it's finished mutating the repo and
|
||||
/// called `Repo::commit`.
|
||||
/// 5. Persist every block the closure (and `Repo::commit`) wrote into
|
||||
/// `repo_blocks`.
|
||||
/// 6. `UPDATE repos SET head_* = …` with the new commit.
|
||||
/// 7. `COMMIT` — releases the lock and makes the new head visible to
|
||||
/// other writers, who will now re-load from the new head instead of
|
||||
/// racing on the old one.
|
||||
///
|
||||
/// The closure's returned `RepoWriteOutcome` is built *before* the
|
||||
/// `UPDATE` (so the new commit's `signed_bytes` and CID are known when we
|
||||
/// write the row), but the transaction stays open until after the
|
||||
/// `UPDATE`. If the closure or `UPDATE` fails, the transaction rolls
|
||||
/// back and no head pointer or block row changes are visible.
|
||||
pub async fn apply_repo_write<F>(
|
||||
state: &AppState,
|
||||
did: &str,
|
||||
f: F,
|
||||
) -> Result<RepoWriteOutcome, (StatusCode, Json<ErrorBody>)>
|
||||
where
|
||||
F: for<'b> FnOnce(
|
||||
&'b mut Repo<MemoryBlockstore>,
|
||||
) -> std::pin::Pin<
|
||||
Box<dyn std::future::Future<Output = Result<RepoWriteOutcome, sqlx::Error>> + Send + 'b>,
|
||||
>,
|
||||
{
|
||||
let mut tx = state.db.begin().await.map_err(|e| {
|
||||
err(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"InternalServerError",
|
||||
format!("begin tx: {e}"),
|
||||
)
|
||||
})?;
|
||||
|
||||
// 2. Take the row-level write lock. Postgres parks competing
|
||||
// transactions here until we COMMIT/ROLLBACK.
|
||||
let head_row: Option<(Vec<u8>, Vec<u8>, Option<Vec<u8>>)> = sqlx::query_as(
|
||||
"SELECT head_cid, head_commit, prev_commit
|
||||
FROM repos
|
||||
WHERE did = $1
|
||||
FOR UPDATE",
|
||||
)
|
||||
.bind(did)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
err(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"InternalServerError",
|
||||
format!("repos FOR UPDATE: {e}"),
|
||||
)
|
||||
})?;
|
||||
|
||||
let (head_cid_blob, head_commit_blob) = match head_row {
|
||||
Some(r) => (r.0, r.1),
|
||||
None => {
|
||||
return Err((
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(ErrorBody::new(
|
||||
"RepoNotFound",
|
||||
Some(format!("no repo row for {did}")),
|
||||
)),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
// 3. Hydrate the user's signing key + blockstore. These reads are
|
||||
// not lock-sensitive — the signing key doesn't change, and the
|
||||
// blockstore reads are append-only from our perspective.
|
||||
//
|
||||
// We grab the signing key from outside the transaction (it's
|
||||
// a separate table) to keep the FOR UPDATE window as short as
|
||||
// practical — long-running locks contend with other writers.
|
||||
//
|
||||
// Note: a brand-new account may have a `repos` row but no
|
||||
// signing key in `users`; in that case `load_signing_key` from
|
||||
// the connection pool is fine because the transaction's
|
||||
// isolation level (Postgres default READ COMMITTED) lets the
|
||||
// second query see the committed row.
|
||||
let signing_key_bytes: Vec<u8> = sqlx::query_scalar(
|
||||
"SELECT signing_key FROM users WHERE did = $1",
|
||||
)
|
||||
.bind(did)
|
||||
.fetch_one(&state.db)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
err(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"InternalServerError",
|
||||
format!("users.signing_key read: {e}"),
|
||||
)
|
||||
})?;
|
||||
let signing_key = load_signing_key(&signing_key_bytes)?;
|
||||
|
||||
let blockstore = load_user_blockstore(state, did).await?;
|
||||
|
||||
// 4. Build the in-memory Repo. Fresh accounts have the all-zero
|
||||
// sentinel in head_cid / head_commit and start empty.
|
||||
let mut repo: Repo<MemoryBlockstore> = if is_zero_blob(&head_cid_blob)
|
||||
|| is_zero_blob(&head_commit_blob)
|
||||
{
|
||||
Repo::new(did.to_string(), signing_key.clone(), blockstore.clone())
|
||||
} else {
|
||||
let head_cid = cid_from_multihash_bytes(&head_cid_blob).map_err(|e| {
|
||||
err(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"InternalServerError",
|
||||
format!("invalid head_cid bytes: {e}"),
|
||||
)
|
||||
})?;
|
||||
// Defensive: re-seed the head commit block in case it hasn't
|
||||
// been flushed into the user's blockstore. Without this, a
|
||||
// load immediately after a previous put_record could miss the
|
||||
// head block.
|
||||
blockstore
|
||||
.put(&head_cid, Bytes::from(head_commit_blob.clone()))
|
||||
.await
|
||||
.map_err(|e| {
|
||||
err(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"InternalServerError",
|
||||
format!("seed head commit block: {e}"),
|
||||
)
|
||||
})?;
|
||||
Repo::load(
|
||||
did.to_string(),
|
||||
signing_key.clone(),
|
||||
blockstore.clone(),
|
||||
head_cid,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
err(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"InternalServerError",
|
||||
format!("repo load: {e:#}"),
|
||||
)
|
||||
})?
|
||||
};
|
||||
|
||||
// 5. Run the caller's closure. The closure may add records, delete
|
||||
// records, or do whatever else the repo supports. It receives a
|
||||
// mutable reference to the repo and returns a future that
|
||||
// completes once it's finished mutating + committing.
|
||||
//
|
||||
// The transaction (`tx`) is *not* passed to the closure — none
|
||||
// of the current write paths need it. If a future caller needs
|
||||
// to run additional queries under the row lock, we'd extend
|
||||
// this helper to also hand out a `&mut PgConnection` (which
|
||||
// doesn't have the lifetime headache of `&mut Transaction`).
|
||||
let outcome: RepoWriteOutcome = f(&mut repo).await.map_err(|e| {
|
||||
err(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"InternalServerError",
|
||||
format!("repo mutation: {e:#}"),
|
||||
)
|
||||
})?;
|
||||
|
||||
// 6. Persist every newly produced block (commit block + MST nodes +
|
||||
// value blocks the closure added). We re-serialise the repo
|
||||
// after the closure returns to make sure we capture everything
|
||||
// `Repo::commit` produced — `Repo::commit` writes its commit
|
||||
// block to the blockstore but `serialize_repo` is the canonical
|
||||
// "what's in this repo right now" dump.
|
||||
let (_header, all_blocks) = repo.serialize_repo().await.map_err(|e| {
|
||||
err(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"InternalServerError",
|
||||
format!("repo.serialize_repo: {e:#}"),
|
||||
)
|
||||
})?;
|
||||
persist_user_blocks_in_tx(&mut tx, did, &all_blocks).await?;
|
||||
|
||||
// 7. Update the head pointer. The prev_commit column carries the
|
||||
// head CID we read under the lock — that's the CID the new
|
||||
// commit's `prev` field also points at.
|
||||
let prev_param: Option<Vec<u8>> = if is_zero_blob(&head_cid_blob) {
|
||||
None
|
||||
} else {
|
||||
Some(head_cid_blob.clone())
|
||||
};
|
||||
sqlx::query(
|
||||
r#"UPDATE repos
|
||||
SET rev = $2,
|
||||
head_cid = $3,
|
||||
head_commit = $4,
|
||||
prev_commit = $5,
|
||||
indexed_at = now()
|
||||
WHERE did = $1"#,
|
||||
)
|
||||
.bind(did)
|
||||
.bind(&outcome.commit.rev)
|
||||
.bind(&outcome.head_cid_bytes)
|
||||
.bind(&outcome.head_commit_bytes)
|
||||
.bind(prev_param.as_deref())
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
err(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"InternalServerError",
|
||||
format!("repos update: {e}"),
|
||||
)
|
||||
})?;
|
||||
|
||||
tx.commit().await.map_err(|e| {
|
||||
err(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"InternalServerError",
|
||||
format!("tx commit: {e}"),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(outcome)
|
||||
}
|
||||
|
||||
/// Persist every block in `blocks` into `repo_blocks` using the open
|
||||
/// transaction. Mirrors the connection-pool version but uses the
|
||||
/// transaction's connection so the writes are part of the same atomic
|
||||
/// unit as the head pointer update.
|
||||
async fn persist_user_blocks_in_tx(
|
||||
tx: &mut sqlx::Transaction<'_, Postgres>,
|
||||
did: &str,
|
||||
blocks: &std::collections::HashMap<Cid, Vec<u8>>,
|
||||
) -> Result<(), (StatusCode, Json<ErrorBody>)> {
|
||||
for (cid, bytes) in blocks {
|
||||
if cid.to_bytes().iter().all(|b| *b == 0) {
|
||||
continue;
|
||||
}
|
||||
sqlx::query(
|
||||
r#"INSERT INTO repo_blocks (did, cid, block, size)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (did, cid) DO NOTHING"#,
|
||||
)
|
||||
.bind(did)
|
||||
.bind(cid.to_bytes().as_slice())
|
||||
.bind(bytes.as_slice())
|
||||
.bind(bytes.len() as i32)
|
||||
.execute(&mut **tx)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
err(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"InternalServerError",
|
||||
format!("repo_blocks insert: {e}"),
|
||||
)
|
||||
})?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
use crate::routes::types::{ResolveHandleReq, ResolveHandleResp};
|
||||
use crate::state::AppState;
|
||||
use axum::extract::State;
|
||||
use axum::http::StatusCode;
|
||||
use axum::Json;
|
||||
use tracing::warn;
|
||||
|
||||
pub async fn resolve_handle(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<ResolveHandleReq>,
|
||||
) -> Result<Json<ResolveHandleResp>, (StatusCode, Json<crate::routes::types::ErrorBody>)> {
|
||||
if let Some(zone) = state.cfg.pds_handle_dns_zone.strip_prefix(".") {
|
||||
if let Some(stripped) = req.handle.strip_suffix(zone) {
|
||||
let user = stripped.trim_end_matches('.');
|
||||
let full = format!("{}{}", user, state.cfg.pds_handle_dns_zone);
|
||||
let row: Option<(String,)> = sqlx::query_as("SELECT did FROM users WHERE handle = $1")
|
||||
.bind(&full)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, e))?;
|
||||
if let Some((did,)) = row {
|
||||
return Ok(Json(ResolveHandleResp { did }));
|
||||
}
|
||||
}
|
||||
}
|
||||
let row: Option<(String,)> = sqlx::query_as("SELECT did FROM users WHERE handle = $1")
|
||||
.bind(&req.handle)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, e))?;
|
||||
match row {
|
||||
Some((did,)) => Ok(Json(ResolveHandleResp { did })),
|
||||
None => {
|
||||
warn!(handle = %req.handle, "handle not found");
|
||||
Err(err(
|
||||
StatusCode::NOT_FOUND,
|
||||
anyhow::anyhow!("handle not found"),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn err(
|
||||
code: StatusCode,
|
||||
e: impl std::fmt::Display,
|
||||
) -> (StatusCode, Json<crate::routes::types::ErrorBody>) {
|
||||
(
|
||||
code,
|
||||
Json(crate::routes::types::ErrorBody::new(
|
||||
match code.as_u16() {
|
||||
400 => "InvalidRequest",
|
||||
401 => "Unauthenticated",
|
||||
403 => "Forbidden",
|
||||
404 => "NotFound",
|
||||
409 => "Conflict",
|
||||
_ => "InternalServerError",
|
||||
},
|
||||
Some(e.to_string()),
|
||||
)),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
pub mod auth;
|
||||
pub mod blob;
|
||||
pub mod feed;
|
||||
pub mod helpers;
|
||||
pub mod identity;
|
||||
pub mod repo;
|
||||
pub mod sync;
|
||||
pub mod types;
|
||||
@@ -0,0 +1,159 @@
|
||||
use crate::routes::helpers::{
|
||||
apply_repo_write, err, to_sqlx_error, RepoWriteOutcome,
|
||||
};
|
||||
use crate::routes::types::{CreateRecordReq, CreateRecordResp};
|
||||
use crate::state::AppState;
|
||||
use at_crypto::cid::cid_for_cbor;
|
||||
use at_repo::blockstore::Blockstore;
|
||||
use at_repo::rev::Tid;
|
||||
use axum::extract::State;
|
||||
use axum::http::{HeaderMap, StatusCode};
|
||||
use axum::Json;
|
||||
use bytes::Bytes;
|
||||
use cid::Cid;
|
||||
use tracing::info;
|
||||
|
||||
pub async fn create_record(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Json(req): Json<CreateRecordReq>,
|
||||
) -> Result<Json<CreateRecordResp>, (StatusCode, Json<crate::routes::types::ErrorBody>)> {
|
||||
let did = req.repo.clone();
|
||||
let auth = headers
|
||||
.get("authorization")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|s| s.strip_prefix("Bearer "));
|
||||
let token = match auth {
|
||||
Some(t) => t.to_string(),
|
||||
None => {
|
||||
return Err(err(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"Unauthenticated",
|
||||
"missing Authorization: Bearer header",
|
||||
));
|
||||
}
|
||||
};
|
||||
let server_pk = crate::jwt_issuer::server_p256_public_multibase(&state.cfg)
|
||||
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, "InternalServerError", e.to_string()))?;
|
||||
let claims = match at_crypto::jwt::verify_jwt(&token, &server_pk) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
return Err(err(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"TokenInvalid",
|
||||
format!("invalid token: {e}"),
|
||||
));
|
||||
}
|
||||
};
|
||||
if claims.sub != did {
|
||||
return Err(err(
|
||||
StatusCode::FORBIDDEN,
|
||||
"Forbidden",
|
||||
"token sub does not match repo",
|
||||
));
|
||||
}
|
||||
|
||||
let validate = req.validate.unwrap_or(true);
|
||||
if validate {
|
||||
if let Err(e) = state.lex.validate(&req.collection, &req.record) {
|
||||
return Err(err(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"InvalidRequest",
|
||||
format!("lex validation failed: {e}"),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let rkey = req
|
||||
.rkey
|
||||
.clone()
|
||||
.unwrap_or_else(|| Tid::new().as_str().to_string());
|
||||
|
||||
// 1. Encode the record value as CBOR, compute its CID.
|
||||
let mut record_buf = Vec::new();
|
||||
ciborium::into_writer(&req.record, &mut record_buf).map_err(|e| {
|
||||
err(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"InternalServerError",
|
||||
format!("cbor: {e}"),
|
||||
)
|
||||
})?;
|
||||
let value_cid: Cid = cid_for_cbor(&record_buf).map_err(|e| {
|
||||
err(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"InternalServerError",
|
||||
format!("cid: {e}"),
|
||||
)
|
||||
})?;
|
||||
|
||||
let push_handle = state.appview.clone();
|
||||
let push_did = did.clone();
|
||||
let push_coll = req.collection.clone();
|
||||
let push_rkey = rkey.clone();
|
||||
let push_cid = value_cid.to_string();
|
||||
let push_record = req.record.clone();
|
||||
let collection = req.collection.clone();
|
||||
|
||||
let outcome = apply_repo_write(&state, &did, move |repo| {
|
||||
let value_cid = value_cid;
|
||||
let rkey = rkey;
|
||||
let record_buf = record_buf;
|
||||
let collection = collection;
|
||||
Box::pin(async move {
|
||||
// Repo assumes the value block is already in the
|
||||
// blockstore — that's the caller's responsibility.
|
||||
repo.blockstore
|
||||
.put(&value_cid, Bytes::from(record_buf))
|
||||
.await
|
||||
.map_err(to_sqlx_error)?;
|
||||
let (uri, _returned_cid) = repo
|
||||
.put_record(&collection, &rkey, value_cid)
|
||||
.await
|
||||
.map_err(to_sqlx_error)?;
|
||||
let commit = repo.commit().await.map_err(to_sqlx_error)?;
|
||||
let head_cid_bytes = commit.cid.to_bytes().to_vec();
|
||||
let head_commit_bytes = commit.signed_bytes.clone();
|
||||
Ok(RepoWriteOutcome {
|
||||
commit,
|
||||
head_cid_bytes,
|
||||
head_commit_bytes,
|
||||
})
|
||||
})
|
||||
})
|
||||
.await?;
|
||||
|
||||
let uri = format!("at://{did}/{push_coll}/{push_rkey}");
|
||||
let commit = outcome.commit;
|
||||
info!(uri = %uri, cid = %value_cid, commit = %commit.cid, "record created");
|
||||
|
||||
// 10. Best-effort push to the AppView's `/internal/ingest-commit`.
|
||||
// We send the full record value (not just the CID) because the
|
||||
// AppView's indexer reads `embed` and `reply` off it.
|
||||
//
|
||||
// **Spawned** (not awaited) so a transient AppView outage never
|
||||
// blocks the user's write response. If the push fails, the
|
||||
// global Jetstream feed will eventually replay the commit to
|
||||
// the AppView.
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = push_handle
|
||||
.push_create(&push_did, &push_coll, &push_rkey, &push_cid, &push_record)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(error = %e, did = %push_did, "appview push_create failed; jetstream will replay");
|
||||
}
|
||||
});
|
||||
|
||||
Ok(Json(CreateRecordResp {
|
||||
uri,
|
||||
cid: value_cid.to_string(),
|
||||
commit: Some(serde_json::json!({
|
||||
"cid": commit.cid.to_string(),
|
||||
"rev": commit.rev,
|
||||
})),
|
||||
validation_status: if validate {
|
||||
Some("valid".into())
|
||||
} else {
|
||||
None
|
||||
},
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,554 @@
|
||||
//! `com.atproto.sync.*` endpoints.
|
||||
//!
|
||||
//! These are unauthenticated read-only endpoints that other PDSs, relays and
|
||||
//! services use to fetch a user's repository. Spec:
|
||||
//! <https://atproto.com/specs/sync>.
|
||||
//!
|
||||
//! Endpoints implemented here:
|
||||
//!
|
||||
//! * `com.atproto.sync.getRepo` — full CAR export of a repo
|
||||
//! * `com.atproto.sync.getBlocks` — selective block fetch by CID
|
||||
//! * `com.atproto.sync.getLatestCommit` — current commit CID + rev (JSON)
|
||||
//! * `com.atproto.sync.getRecord` — record value block as CAR
|
||||
//! * `com.atproto.sync.listRepos` — paginated list of all hosted repos
|
||||
//!
|
||||
//! The wire format for `getRepo`/`getBlocks`/`getRecord` is CAR v1
|
||||
//! (`application/vnd.ipld.car`). See `crate::car` for the writer.
|
||||
|
||||
use crate::car::CarWriter;
|
||||
use crate::routes::helpers::{err, load_head_commit, load_user_blockstore};
|
||||
use crate::state::AppState;
|
||||
use at_mst::Mst;
|
||||
use at_repo::blockstore::Blockstore;
|
||||
use at_repo::repo::Repo;
|
||||
use axum::extract::{Query, RawQuery, State};
|
||||
use axum::http::{header, HeaderValue, StatusCode};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::Json;
|
||||
use bytes::Bytes;
|
||||
use cid::Cid;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use std::str::FromStr;
|
||||
use url::form_urlencoded;
|
||||
|
||||
const CAR_MIME: &str = "application/vnd.ipld.car";
|
||||
const MAX_LIST_LIMIT: i64 = 1000;
|
||||
|
||||
// -- query / response types ------------------------------------------------
|
||||
|
||||
/// Parsed query parameters for `getBlocks`. We don't use `Query<HashMap<...>>`
|
||||
/// here because the spec calls for `?cids=a&cids=b&cids=c` (repeated keys)
|
||||
/// and `serde_urlencoded` (the default) only keeps the last value. We parse
|
||||
/// the raw query string manually in `get_blocks`.
|
||||
#[derive(Debug)]
|
||||
pub struct GetBlocksQuery {
|
||||
pub did: Option<String>,
|
||||
pub cids: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct GetRepoQuery {
|
||||
pub did: String,
|
||||
/// Not yet supported: when set we would return a diff CAR. The spec
|
||||
/// accepts a `since` parameter for `getRepo` so we parse it for forwards
|
||||
/// compatibility but ignore the value (we always return the full repo).
|
||||
#[allow(dead_code)]
|
||||
pub since: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct GetLatestCommitQuery {
|
||||
pub did: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct GetRecordQuery {
|
||||
pub did: String,
|
||||
pub collection: String,
|
||||
pub rkey: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ListReposQuery {
|
||||
pub limit: Option<i64>,
|
||||
pub cursor: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ListReposRepo {
|
||||
did: String,
|
||||
head: String,
|
||||
rev: String,
|
||||
active: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ListReposResp {
|
||||
repos: Vec<ListReposRepo>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
cursor: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct GetLatestCommitResp {
|
||||
cid: String,
|
||||
rev: String,
|
||||
}
|
||||
|
||||
// -- response helpers ------------------------------------------------------
|
||||
|
||||
/// Wrap a CAR byte vector in an HTTP response with the correct
|
||||
/// `Content-Type` header.
|
||||
fn car_response(bytes: Vec<u8>) -> Response {
|
||||
let mut resp = (StatusCode::OK, Bytes::from(bytes)).into_response();
|
||||
resp.headers_mut().insert(
|
||||
header::CONTENT_TYPE,
|
||||
HeaderValue::from_static(CAR_MIME),
|
||||
);
|
||||
resp
|
||||
}
|
||||
|
||||
/// Parse a raw query string into a [`GetBlocksQuery`]. We can't use the
|
||||
/// `axum::extract::Query` extractor for this because the atproto wire format
|
||||
/// sends `?cids=a&cids=b&cids=c` (repeated keys) and `serde_urlencoded`
|
||||
/// silently drops all but the last value.
|
||||
fn parse_get_blocks_query(raw: &str) -> GetBlocksQuery {
|
||||
let mut did: Option<String> = None;
|
||||
let mut cids: Vec<String> = Vec::new();
|
||||
for (k, v) in form_urlencoded::parse(raw.as_bytes()) {
|
||||
match k.as_ref() {
|
||||
"did" => did = Some(v.into_owned()),
|
||||
"cids" => {
|
||||
for piece in v.split(',') {
|
||||
let piece = piece.trim();
|
||||
if !piece.is_empty() {
|
||||
cids.push(piece.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
GetBlocksQuery { did, cids }
|
||||
}
|
||||
|
||||
// -- getRepo ---------------------------------------------------------------
|
||||
|
||||
pub async fn get_repo(
|
||||
State(state): State<AppState>,
|
||||
Query(q): Query<GetRepoQuery>,
|
||||
) -> Result<Response, (StatusCode, Json<crate::routes::types::ErrorBody>)> {
|
||||
let (head_cid, head_commit_bytes) = match load_head_commit(&state, &q.did).await? {
|
||||
Some(t) => t,
|
||||
None => {
|
||||
return Err(err(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"RepoNotFound",
|
||||
format!("no commits for did `{}`", q.did),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
// Load every block for the user from the `repo_blocks` table, then seed
|
||||
// the latest commit block in case it was added by a process that didn't
|
||||
// persist it (defensive: `repo_blocks` is updated before `repos` so the
|
||||
// commit block should already be there).
|
||||
let blockstore = load_user_blockstore(&state, &q.did).await?;
|
||||
blockstore
|
||||
.put(&head_cid, Bytes::from(head_commit_bytes.clone()))
|
||||
.await
|
||||
.map_err(|e| {
|
||||
err(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"InternalServerError",
|
||||
format!("blockstore put head commit: {e:#}"),
|
||||
)
|
||||
})?;
|
||||
|
||||
// Pull every block out of the in-memory blockstore and put it in the CAR.
|
||||
// We do NOT reconstruct the `Repo` here — we want to faithfully export
|
||||
// every persisted block, not just the ones reachable from the live MST
|
||||
// (the persisted set may include older MST nodes retained for proof
|
||||
// purposes).
|
||||
let all = blockstore.list().await.map_err(|e| {
|
||||
err(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"InternalServerError",
|
||||
format!("blockstore list: {e:#}"),
|
||||
)
|
||||
})?;
|
||||
|
||||
let mut writer = CarWriter::new();
|
||||
for (cid, data) in &all {
|
||||
writer.append(*cid, data);
|
||||
}
|
||||
let car = writer.finish(&[head_cid]);
|
||||
Ok(car_response(car))
|
||||
}
|
||||
|
||||
// -- getBlocks -------------------------------------------------------------
|
||||
|
||||
pub async fn get_blocks(
|
||||
State(state): State<AppState>,
|
||||
RawQuery(raw): RawQuery,
|
||||
) -> Result<Response, (StatusCode, Json<crate::routes::types::ErrorBody>)> {
|
||||
// Parse the query string manually so we can handle repeated `cids=...`
|
||||
// keys (the atproto spec calls for `?cids=a&cids=b&cids=c`, and
|
||||
// `serde_urlencoded` collapses repeated keys to the last value).
|
||||
let q = parse_get_blocks_query(raw.as_deref().unwrap_or(""));
|
||||
if q.did.as_deref().unwrap_or("").is_empty() {
|
||||
return Err(err(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"InvalidRequest",
|
||||
"missing `did` parameter",
|
||||
));
|
||||
}
|
||||
if q.cids.is_empty() {
|
||||
return Err(err(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"InvalidRequest",
|
||||
"missing `cids` parameter",
|
||||
));
|
||||
}
|
||||
|
||||
let did = q.did.unwrap();
|
||||
|
||||
// Validate every requested CID up front so we can return a sensible error
|
||||
// for malformed input.
|
||||
let mut parsed: Vec<Cid> = Vec::with_capacity(q.cids.len());
|
||||
for s in &q.cids {
|
||||
let c = Cid::from_str(s).map_err(|e| {
|
||||
err(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"InvalidRequest",
|
||||
format!("invalid cid `{s}`: {e}"),
|
||||
)
|
||||
})?;
|
||||
parsed.push(c);
|
||||
}
|
||||
|
||||
// Confirm the repo exists by looking up the head commit. We use this only
|
||||
// as a "does this DID have a repo" check — the per-CID lookups below
|
||||
// don't need a head commit.
|
||||
match load_head_commit(&state, &did).await? {
|
||||
Some(_) => {}
|
||||
None => {
|
||||
return Err(err(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"RepoNotFound",
|
||||
format!("no commits for did `{did}`"),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let blockstore = load_user_blockstore(&state, &did).await?;
|
||||
let mut writer = CarWriter::new();
|
||||
let mut any_block = false;
|
||||
// Spec: if NONE of the requested blocks are present, return 400
|
||||
// `BlockNotFound`. We do that by tracking whether we found anything and
|
||||
// bailing if not.
|
||||
for cid in &parsed {
|
||||
if let Some(bytes) = blockstore.get(cid).await.map_err(|e| {
|
||||
err(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"InternalServerError",
|
||||
format!("blockstore get: {e:#}"),
|
||||
)
|
||||
})? {
|
||||
writer.append(*cid, &bytes);
|
||||
any_block = true;
|
||||
}
|
||||
}
|
||||
if !any_block {
|
||||
return Err(err(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"BlockNotFound",
|
||||
"none of the requested CIDs are present in this repo",
|
||||
));
|
||||
}
|
||||
// `getBlocks` doesn't really have a meaningful root for the CAR header
|
||||
// when the caller is fetching arbitrary blocks (e.g. MST nodes). Per the
|
||||
// CAR v1 spec, the roots array must contain at least one CID. We use the
|
||||
// head commit CID if the user requested it, otherwise the first block
|
||||
// we found.
|
||||
let root = {
|
||||
let head = load_head_commit(&state, &did).await?.map(|(c, _)| c);
|
||||
head.unwrap_or_else(|| parsed[0])
|
||||
};
|
||||
let car = writer.finish(&[root]);
|
||||
Ok(car_response(car))
|
||||
}
|
||||
|
||||
// -- getLatestCommit -------------------------------------------------------
|
||||
|
||||
pub async fn get_latest_commit(
|
||||
State(state): State<AppState>,
|
||||
Query(q): Query<GetLatestCommitQuery>,
|
||||
) -> Result<Json<GetLatestCommitResp>, (StatusCode, Json<crate::routes::types::ErrorBody>)> {
|
||||
let (head_cid, _head_commit) = match load_head_commit(&state, &q.did).await? {
|
||||
Some(t) => t,
|
||||
None => {
|
||||
return Err(err(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"RepoNotFound",
|
||||
format!("no commits for did `{}`", q.did),
|
||||
));
|
||||
}
|
||||
};
|
||||
let rev: String = sqlx::query_scalar("SELECT rev FROM repos WHERE did = $1")
|
||||
.bind(&q.did)
|
||||
.fetch_one(&state.db)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
err(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"InternalServerError",
|
||||
format!("repos rev read: {e}"),
|
||||
)
|
||||
})?;
|
||||
Ok(Json(GetLatestCommitResp {
|
||||
cid: head_cid.to_string(),
|
||||
rev,
|
||||
}))
|
||||
}
|
||||
|
||||
// -- getRecord -------------------------------------------------------------
|
||||
|
||||
pub async fn get_record(
|
||||
State(state): State<AppState>,
|
||||
Query(q): Query<GetRecordQuery>,
|
||||
) -> Result<Response, (StatusCode, Json<crate::routes::types::ErrorBody>)> {
|
||||
if q.collection.is_empty() || q.rkey.is_empty() {
|
||||
return Err(err(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"InvalidRequest",
|
||||
"`collection` and `rkey` are required",
|
||||
));
|
||||
}
|
||||
|
||||
let (head_cid, head_commit_bytes) = match load_head_commit(&state, &q.did).await? {
|
||||
Some(t) => t,
|
||||
None => {
|
||||
return Err(err(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"RepoNotFound",
|
||||
format!("no commits for did `{}`", q.did),
|
||||
));
|
||||
}
|
||||
};
|
||||
let signing_key_bytes: Vec<u8> = sqlx::query_scalar(
|
||||
"SELECT signing_key FROM users WHERE did = $1",
|
||||
)
|
||||
.bind(&q.did)
|
||||
.fetch_one(&state.db)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
err(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"InternalServerError",
|
||||
format!("users.signing_key read: {e}"),
|
||||
)
|
||||
})?;
|
||||
let signing_key = crate::routes::helpers::load_signing_key(&signing_key_bytes)?;
|
||||
|
||||
let blockstore = load_user_blockstore(&state, &q.did).await?;
|
||||
blockstore
|
||||
.put(&head_cid, Bytes::from(head_commit_bytes.clone()))
|
||||
.await
|
||||
.map_err(|e| {
|
||||
err(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"InternalServerError",
|
||||
format!("blockstore put head commit: {e:#}"),
|
||||
)
|
||||
})?;
|
||||
let repo: Repo<_> =
|
||||
Repo::load(q.did.clone(), signing_key, blockstore.clone(), head_cid)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
err(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"InternalServerError",
|
||||
format!("repo load: {e:#}"),
|
||||
)
|
||||
})?;
|
||||
|
||||
let raw_key = format!("{}/{}", q.collection, q.rkey);
|
||||
let value_cid = match repo.get_record(&q.collection, &q.rkey).await {
|
||||
Ok(Some(c)) => c,
|
||||
Ok(None) => {
|
||||
return Err(err(
|
||||
StatusCode::NOT_FOUND,
|
||||
"RecordNotFound",
|
||||
format!(
|
||||
"no record at {}/{}/{}",
|
||||
q.did, q.collection, q.rkey
|
||||
),
|
||||
));
|
||||
}
|
||||
Err(e) => {
|
||||
return Err(err(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"InternalServerError",
|
||||
format!("repo.get_record: {e:#}"),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
let proof = build_mst_proof(&repo.mst, std::iter::once(raw_key.as_str())).map_err(|e| {
|
||||
err(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"InternalServerError",
|
||||
format!("mst proof: {e:#}"),
|
||||
)
|
||||
})?;
|
||||
|
||||
let value_bytes = match blockstore.get(&value_cid).await.map_err(|e| {
|
||||
err(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"InternalServerError",
|
||||
format!("blockstore get value: {e:#}"),
|
||||
)
|
||||
})? {
|
||||
Some(b) => b,
|
||||
None => {
|
||||
return Err(err(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"InternalServerError",
|
||||
format!("value block missing for {value_cid}"),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
let mut writer = CarWriter::new();
|
||||
writer.append(head_cid, &head_commit_bytes);
|
||||
writer.append(value_cid, &value_bytes);
|
||||
if let Some(root_cid) = repo.mst.root_cid() {
|
||||
if let Some(root_bytes) = repo.mst.blocks().get(&root_cid).cloned() {
|
||||
writer.append(root_cid, &root_bytes);
|
||||
}
|
||||
}
|
||||
for (cid, bytes) in &proof.blocks {
|
||||
writer.append(*cid, bytes);
|
||||
}
|
||||
let car = writer.finish(&[head_cid]);
|
||||
Ok(car_response(car))
|
||||
}
|
||||
|
||||
fn build_mst_proof<'a, I, S>(mst: &Mst, keys: I) -> anyhow::Result<at_mst::tree::Proof>
|
||||
where
|
||||
I: IntoIterator<Item = S>,
|
||||
S: AsRef<str>,
|
||||
{
|
||||
mst.proof(keys)
|
||||
}
|
||||
|
||||
// -- listRepos -------------------------------------------------------------
|
||||
|
||||
pub async fn list_repos(
|
||||
State(state): State<AppState>,
|
||||
Query(q): Query<ListReposQuery>,
|
||||
) -> Result<Json<ListReposResp>, (StatusCode, Json<crate::routes::types::ErrorBody>)> {
|
||||
let requested = q.limit.unwrap_or(500);
|
||||
let limit = if requested < 1 {
|
||||
1
|
||||
} else if requested > MAX_LIST_LIMIT {
|
||||
MAX_LIST_LIMIT
|
||||
} else {
|
||||
requested
|
||||
};
|
||||
let cursor = q.cursor.unwrap_or_default();
|
||||
|
||||
let rows: Vec<(String, Vec<u8>, String)> = sqlx::query_as(
|
||||
r#"SELECT r.did, r.head_cid, r.rev
|
||||
FROM repos r
|
||||
WHERE r.did > $1
|
||||
AND octet_length(r.head_cid) > 0
|
||||
AND NOT (r.head_cid = decode(repeat(E'\\000', octet_length(r.head_cid)), 'escape'))
|
||||
ORDER BY r.did ASC
|
||||
LIMIT $2"#,
|
||||
)
|
||||
.bind(&cursor)
|
||||
.bind(limit)
|
||||
.fetch_all(&state.db)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
err(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"InternalServerError",
|
||||
format!("list_repos query: {e}"),
|
||||
)
|
||||
})?;
|
||||
|
||||
let mut repos = Vec::with_capacity(rows.len());
|
||||
let mut last_did: Option<String> = None;
|
||||
for (did, head_cid_blob, rev) in rows {
|
||||
let head_cid = at_crypto::cid::cid_from_multihash_bytes(&head_cid_blob)
|
||||
.map_err(|e| {
|
||||
err(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"InternalServerError",
|
||||
format!("invalid head_cid for {did}: {e}"),
|
||||
)
|
||||
})?;
|
||||
repos.push(ListReposRepo {
|
||||
did: did.clone(),
|
||||
head: head_cid.to_string(),
|
||||
rev,
|
||||
active: true,
|
||||
});
|
||||
last_did = Some(did);
|
||||
}
|
||||
|
||||
let next_cursor = if (repos.len() as i64) == limit {
|
||||
last_did
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Ok(Json(ListReposResp {
|
||||
repos,
|
||||
cursor: next_cursor,
|
||||
}))
|
||||
}
|
||||
|
||||
// -- extra JSON helpers (useful for tests / future endpoints) ---------------
|
||||
|
||||
/// Sanity check that the JSON shape we emit for `getLatestCommit` matches the
|
||||
/// spec (`{cid, rev}`). The test below is `#[test]` so it shows up in
|
||||
/// `cargo test` and will fail loudly if a future refactor renames a field.
|
||||
#[cfg(test)]
|
||||
mod shape_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn get_latest_commit_resp_shape() {
|
||||
let r = GetLatestCommitResp {
|
||||
cid: "bafyxxx".into(),
|
||||
rev: "0".into(),
|
||||
};
|
||||
let v = serde_json::to_value(&r).unwrap();
|
||||
assert_eq!(v, json!({"cid": "bafyxxx", "rev": "0"}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_repos_repo_shape() {
|
||||
let r = ListReposRepo {
|
||||
did: "did:plc:abc".into(),
|
||||
head: "bafyxxx".into(),
|
||||
rev: "0".into(),
|
||||
active: true,
|
||||
};
|
||||
let v = serde_json::to_value(&r).unwrap();
|
||||
assert_eq!(
|
||||
v,
|
||||
json!({
|
||||
"did": "did:plc:abc",
|
||||
"head": "bafyxxx",
|
||||
"rev": "0",
|
||||
"active": true,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateAccountReq {
|
||||
pub handle: String,
|
||||
pub email: Option<String>,
|
||||
pub password: Option<String>,
|
||||
pub did: Option<String>,
|
||||
pub invite_code: Option<String>,
|
||||
pub recovery_key: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct CreateAccountResp {
|
||||
pub did: String,
|
||||
pub handle: String,
|
||||
pub access_jwt: String,
|
||||
pub refresh_jwt: String,
|
||||
pub did_doc: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateSessionReq {
|
||||
pub identifier: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct CreateSessionResp {
|
||||
pub did: String,
|
||||
pub handle: String,
|
||||
pub access_jwt: String,
|
||||
pub refresh_jwt: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct RefreshSessionReq {
|
||||
pub refresh_jwt: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct RefreshSessionResp {
|
||||
pub access_jwt: String,
|
||||
pub refresh_jwt: String,
|
||||
pub handle: String,
|
||||
pub did: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct DescribeServerResp {
|
||||
pub did: String,
|
||||
pub available_user_domains: Vec<String>,
|
||||
pub invite_code_required: bool,
|
||||
pub links: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ResolveHandleReq {
|
||||
pub handle: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ResolveHandleResp {
|
||||
pub did: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateRecordReq {
|
||||
pub repo: String,
|
||||
pub collection: String,
|
||||
pub rkey: Option<String>,
|
||||
pub record: serde_json::Value,
|
||||
pub validate: Option<bool>,
|
||||
pub swap_commit: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct CreateRecordResp {
|
||||
pub uri: String,
|
||||
pub cid: String,
|
||||
pub commit: Option<serde_json::Value>,
|
||||
pub validation_status: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct ErrorBody {
|
||||
pub error: String,
|
||||
pub message: Option<String>,
|
||||
}
|
||||
|
||||
impl ErrorBody {
|
||||
pub fn new(name: impl Into<String>, message: Option<String>) -> Self {
|
||||
Self {
|
||||
error: name.into(),
|
||||
message,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
use crate::appview_push::AppViewPushClient;
|
||||
use at_blob::S3BlobStore;
|
||||
use at_identity::plc::PlcClient;
|
||||
use at_lexicon::{Lex, LexRegistry};
|
||||
use at_repo::blockstore::MemoryBlockstore;
|
||||
use at_shared::config::AppConfig;
|
||||
use sqlx::PgPool;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub cfg: AppConfig,
|
||||
pub db: PgPool,
|
||||
pub blob: S3BlobStore,
|
||||
pub lex: Arc<LexRegistry>,
|
||||
pub blockstore: Arc<MemoryBlockstore>,
|
||||
pub plc: PlcClient,
|
||||
pub appview: AppViewPushClient,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
pub async fn new(cfg: AppConfig, db: PgPool, blob: S3BlobStore) -> Self {
|
||||
let mut lex = LexRegistry::new();
|
||||
lex.lexicons.insert(
|
||||
"app.twi.post".to_string(),
|
||||
Lex::from_json(include_str!("../../../lexicons/app/twi/post.json")).unwrap(),
|
||||
);
|
||||
let plc_url = cfg.plc_directory_url.clone();
|
||||
// The PDS speaks to the AppView via the cluster-internal URL —
|
||||
// never the public one, because the ingest endpoint is unauth'd
|
||||
// in dev mode (and uses a shared secret in prod). The base URL
|
||||
// is the same as `appview_public_url` in our single-host dev
|
||||
// setup, but operators can override with `APPVIEW_INTERNAL_URL`.
|
||||
let appview_url = std::env::var("APPVIEW_INTERNAL_URL")
|
||||
.unwrap_or_else(|_| cfg.appview_public_url.clone());
|
||||
let appview = AppViewPushClient::new(appview_url, cfg.appview_ingest_secret.clone());
|
||||
Self {
|
||||
cfg,
|
||||
db,
|
||||
blob,
|
||||
lex: Arc::new(lex),
|
||||
blockstore: Arc::new(MemoryBlockstore::new()),
|
||||
plc: PlcClient::new(plc_url),
|
||||
appview,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,404 @@
|
||||
use serde_json::{json, Value};
|
||||
use std::time::Duration;
|
||||
|
||||
const PDS_URL: &str = "http://127.0.0.1:2583";
|
||||
|
||||
async fn client() -> reqwest::Client {
|
||||
reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(5))
|
||||
.build()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
async fn wait_for_pds() -> bool {
|
||||
let c = client().await;
|
||||
for _ in 0..20 {
|
||||
if let Ok(r) = c.get(format!("{}/healthz", PDS_URL)).send().await {
|
||||
if r.status().is_success() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(250)).await;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
async fn db_pool() -> Option<sqlx::PgPool> {
|
||||
let url = std::env::var("DATABASE_URL_PDS")
|
||||
.unwrap_or_else(|_| "postgres://pds:pds@127.0.0.1:5434/pds".to_string());
|
||||
sqlx::postgres::PgPoolOptions::new()
|
||||
.max_connections(2)
|
||||
.acquire_timeout(Duration::from_secs(2))
|
||||
.connect(&url)
|
||||
.await
|
||||
.ok()
|
||||
}
|
||||
|
||||
async fn fresh_user(prefix: &str) -> (reqwest::Client, String, String) {
|
||||
let c = client().await;
|
||||
let handle = format!(
|
||||
"{}_{}.maarcadetweet.local",
|
||||
prefix,
|
||||
uuid::Uuid::new_v4().simple()
|
||||
);
|
||||
let acc: Value = c
|
||||
.post(format!("{}/xrpc/com.atproto.server.createAccount", PDS_URL))
|
||||
.json(&json!({"handle": handle, "password": "hunter2hunter2"}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json()
|
||||
.await
|
||||
.unwrap();
|
||||
let did = acc["did"].as_str().unwrap().to_string();
|
||||
let jwt = acc["access_jwt"].as_str().unwrap().to_string();
|
||||
(c, did, jwt)
|
||||
}
|
||||
|
||||
/// Seed a single post so the repo has a head-commit / repo_blocks
|
||||
/// row. The exact content doesn't matter — we just need *any* block
|
||||
/// under the user's DID so that `repo_blocks` is non-empty and the
|
||||
/// `getBlob` handler's "user has a repo" gate passes.
|
||||
async fn seed_any_record(c: &reqwest::Client, did: &str, jwt: &str) {
|
||||
let r: Value = c
|
||||
.post(format!("{}/xrpc/com.atproto.repo.createRecord", PDS_URL))
|
||||
.bearer_auth(jwt)
|
||||
.json(&json!({
|
||||
"repo": did,
|
||||
"collection": "app.twi.post",
|
||||
"record": {
|
||||
"text": "blob seed",
|
||||
"createdAt": "2026-07-05T12:00:00Z",
|
||||
},
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json()
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(r["uri"].is_string(), "seed createRecord: {:?}", r);
|
||||
}
|
||||
|
||||
/// Compute a CIDv1 + sha256 CID for the given blob bytes — the same
|
||||
/// shape PDS clients use to reference uploaded blobs.
|
||||
fn blob_cid(bytes: &[u8]) -> cid::Cid {
|
||||
at_crypto::cid::cid_for_raw(0x55, at_crypto::cid::sha256(bytes)).unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_blob_returns_value_block() {
|
||||
if !wait_for_pds().await {
|
||||
eprintln!("pds not running, skipping");
|
||||
return;
|
||||
}
|
||||
let Some(pool) = db_pool().await else {
|
||||
eprintln!("no PDS database reachable, skipping");
|
||||
return;
|
||||
};
|
||||
let (c, did, jwt) = fresh_user("blob").await;
|
||||
seed_any_record(&c, &did, &jwt).await;
|
||||
|
||||
let payload: Vec<u8> = b"hello, blob! \xe2\x98\x83 \xf0\x9f\x9a\x80".to_vec();
|
||||
let cid = blob_cid(&payload);
|
||||
|
||||
let cid_bytes: Vec<u8> = cid.to_bytes();
|
||||
sqlx::query(
|
||||
r#"INSERT INTO repo_blocks (did, cid, block, size)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (did, cid) DO NOTHING"#,
|
||||
)
|
||||
.bind(&did)
|
||||
.bind(cid_bytes.as_slice())
|
||||
.bind(payload.as_slice())
|
||||
.bind(payload.len() as i32)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let url = format!(
|
||||
"{}/xrpc/com.atproto.sync.getBlob?did={}&cid={}",
|
||||
PDS_URL, did, cid
|
||||
);
|
||||
let resp = c.get(&url).send().await.unwrap();
|
||||
assert_eq!(resp.status().as_u16(), 200, "expected 200 for {url}");
|
||||
let bytes = resp.bytes().await.unwrap();
|
||||
assert_eq!(bytes.as_ref(), payload.as_slice());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_blob_returns_404_for_unknown_cid() {
|
||||
if !wait_for_pds().await {
|
||||
eprintln!("pds not running, skipping");
|
||||
return;
|
||||
}
|
||||
let Some(_pool) = db_pool().await else {
|
||||
eprintln!("no PDS database reachable, skipping");
|
||||
return;
|
||||
};
|
||||
let (c, did, jwt) = fresh_user("blobmiss").await;
|
||||
seed_any_record(&c, &did, &jwt).await;
|
||||
|
||||
let cid = blob_cid(b"definitely-not-uploaded");
|
||||
|
||||
let resp = c
|
||||
.get(format!(
|
||||
"{}/xrpc/com.atproto.sync.getBlob?did={}&cid={}",
|
||||
PDS_URL, did, cid
|
||||
))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status().as_u16(), 400);
|
||||
let body: Value = resp.json().await.unwrap();
|
||||
assert_eq!(body["error"], json!("BlobNotFound"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_blob_rejects_invalid_cid() {
|
||||
if !wait_for_pds().await {
|
||||
eprintln!("pds not running, skipping");
|
||||
return;
|
||||
}
|
||||
let (c, did, _jwt) = fresh_user("blobcid").await;
|
||||
let resp = c
|
||||
.get(format!(
|
||||
"{}/xrpc/com.atproto.sync.getBlob?did={}&cid=not-a-cid",
|
||||
PDS_URL, did
|
||||
))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status().as_u16(), 400);
|
||||
let body: Value = resp.json().await.unwrap();
|
||||
assert_eq!(body["error"], json!("InvalidRequest"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_blob_shortcut_returns_value_block() {
|
||||
if !wait_for_pds().await {
|
||||
eprintln!("pds not running, skipping");
|
||||
return;
|
||||
}
|
||||
let Some(pool) = db_pool().await else {
|
||||
eprintln!("no PDS database reachable, skipping");
|
||||
return;
|
||||
};
|
||||
let (c, did, jwt) = fresh_user("blobshort").await;
|
||||
seed_any_record(&c, &did, &jwt).await;
|
||||
|
||||
// Use a binary payload that won't match any known signature or
|
||||
// pass the ASCII-text heuristic, so the shortcut endpoint falls
|
||||
// back to `application/octet-stream`. (Phase 7: previously this
|
||||
// test used a plain-text payload; with magic-byte sniffing that
|
||||
// would be classified as `text/plain; charset=utf-8` instead of
|
||||
// the octet-stream default.)
|
||||
let payload: Vec<u8> = vec![0x00, 0x01, 0x02, 0xff, 0xfe, 0x80, 0x90];
|
||||
let cid = blob_cid(&payload);
|
||||
let cid_bytes: Vec<u8> = cid.to_bytes();
|
||||
|
||||
sqlx::query(
|
||||
r#"INSERT INTO repo_blocks (did, cid, block, size)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (did, cid) DO NOTHING"#,
|
||||
)
|
||||
.bind(&did)
|
||||
.bind(cid_bytes.as_slice())
|
||||
.bind(payload.as_slice())
|
||||
.bind(payload.len() as i32)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let resp = c
|
||||
.get(format!("{}/blob/{}", PDS_URL, cid))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status().as_u16(), 200);
|
||||
let ct = resp
|
||||
.headers()
|
||||
.get("content-type")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let bytes = resp.bytes().await.unwrap();
|
||||
assert_eq!(bytes.as_ref(), payload.as_slice());
|
||||
assert_eq!(ct, "application/octet-stream");
|
||||
}
|
||||
|
||||
// -- Phase 7: uploadBlob tests ---------------------------------------------
|
||||
|
||||
/// A minimal PNG signature followed by enough bytes that the magic
|
||||
/// detector recognises it. We don't need a fully-valid PNG for the
|
||||
/// uploadBlob tests — we just need the first 8 bytes to match the
|
||||
/// signature and the response to carry the correct `mimeType`.
|
||||
fn png_bytes() -> Vec<u8> {
|
||||
let mut v = vec![0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a];
|
||||
v.extend_from_slice(&[0u8; 64]);
|
||||
v
|
||||
}
|
||||
|
||||
/// `com.atproto.uploadBlob` — happy path. POST a small binary blob
|
||||
/// with a `Content-Type` header, then read it back via
|
||||
/// `com.atproto.sync.getBlob` and verify the bytes match and the
|
||||
/// server-side CID matches what `sha256 + CIDv1-raw` would compute
|
||||
/// locally.
|
||||
#[tokio::test]
|
||||
async fn upload_blob_persists_and_round_trips() {
|
||||
if !wait_for_pds().await {
|
||||
eprintln!("pds not running, skipping");
|
||||
return;
|
||||
}
|
||||
let (c, did, jwt) = fresh_user("upl").await;
|
||||
seed_any_record(&c, &did, &jwt).await;
|
||||
|
||||
let payload = png_bytes();
|
||||
|
||||
let resp = c
|
||||
.post(format!("{}/xrpc/com.atproto.uploadBlob", PDS_URL))
|
||||
.bearer_auth(&jwt)
|
||||
.header("Content-Type", "image/png")
|
||||
.body(payload.clone())
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
resp.status().as_u16(),
|
||||
200,
|
||||
"uploadBlob should succeed"
|
||||
);
|
||||
let body: Value = resp.json().await.unwrap();
|
||||
let returned_cid = body["blob"]["ref"]["$link"]
|
||||
.as_str()
|
||||
.expect("blob.ref.$link should be a string")
|
||||
.to_string();
|
||||
let returned_size = body["blob"]["size"].as_u64().unwrap();
|
||||
let returned_mime = body["blob"]["mimeType"].as_str().unwrap();
|
||||
assert_eq!(returned_mime, "image/png");
|
||||
assert_eq!(returned_size, payload.len() as u64);
|
||||
|
||||
// The returned CID must match what we compute locally from the
|
||||
// payload bytes (CIDv1-raw + SHA-256).
|
||||
let expected_cid = blob_cid(&payload).to_string();
|
||||
assert_eq!(returned_cid, expected_cid);
|
||||
|
||||
// Read the blob back via the spec endpoint and confirm bytes
|
||||
// match.
|
||||
let get_resp = c
|
||||
.get(format!(
|
||||
"{}/xrpc/com.atproto.sync.getBlob?did={}&cid={}",
|
||||
PDS_URL, did, returned_cid
|
||||
))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(get_resp.status().as_u16(), 200);
|
||||
let bytes = get_resp.bytes().await.unwrap();
|
||||
assert_eq!(bytes.as_ref(), payload.as_slice());
|
||||
}
|
||||
|
||||
/// `com.atproto.uploadBlob` rejects payloads larger than the 1 MiB
|
||||
/// limit with `413 Payload Too Large`.
|
||||
#[tokio::test]
|
||||
async fn upload_blob_rejects_oversized() {
|
||||
if !wait_for_pds().await {
|
||||
eprintln!("pds not running, skipping");
|
||||
return;
|
||||
}
|
||||
let (c, did, jwt) = fresh_user("upbig").await;
|
||||
seed_any_record(&c, &did, &jwt).await;
|
||||
|
||||
// 2 MiB payload. The PDS's `DefaultBodyLimit::max(1 MiB)` layer
|
||||
// rejects the request before our handler sees it, so we expect
|
||||
// 413 from axum's body extractor.
|
||||
let payload = vec![0u8; 2 * 1024 * 1024];
|
||||
|
||||
let resp = c
|
||||
.post(format!("{}/xrpc/com.atproto.uploadBlob", PDS_URL))
|
||||
.bearer_auth(&jwt)
|
||||
.header("Content-Type", "image/png")
|
||||
.body(payload)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
resp.status().as_u16(),
|
||||
413,
|
||||
"oversized upload must return 413"
|
||||
);
|
||||
}
|
||||
|
||||
/// `com.atproto.uploadBlob` rejects requests with no `Authorization`
|
||||
/// header. Returns `401 Unauthenticated`.
|
||||
#[tokio::test]
|
||||
async fn upload_blob_unauthenticated_rejected() {
|
||||
if !wait_for_pds().await {
|
||||
eprintln!("pds not running, skipping");
|
||||
return;
|
||||
}
|
||||
let payload = png_bytes();
|
||||
let resp = client()
|
||||
.await
|
||||
.post(format!("{}/xrpc/com.atproto.uploadBlob", PDS_URL))
|
||||
.header("Content-Type", "image/png")
|
||||
.body(payload)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
resp.status().as_u16(),
|
||||
401,
|
||||
"no bearer token must return 401"
|
||||
);
|
||||
}
|
||||
|
||||
/// `com.atproto.sync.getBlob` returns the resolved `Content-Type` for
|
||||
/// a previously-uploaded blob. We POST a PNG, GET it back, and
|
||||
/// verify the response advertises `image/png` rather than the old
|
||||
/// `application/octet-stream` default.
|
||||
#[tokio::test]
|
||||
async fn get_blob_returns_detected_mime_type() {
|
||||
if !wait_for_pds().await {
|
||||
eprintln!("pds not running, skipping");
|
||||
return;
|
||||
}
|
||||
let (c, did, jwt) = fresh_user("upmime").await;
|
||||
seed_any_record(&c, &did, &jwt).await;
|
||||
|
||||
let payload = png_bytes();
|
||||
let up: Value = c
|
||||
.post(format!("{}/xrpc/com.atproto.uploadBlob", PDS_URL))
|
||||
.bearer_auth(&jwt)
|
||||
.header("Content-Type", "image/png")
|
||||
.body(payload.clone())
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json()
|
||||
.await
|
||||
.unwrap();
|
||||
let cid = up["blob"]["ref"]["$link"].as_str().unwrap().to_string();
|
||||
|
||||
let resp = c
|
||||
.get(format!(
|
||||
"{}/xrpc/com.atproto.sync.getBlob?did={}&cid={}",
|
||||
PDS_URL, did, cid
|
||||
))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status().as_u16(), 200);
|
||||
let ct = resp
|
||||
.headers()
|
||||
.get("content-type")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let bytes = resp.bytes().await.unwrap();
|
||||
assert_eq!(bytes.as_ref(), payload.as_slice());
|
||||
assert_eq!(
|
||||
ct, "image/png",
|
||||
"getBlob should serve the stored mime type, not the octet-stream default"
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user