···11+use serde_json::{Value, json};
22+33+/// Walk `media[]` and add a `url` field to each blob so the frontend can
44+/// display images directly.
55+pub(crate) fn enrich_media_blobs(record: &mut Value, pds: &str, did: &str) {
66+ let media = match record.get_mut("media").and_then(|m| m.as_array_mut()) {
77+ Some(arr) => arr,
88+ None => return,
99+ };
1010+1111+ let pds_base = pds.trim_end_matches('/');
1212+1313+ for item in media.iter_mut() {
1414+ let cid = item
1515+ .get("blob")
1616+ .and_then(|b| b.get("ref"))
1717+ .and_then(|r| r.get("$link"))
1818+ .and_then(|l| l.as_str())
1919+ .map(|s| s.to_string());
2020+2121+ if let Some(cid) = cid
2222+ && let Some(blob) = item.get_mut("blob")
2323+ && let Some(obj) = blob.as_object_mut()
2424+ {
2525+ obj.insert(
2626+ "url".to_string(),
2727+ json!(format!(
2828+ "{pds_base}/xrpc/com.atproto.sync.getBlob?did={did}&cid={cid}"
2929+ )),
3030+ );
3131+ }
3232+ }
3333+}
3434+3535+#[cfg(test)]
3636+mod tests {
3737+ use super::*;
3838+3939+ #[test]
4040+ fn enrich_media_adds_url() {
4141+ let mut record = json!({
4242+ "media": [{
4343+ "blob": {
4444+ "ref": { "$link": "bafyreiabc" },
4545+ "mimeType": "image/jpeg",
4646+ "size": 1024
4747+ }
4848+ }]
4949+ });
5050+5151+ enrich_media_blobs(&mut record, "https://pds.example.com", "did:plc:test");
5252+5353+ let url = record["media"][0]["blob"]["url"].as_str().unwrap();
5454+ assert_eq!(
5555+ url,
5656+ "https://pds.example.com/xrpc/com.atproto.sync.getBlob?did=did:plc:test&cid=bafyreiabc"
5757+ );
5858+ }
5959+6060+ #[test]
6161+ fn enrich_media_noop_without_media() {
6262+ let mut record = json!({"title": "test"});
6363+ enrich_media_blobs(&mut record, "https://pds.example.com", "did:plc:test");
6464+ assert!(record.get("media").is_none());
6565+ }
6666+6767+ #[test]
6868+ fn enrich_media_skips_items_without_ref() {
6969+ let mut record = json!({
7070+ "media": [{
7171+ "blob": { "mimeType": "image/png" }
7272+ }]
7373+ });
7474+7575+ enrich_media_blobs(&mut record, "https://pds.example.com", "did:plc:test");
7676+ assert!(record["media"][0]["blob"].get("url").is_none());
7777+ }
7878+7979+ #[test]
8080+ fn enrich_media_handles_multiple_items() {
8181+ let mut record = json!({
8282+ "media": [
8383+ { "blob": { "ref": { "$link": "cid1" } } },
8484+ { "blob": { "ref": { "$link": "cid2" } } }
8585+ ]
8686+ });
8787+8888+ enrich_media_blobs(&mut record, "https://pds.example.com/", "did:plc:x");
8989+9090+ let url1 = record["media"][0]["blob"]["url"].as_str().unwrap();
9191+ let url2 = record["media"][1]["blob"]["url"].as_str().unwrap();
9292+ assert!(url1.contains("cid1"));
9393+ assert!(url2.contains("cid2"));
9494+ }
9595+9696+ #[test]
9797+ fn enrich_media_trims_trailing_slash() {
9898+ let mut record = json!({
9999+ "media": [{
100100+ "blob": { "ref": { "$link": "bafytest" } }
101101+ }]
102102+ });
103103+104104+ enrich_media_blobs(&mut record, "https://pds.example.com/", "did:plc:test");
105105+106106+ let url = record["media"][0]["blob"]["url"].as_str().unwrap();
107107+ assert!(url.starts_with("https://pds.example.com/xrpc/"));
108108+ assert!(!url.contains("//xrpc"));
109109+ }
110110+}
+12
src/repo/mod.rs
···11+mod at_uri;
22+mod dpop;
33+mod media;
44+mod pds;
55+pub(crate) mod session;
66+mod upload_blob;
77+88+pub use upload_blob::upload_blob;
99+pub(crate) use at_uri::parse_did_from_at_uri;
1010+pub(crate) use media::enrich_media_blobs;
1111+pub(crate) use pds::{forward_pds_response, pds_post_json_raw};
1212+pub(crate) use session::{AtpSession, get_atp_session};
+152
src/repo/pds.rs
···11+use axum::body::Bytes;
22+use axum::http::StatusCode;
33+use axum::response::{IntoResponse, Response};
44+use serde_json::Value;
55+66+use crate::AppState;
77+use crate::error::AppError;
88+99+use super::dpop::generate_dpop_proof;
1010+use super::session::AtpSession;
1111+1212+/// Forward a PDS response back to the client, preserving status and body.
1313+pub(crate) async fn forward_pds_response(resp: reqwest::Response) -> Result<Response, AppError> {
1414+ let status = resp.status();
1515+ let body = resp
1616+ .bytes()
1717+ .await
1818+ .map_err(|e| AppError::Internal(format!("failed to read PDS response: {e}")))?;
1919+2020+ let axum_status = StatusCode::from_u16(status.as_u16()).unwrap();
2121+2222+ if status.is_success() {
2323+ Ok((
2424+ axum_status,
2525+ [(axum::http::header::CONTENT_TYPE, "application/json")],
2626+ body,
2727+ )
2828+ .into_response())
2929+ } else {
3030+ let body_str = String::from_utf8_lossy(&body);
3131+ tracing::warn!(status = %axum_status, body = %body_str, "PDS returned error");
3232+ Err(AppError::PdsError(axum_status, body))
3333+ }
3434+}
3535+3636+/// POST JSON to a PDS XRPC endpoint with DPoP auth and nonce retry.
3737+/// Returns the raw reqwest::Response so callers can inspect the body.
3838+pub(crate) async fn pds_post_json_raw(
3939+ state: &AppState,
4040+ session: &AtpSession,
4141+ xrpc_method: &str,
4242+ body: &Value,
4343+) -> Result<reqwest::Response, AppError> {
4444+ let url = format!(
4545+ "{}/xrpc/{xrpc_method}",
4646+ session.pds_endpoint.trim_end_matches('/')
4747+ );
4848+4949+ let dpop = generate_dpop_proof("POST", &url, &session.dpop_jwk, &session.access_token, None)?;
5050+5151+ let resp = state
5252+ .http
5353+ .post(&url)
5454+ .header("authorization", format!("DPoP {}", session.access_token))
5555+ .header("dpop", &dpop)
5656+ .json(body)
5757+ .send()
5858+ .await
5959+ .map_err(|e| AppError::Internal(format!("PDS request failed: {e}")))?;
6060+6161+ // Retry with nonce if PDS requires it
6262+ if resp.status() == reqwest::StatusCode::UNAUTHORIZED
6363+ && let Some(nonce) = resp
6464+ .headers()
6565+ .get("dpop-nonce")
6666+ .and_then(|v| v.to_str().ok())
6767+ {
6868+ let nonce = nonce.to_string();
6969+ tracing::debug!("retrying with DPoP nonce");
7070+7171+ let dpop = generate_dpop_proof(
7272+ "POST",
7373+ &url,
7474+ &session.dpop_jwk,
7575+ &session.access_token,
7676+ Some(&nonce),
7777+ )?;
7878+7979+ let resp = state
8080+ .http
8181+ .post(&url)
8282+ .header("authorization", format!("DPoP {}", session.access_token))
8383+ .header("dpop", &dpop)
8484+ .json(body)
8585+ .send()
8686+ .await
8787+ .map_err(|e| AppError::Internal(format!("PDS request retry failed: {e}")))?;
8888+8989+ return Ok(resp);
9090+ }
9191+9292+ Ok(resp)
9393+}
9494+9595+/// POST a binary blob to the PDS with DPoP auth and nonce retry.
9696+pub(super) async fn pds_post_blob(
9797+ state: &AppState,
9898+ session: &AtpSession,
9999+ content_type: &str,
100100+ blob: Bytes,
101101+) -> Result<Response, AppError> {
102102+ let url = format!(
103103+ "{}/xrpc/com.atproto.repo.uploadBlob",
104104+ session.pds_endpoint.trim_end_matches('/')
105105+ );
106106+107107+ let dpop = generate_dpop_proof("POST", &url, &session.dpop_jwk, &session.access_token, None)?;
108108+109109+ let resp = state
110110+ .http
111111+ .post(&url)
112112+ .header("authorization", format!("DPoP {}", session.access_token))
113113+ .header("dpop", &dpop)
114114+ .header("content-type", content_type)
115115+ .body(blob.clone())
116116+ .send()
117117+ .await
118118+ .map_err(|e| AppError::Internal(format!("PDS uploadBlob failed: {e}")))?;
119119+120120+ if resp.status() == reqwest::StatusCode::UNAUTHORIZED
121121+ && let Some(nonce) = resp
122122+ .headers()
123123+ .get("dpop-nonce")
124124+ .and_then(|v| v.to_str().ok())
125125+ {
126126+ let nonce = nonce.to_string();
127127+ tracing::debug!("retrying uploadBlob with DPoP nonce");
128128+129129+ let dpop = generate_dpop_proof(
130130+ "POST",
131131+ &url,
132132+ &session.dpop_jwk,
133133+ &session.access_token,
134134+ Some(&nonce),
135135+ )?;
136136+137137+ let resp = state
138138+ .http
139139+ .post(&url)
140140+ .header("authorization", format!("DPoP {}", session.access_token))
141141+ .header("dpop", &dpop)
142142+ .header("content-type", content_type)
143143+ .body(blob)
144144+ .send()
145145+ .await
146146+ .map_err(|e| AppError::Internal(format!("PDS uploadBlob retry failed: {e}")))?;
147147+148148+ return forward_pds_response(resp).await;
149149+ }
150150+151151+ forward_pds_response(resp).await
152152+}