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,273 @@
|
||||
//! `POST /internal/ingest-commit` — used by the PDS to push local commits
|
||||
//! into the AppView so the user's own actions show up without waiting for
|
||||
//! the Jetstream round-trip.
|
||||
//!
|
||||
//! Wire shape:
|
||||
//! ```json
|
||||
//! {
|
||||
//! "did": "did:plc:abc",
|
||||
//! "collection": "app.twi.post",
|
||||
//! "action": "create",
|
||||
//! "rkey": "3k2...",
|
||||
//! "cid": "bafy...", // optional
|
||||
//! "record": { ... }, // optional; required for follow delete
|
||||
//! "subject_did": "did:plc:..." // required for app.bsky.graph.follow
|
||||
//! }
|
||||
//! ```
|
||||
//!
|
||||
//! In production this endpoint would be protected with mTLS and a token
|
||||
//! minted by the PDS; for now it's open inside the cluster.
|
||||
|
||||
use crate::indexer;
|
||||
use crate::state::AppState;
|
||||
use axum::extract::State;
|
||||
use axum::http::{HeaderMap, StatusCode};
|
||||
use axum::Json;
|
||||
use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
use tracing::{info, warn};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct IngestCommitReq {
|
||||
pub did: String,
|
||||
pub collection: String,
|
||||
pub action: String,
|
||||
pub rkey: String,
|
||||
#[serde(default)]
|
||||
pub cid: Option<String>,
|
||||
#[serde(default)]
|
||||
pub record: Option<Value>,
|
||||
/// Required for `app.bsky.graph.follow` because the record value isn't
|
||||
/// always preserved on delete events.
|
||||
#[serde(default)]
|
||||
pub subject_did: Option<String>,
|
||||
}
|
||||
|
||||
/// Authenticate internal ingest requests.
|
||||
/// - If `APPVIEW_INGEST_SECRET` env var is unset: dev mode, accept anything.
|
||||
/// - If set: require `X-Ingest-Secret: <value>` header to match.
|
||||
pub fn check_ingest_secret(
|
||||
headers: &HeaderMap,
|
||||
configured: Option<&str>,
|
||||
) -> Result<(), (StatusCode, Json<Value>)> {
|
||||
let Some(expected) = configured else {
|
||||
return Ok(()); // dev mode
|
||||
};
|
||||
let provided = headers
|
||||
.get("x-ingest-secret")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("");
|
||||
if constant_time_eq(provided.as_bytes(), expected.as_bytes()) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err((
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(serde_json::json!({
|
||||
"error": "AuthenticationRequired",
|
||||
"message": "missing or invalid X-Ingest-Secret",
|
||||
})),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
|
||||
if a.len() != b.len() {
|
||||
return false;
|
||||
}
|
||||
let mut diff = 0u8;
|
||||
for (x, y) in a.iter().zip(b.iter()) {
|
||||
diff |= x ^ y;
|
||||
}
|
||||
diff == 0
|
||||
}
|
||||
|
||||
pub async fn ingest_commit(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Json(req): Json<IngestCommitReq>,
|
||||
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
||||
check_ingest_secret(&headers, state.cfg.appview_ingest_secret.as_deref())?;
|
||||
|
||||
let result = apply(&state, &req).await;
|
||||
if let Err((status, body)) = &result {
|
||||
warn!(
|
||||
status = status.as_u16(),
|
||||
body = %body.0,
|
||||
did = %req.did,
|
||||
collection = %req.collection,
|
||||
action = %req.action,
|
||||
"ingest commit failed"
|
||||
);
|
||||
} else {
|
||||
info!(did = %req.did, collection = %req.collection,
|
||||
action = %req.action, rkey = %req.rkey, "ingested commit");
|
||||
}
|
||||
result.map(|applied| {
|
||||
Json(serde_json::json!({
|
||||
"ok": true,
|
||||
"applied": applied,
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
async fn apply(
|
||||
state: &AppState,
|
||||
req: &IngestCommitReq,
|
||||
) -> Result<bool, (StatusCode, Json<Value>)> {
|
||||
match (req.collection.as_str(), req.action.as_str()) {
|
||||
("app.twi.post", "create") | ("app.bsky.feed.post", "create") => {
|
||||
let record = req
|
||||
.record
|
||||
.clone()
|
||||
.unwrap_or_else(|| serde_json::json!({"text": "", "createdAt": chrono::Utc::now().to_rfc3339()}));
|
||||
let cid = req.cid.clone().unwrap_or_default();
|
||||
let row = indexer::PostRow::from_record(
|
||||
&req.did,
|
||||
&req.rkey,
|
||||
&req.collection,
|
||||
&cid,
|
||||
&record,
|
||||
);
|
||||
indexer::upsert_post(&state.db, &row).await.map_err(db_err)?;
|
||||
Ok(true)
|
||||
}
|
||||
("app.twi.post", "delete") | ("app.bsky.feed.post", "delete") => {
|
||||
let uri = format!("at://{}/{}/{}", req.did, req.collection, req.rkey);
|
||||
indexer::delete_post(&state.db, &uri).await.map_err(db_err)?;
|
||||
Ok(true)
|
||||
}
|
||||
("app.bsky.feed.like", "create") => {
|
||||
indexer::upsert_like(
|
||||
&state.db,
|
||||
&req.did,
|
||||
&req.rkey,
|
||||
req.cid.as_deref(),
|
||||
req.record.as_ref(),
|
||||
)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
Ok(true)
|
||||
}
|
||||
("app.bsky.feed.like", "delete") => {
|
||||
indexer::delete_like(&state.db, &req.did, &req.rkey)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
Ok(true)
|
||||
}
|
||||
("app.bsky.feed.repost", "create") => {
|
||||
indexer::upsert_repost(
|
||||
&state.db,
|
||||
&req.did,
|
||||
&req.rkey,
|
||||
req.cid.as_deref(),
|
||||
req.record.as_ref(),
|
||||
)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
Ok(true)
|
||||
}
|
||||
("app.bsky.feed.repost", "delete") => {
|
||||
indexer::delete_repost(&state.db, &req.did, &req.rkey)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
Ok(true)
|
||||
}
|
||||
("app.bsky.graph.follow", "create") => {
|
||||
let subject = req
|
||||
.subject_did
|
||||
.clone()
|
||||
.or_else(|| {
|
||||
req.record
|
||||
.as_ref()
|
||||
.and_then(|r| r.get("subject"))
|
||||
.and_then(|s| s.as_str())
|
||||
.map(str::to_string)
|
||||
})
|
||||
.ok_or_else(|| bad_request("follow create requires subject_did or record.subject"))?;
|
||||
indexer::upsert_follow(
|
||||
&state.db,
|
||||
&req.did,
|
||||
&subject,
|
||||
req.record.as_ref(),
|
||||
)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
Ok(true)
|
||||
}
|
||||
("app.bsky.graph.follow", "delete") => {
|
||||
let subject = req
|
||||
.subject_did
|
||||
.clone()
|
||||
.or_else(|| {
|
||||
req.record
|
||||
.as_ref()
|
||||
.and_then(|r| r.get("subject"))
|
||||
.and_then(|s| s.as_str())
|
||||
.map(str::to_string)
|
||||
})
|
||||
.ok_or_else(|| bad_request("follow delete requires subject_did"))?;
|
||||
indexer::delete_follow(&state.db, &req.did, &subject)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
Ok(true)
|
||||
}
|
||||
(coll, action) => {
|
||||
// Unrecognised collection/action — return ok=false so the PDS
|
||||
// doesn't retry. Future collections should be added above.
|
||||
tracing::debug!(collection = %coll, action = %action, "ingest: unhandled");
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn db_err(e: impl std::fmt::Display) -> (StatusCode, Json<Value>) {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({
|
||||
"error": "InternalServerError",
|
||||
"message": e.to_string(),
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
fn bad_request(msg: &str) -> (StatusCode, Json<Value>) {
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({
|
||||
"error": "InvalidRequest",
|
||||
"message": msg,
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use axum::http::HeaderValue;
|
||||
|
||||
#[test]
|
||||
fn no_secret_configured_allows_anonymous() {
|
||||
let h = HeaderMap::new();
|
||||
assert!(check_ingest_secret(&h, None).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn secret_required_when_configured() {
|
||||
let h = HeaderMap::new();
|
||||
assert!(check_ingest_secret(&h, Some("hunter2")).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn secret_matches() {
|
||||
let mut h = HeaderMap::new();
|
||||
h.insert("x-ingest-secret", HeaderValue::from_static("hunter2"));
|
||||
assert!(check_ingest_secret(&h, Some("hunter2")).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn secret_mismatched() {
|
||||
let mut h = HeaderMap::new();
|
||||
h.insert("x-ingest-secret", HeaderValue::from_static("hunter3"));
|
||||
assert!(check_ingest_secret(&h, Some("hunter2")).is_err());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user