Adds the AppView-side half of the profile feature so non-local-PDS
authors also get their profile metadata indexed (the Jetstream
identity event stream only carries the handle, not display name /
bio / avatar). The PDS-push path was already wired by the previous
commit; this lands the Jetstream path.
Migration 0005:
* `profiles` table keyed by DID with display_name / description /
avatar_cid / banner_cid plus denormalised post_count /
follower_count / following_count. Backfilled from the posts
table on apply.
* `posts.avatar_cid` column — populated from the profiles cache
at `upsert_post` time so the PostCard can render an avatar
inline without a per-row PDS round trip.
Migration 0007 (clean-up): the original 0005 also created a
`LOWER(handle)` index that no query uses; this drops it
idempotently so dev DBs that already applied 0005 converge.
Indexer (`crates/appview/src/indexer.rs`):
* New `app.bsky.actor.profile` arm in `apply_commit` calls
`upsert_profile` on create, DELETEs the row on delete. Handle
is looked up from `posts` (the Jetstream commit envelope
doesn't carry it).
* `upsert_post` signature is now `&mut PostRow` so it can fill
`row.avatar_cid` from the profiles cache; the ON CONFLICT
clause uses `COALESCE(EXCLUDED, posts)` so re-indexing doesn't
overwrite an already-known avatar.
* `upsert_profile` writes display_name / description /
avatar_cid / banner_cid + the denormalised counts.
* `blob_link_of` helper accepts both `{ $type, ref.$link }`
and legacy flat `{ $link }` blob-ref shapes.
Ingest (`crates/appview/src/ingest.rs`):
* `app.bsky.actor.profile` create/delete arms in the PDS-push
path. The handle-fallback previously did `SELECT handle FROM
users WHERE did = $1` — but the AppView has no `users` table
(it's PDS-owned state). Replaced with a simple use-what-the-PDS-
sent approach; the handle_sync worker fills the column later.
Routes (`crates/appview/src/routes.rs`):
* `resolve_profile` reads the denormalised profile fields from
the cache. When no profile row exists the `post_count` fallback
uses a live `SELECT COUNT(*)` instead of `posts.len()`, so
prolific authors without a profile row report the real count
rather than the 50-post slice cap.
Tests (DB-gated, run when DATABASE_URL_APPVIEW is set):
* `blob_link_of_modern_shape` / `_legacy_flat_link` /
`_missing_field`.
* `upsert_profile_round_trip` — insert + replace semantics.
* `apply_commit_indexes_profile_create` — end-to-end Jetstream
arm + delete.
314 lines
10 KiB
Rust
314 lines
10 KiB
Rust
//! `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,
|
|
/// The poster's current handle, as known by the PDS `users` table.
|
|
/// Optional in the wire payload — the AppView falls back to an
|
|
/// empty string, and the upsert COALESCE guard prevents the empty
|
|
/// value from clobbering a backfilled handle from the Jetstream
|
|
/// `identity` event path.
|
|
#[serde(default)]
|
|
pub handle: Option<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 mut row = indexer::PostRow::from_record(
|
|
&req.did,
|
|
&req.rkey,
|
|
&req.collection,
|
|
&cid,
|
|
&record,
|
|
req.handle.as_deref(),
|
|
);
|
|
indexer::upsert_post(&state.db, &mut 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)
|
|
}
|
|
("app.bsky.actor.profile", "create") if req.rkey == "self" => {
|
|
// Profile record push from the PDS — populate the
|
|
// `profiles` cache so the ProfileView-Page and PostCard
|
|
// avatar get the new display name / bio / avatar / banner
|
|
// without waiting for the next handle-sync pass.
|
|
let record = match &req.record {
|
|
Some(r) if !r.is_null() => r.clone(),
|
|
_ => return Ok(false),
|
|
};
|
|
// Use the handle the PDS provided when present. We
|
|
// deliberately do NOT fall back to a DB lookup here:
|
|
// the AppView has no `users` table — the PDS owns that
|
|
// state. If the PDS omits the handle, we write an empty
|
|
// string and the `handle_sync` worker (or a subsequent
|
|
// Jetstream `identity` event) will fill it in.
|
|
let handle = req
|
|
.handle
|
|
.clone()
|
|
.filter(|h| !h.is_empty())
|
|
.unwrap_or_default();
|
|
indexer::upsert_profile(&state.db, &req.did, &handle, &record)
|
|
.await
|
|
.map_err(db_err)?;
|
|
Ok(true)
|
|
}
|
|
("app.bsky.actor.profile", "delete") if req.rkey == "self" => {
|
|
sqlx::query("DELETE FROM profiles WHERE did = $1")
|
|
.bind(&req.did)
|
|
.execute(&state.db)
|
|
.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());
|
|
}
|
|
} |