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:
tomdebone
2026-07-05 20:01:31 +02:00
commit c586fd39c9
134 changed files with 35279 additions and 0 deletions
+297
View File
@@ -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()),
)),
)
}
+692
View File
@@ -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);
}
}
+471
View File
@@ -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());
}
}
+456
View File
@@ -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(())
}
+61
View File
@@ -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()),
)),
)
}
+8
View File
@@ -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;
+159
View File
@@ -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
},
}))
}
+554
View File
@@ -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,
})
);
}
}
+98
View File
@@ -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,
}
}
}