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,845 @@
|
||||
//! AppView HTTP routes.
|
||||
//!
|
||||
//! Three groups:
|
||||
//! - `root` + `healthz`: public liveness / info probes.
|
||||
//! - `timeline_*` / `profile_*` / `search`: read API the Tauri client
|
||||
//! calls to render the UI. Reads from `posts` (and the helper
|
||||
//! `follows` / `likes` tables) — never writes.
|
||||
//! - `ingest_commit`: the internal-only writer used by the PDS, owned
|
||||
//! in `crate::ingest`.
|
||||
//!
|
||||
//! The cursor format used by `timeline_home` is opaque: it's a
|
||||
//! `base64url(micros):uri` pair, which is what [`cursor::encode`] and
|
||||
//! [`cursor::decode`] produce/consume.
|
||||
|
||||
use axum::{
|
||||
extract::{Path, Query, State},
|
||||
http::StatusCode,
|
||||
response::IntoResponse,
|
||||
routing::{get, post},
|
||||
Json, Router,
|
||||
};
|
||||
use chrono::{DateTime, TimeZone, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
pub mod cursor;
|
||||
pub mod types;
|
||||
|
||||
use types::{PostRow, PostRowWithIndexed, ProfileResponse, SearchResponse, TimelineResponse};
|
||||
|
||||
pub fn router(state: AppState) -> Router {
|
||||
Router::new()
|
||||
.route("/", get(root))
|
||||
.route("/api/timeline/home", get(timeline_home))
|
||||
.route("/api/profile", get(profile_query))
|
||||
.route("/api/profile/:handle", get(profile_path))
|
||||
.route("/api/search", get(search))
|
||||
.route("/api/post/*uri", get(post_by_uri))
|
||||
.route("/healthz", get(healthz))
|
||||
.route("/internal/ingest-commit", post(crate::ingest::ingest_commit))
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
async fn root() -> Json<Value> {
|
||||
Json(json!({
|
||||
"name": "maarcadetweet-appview",
|
||||
"version": env!("CARGO_PKG_VERSION"),
|
||||
}))
|
||||
}
|
||||
|
||||
// -- timeline ---------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct TimelineQuery {
|
||||
/// The DID of the user whose timeline we're building. Required —
|
||||
/// without it we have no way to do (future) follow-graph filtering
|
||||
/// and no place to anchor the pagination.
|
||||
did: String,
|
||||
/// Page size. Defaults to 30, hard-capped at 100.
|
||||
#[serde(default)]
|
||||
limit: Option<i64>,
|
||||
/// Opaque cursor returned by a previous call.
|
||||
#[serde(default)]
|
||||
cursor: Option<String>,
|
||||
}
|
||||
|
||||
const DEFAULT_LIMIT: i64 = 30;
|
||||
const MAX_LIMIT: i64 = 100;
|
||||
/// Hard cap on the number of DIDs we'll filter a timeline query by.
|
||||
///
|
||||
/// Prevents an unbounded `did = ANY($1::text[])` array from a power
|
||||
/// user with thousands of follows — SQL injection isn't the worry
|
||||
/// (the bind is parameterised) but a 50k-element array still has to be
|
||||
/// shipped across the wire and parsed by Postgres on every page
|
||||
/// request. 1000 covers essentially every realistic follow graph; if
|
||||
/// a user exceeds it we cap deterministically (sorted by DID) so the
|
||||
/// result set is stable across requests, and we always keep the
|
||||
/// requesting user's own DID in the set so their own posts still
|
||||
/// surface.
|
||||
const MAX_FOLLOWED_DIDS: usize = 1000;
|
||||
|
||||
async fn timeline_home(
|
||||
State(state): State<AppState>,
|
||||
Query(q): Query<TimelineQuery>,
|
||||
) -> Result<Json<TimelineResponse>, (StatusCode, Json<Value>)> {
|
||||
if q.did.is_empty() {
|
||||
return Err(bad_request("did is required"));
|
||||
}
|
||||
let limit = q
|
||||
.limit
|
||||
.unwrap_or(DEFAULT_LIMIT)
|
||||
.clamp(1, MAX_LIMIT);
|
||||
|
||||
// Look up the set of DIDs this user follows, then build the
|
||||
// `target_dids` set we'll filter `posts` by:
|
||||
//
|
||||
// 1. Start with the followees from the `follows` table.
|
||||
// 2. Add the user's own DID so they always see their own posts.
|
||||
// 3. Deduplicate.
|
||||
// 4. Cap at MAX_FOLLOWED_DIDS. If we'd exceed the cap, sort by
|
||||
// DID (stable across requests), trim to the cap, then if the
|
||||
// requesting user's DID was trimmed out and there's still
|
||||
// room in the cap, put them back in. This guarantees the
|
||||
// user's own posts are visible regardless of follow graph
|
||||
// size.
|
||||
//
|
||||
// If the followee set is empty *and* adding the user's own DID
|
||||
// leaves us with only that one entry, we treat it as the cold
|
||||
// start case and fall back to the global recent feed — new users
|
||||
// see the world before they have a graph.
|
||||
let followed_dids: Vec<String> = sqlx::query_scalar(
|
||||
"SELECT subject_did FROM follows WHERE follower_did = $1",
|
||||
)
|
||||
.bind(&q.did)
|
||||
.fetch_all(&state.db)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
|
||||
// Build the deduped target set. `followed_dids` may contain the
|
||||
// user's own DID (self-follow is rare but legal in the protocol),
|
||||
// so we dedup with full sort+dedup rather than relying on a
|
||||
// presence-check.
|
||||
let has_real_follows = followed_dids.iter().any(|d| d != &q.did);
|
||||
let mut target_dids: Vec<String> = if has_real_follows {
|
||||
let mut v = followed_dids;
|
||||
v.push(q.did.clone());
|
||||
v.sort();
|
||||
v.dedup();
|
||||
v
|
||||
} else {
|
||||
// Cold start: no real follow graph yet. Fall through to the
|
||||
// global-recent branch below.
|
||||
vec![]
|
||||
};
|
||||
|
||||
// Cap deterministically. After capping, the user's own DID must
|
||||
// still be in the set — that's a hard invariant for "user sees
|
||||
// their own posts".
|
||||
if target_dids.len() > MAX_FOLLOWED_DIDS {
|
||||
// Drop own, sort, trim to leave room for own, re-add own.
|
||||
// Sorting makes the truncation deterministic (we drop the
|
||||
// lex-greatest (N − MAX_FOLLOWED_DIDS) followees, not a random
|
||||
// subset).
|
||||
let own = q.did.clone();
|
||||
target_dids.retain(|d| d != &own);
|
||||
target_dids.sort();
|
||||
// Reserve one slot for `own` so the final length is exactly
|
||||
// MAX_FOLLOWED_DIDS.
|
||||
target_dids.truncate(MAX_FOLLOWED_DIDS - 1);
|
||||
target_dids.push(own);
|
||||
target_dids.sort();
|
||||
}
|
||||
|
||||
let decode = match q.cursor.as_deref().map(cursor::decode) {
|
||||
Some(Ok(c)) => Some(c),
|
||||
Some(Err(e)) => return Err(bad_request(&e)),
|
||||
None => None,
|
||||
};
|
||||
// Convert the cursor's microsecond timestamp to a DateTime<Utc> so
|
||||
// sqlx binds it as `timestamptz` rather than `bigint` (which would
|
||||
// fail the `(indexed_at, uri) < ($1, $2)` row comparison).
|
||||
//
|
||||
// If the timestamp is out of chrono::Utc's representable range
|
||||
// (e.g. i64::MAX from a malicious cursor) we reject with 400 instead
|
||||
// of silently falling back to page 1, which would lose the user's
|
||||
// pagination state.
|
||||
let cursor_ts: Option<DateTime<Utc>> = match decode.as_ref() {
|
||||
Some(c) => Some(
|
||||
chrono::Utc
|
||||
.timestamp_micros(c.ts)
|
||||
.single()
|
||||
.ok_or_else(|| bad_request("invalid cursor timestamp"))?,
|
||||
),
|
||||
None => None,
|
||||
};
|
||||
let cursor_uri: Option<String> =
|
||||
decode.as_ref().map(|c| c.uri.clone());
|
||||
// Fetch limit+1 to know if there's a next page without a second
|
||||
// round trip.
|
||||
let fetch = limit + 1;
|
||||
|
||||
// We select `indexed_at` alongside the post row so the cursor
|
||||
// builder can use it directly without a second round trip. The
|
||||
// helper inner function collapses the four (followed? + cursor?)
|
||||
// branches into one query_as per shape.
|
||||
// Cold-start branch: user has no real follow graph (only self, or
|
||||
// nothing). Show the global recent feed.
|
||||
let mut rows: Vec<PostRowWithIndexed> = if target_dids.is_empty() {
|
||||
match cursor_ts {
|
||||
Some(ts) => sqlx::query_as::<_, PostRowWithIndexed>(
|
||||
r#"SELECT uri, did, handle, rkey, collection, text, cid,
|
||||
parent_uri, root_uri, embed, langs, created_at,
|
||||
indexed_at
|
||||
FROM posts
|
||||
WHERE collection IN ('app.twi.post','app.bsky.feed.post')
|
||||
AND (indexed_at, uri) < ($1, $2)
|
||||
ORDER BY indexed_at DESC, uri DESC
|
||||
LIMIT $3"#,
|
||||
)
|
||||
.bind(ts)
|
||||
.bind(cursor_uri.as_deref().unwrap())
|
||||
.bind(fetch)
|
||||
.fetch_all(&state.db)
|
||||
.await
|
||||
.map_err(db_err)?,
|
||||
None => sqlx::query_as::<_, PostRowWithIndexed>(
|
||||
r#"SELECT uri, did, handle, rkey, collection, text, cid,
|
||||
parent_uri, root_uri, embed, langs, created_at,
|
||||
indexed_at
|
||||
FROM posts
|
||||
WHERE collection IN ('app.twi.post','app.bsky.feed.post')
|
||||
ORDER BY indexed_at DESC, uri DESC
|
||||
LIMIT $1"#,
|
||||
)
|
||||
.bind(fetch)
|
||||
.fetch_all(&state.db)
|
||||
.await
|
||||
.map_err(db_err)?,
|
||||
}
|
||||
} else {
|
||||
// Graph-aware branch: filter `posts.did` to the followee set
|
||||
// plus the requesting user's own DID. `target_dids` has been
|
||||
// deduped and capped at MAX_FOLLOWED_DIDS, and the user's own
|
||||
// DID is guaranteed to be in the set.
|
||||
match cursor_ts {
|
||||
Some(ts) => sqlx::query_as::<_, PostRowWithIndexed>(
|
||||
r#"SELECT uri, did, handle, rkey, collection, text, cid,
|
||||
parent_uri, root_uri, embed, langs, created_at,
|
||||
indexed_at
|
||||
FROM posts
|
||||
WHERE collection IN ('app.twi.post','app.bsky.feed.post')
|
||||
AND did = ANY($2::text[])
|
||||
AND (indexed_at, uri) < ($3, $4)
|
||||
ORDER BY indexed_at DESC, uri DESC
|
||||
LIMIT $1"#,
|
||||
)
|
||||
.bind(fetch)
|
||||
.bind(&target_dids)
|
||||
.bind(ts)
|
||||
.bind(cursor_uri.as_deref().unwrap())
|
||||
.fetch_all(&state.db)
|
||||
.await
|
||||
.map_err(db_err)?,
|
||||
None => sqlx::query_as::<_, PostRowWithIndexed>(
|
||||
r#"SELECT uri, did, handle, rkey, collection, text, cid,
|
||||
parent_uri, root_uri, embed, langs, created_at,
|
||||
indexed_at
|
||||
FROM posts
|
||||
WHERE collection IN ('app.twi.post','app.bsky.feed.post')
|
||||
AND did = ANY($2::text[])
|
||||
ORDER BY indexed_at DESC, uri DESC
|
||||
LIMIT $1"#,
|
||||
)
|
||||
.bind(fetch)
|
||||
.bind(&target_dids)
|
||||
.fetch_all(&state.db)
|
||||
.await
|
||||
.map_err(db_err)?,
|
||||
}
|
||||
};
|
||||
|
||||
// We fetched limit+1 to peek for a next page. If we got more than
|
||||
// `limit`, drop the extra and remember its `indexed_at` to encode
|
||||
// into the next cursor.
|
||||
let next_ts_uri: Option<(DateTime<Utc>, String)> = if rows.len() as i64 > limit {
|
||||
rows.truncate(limit as usize);
|
||||
rows.last()
|
||||
.map(|p| (p.indexed_at, p.uri.clone()))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Strip the `indexed_at` companion column from the response.
|
||||
let mut posts: Vec<PostRow> = rows.into_iter().map(Into::into).collect();
|
||||
|
||||
// Derive a `display_handle` for posts that have an empty `handle`
|
||||
// column (Jetstream-only path). We mutate a clone with a populated
|
||||
// `handle` so the client doesn't have to guess.
|
||||
decorate_handles(&mut posts);
|
||||
|
||||
let next_cursor = next_ts_uri
|
||||
.map(|(ts, uri)| cursor::encode(ts, &uri));
|
||||
|
||||
Ok(Json(TimelineResponse {
|
||||
posts,
|
||||
cursor: next_cursor,
|
||||
}))
|
||||
}
|
||||
|
||||
// -- profile ----------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ProfileQuery {
|
||||
#[serde(default)]
|
||||
did: Option<String>,
|
||||
#[serde(default)]
|
||||
handle: Option<String>,
|
||||
}
|
||||
|
||||
async fn profile_query(
|
||||
State(state): State<AppState>,
|
||||
Query(q): Query<ProfileQuery>,
|
||||
) -> Result<Json<ProfileResponse>, (StatusCode, Json<Value>)> {
|
||||
let did = q.did.clone().or(q.handle.clone());
|
||||
let handle = q.handle.clone();
|
||||
resolve_profile(&state, did.as_deref(), handle.as_deref()).await
|
||||
}
|
||||
|
||||
async fn profile_path(
|
||||
State(state): State<AppState>,
|
||||
Path(handle): Path<String>,
|
||||
) -> Result<Json<ProfileResponse>, (StatusCode, Json<Value>)> {
|
||||
resolve_profile(&state, None, Some(&handle)).await
|
||||
}
|
||||
|
||||
async fn resolve_profile(
|
||||
state: &AppState,
|
||||
did: Option<&str>,
|
||||
handle: Option<&str>,
|
||||
) -> Result<Json<ProfileResponse>, (StatusCode, Json<Value>)> {
|
||||
// Reject empty params up front — otherwise the empty-string DID/handle
|
||||
// produces a valid-looking 200 with zero posts.
|
||||
if let Some(d) = did {
|
||||
if d.is_empty() {
|
||||
return Err(bad_request("did is required"));
|
||||
}
|
||||
}
|
||||
if let Some(h) = handle {
|
||||
if h.is_empty() {
|
||||
return Err(bad_request("handle is required"));
|
||||
}
|
||||
}
|
||||
|
||||
// Strip a leading '@' on the handle — the UI passes `@alice` style.
|
||||
let handle_clean = handle.map(|h| h.trim_start_matches('@').to_string());
|
||||
|
||||
// Try by DID first if we have it; fall back to handle lookup.
|
||||
let target_did: Option<String> = if let Some(d) = did {
|
||||
Some(d.to_string())
|
||||
} else if let Some(ref h) = handle_clean {
|
||||
// Order by `indexed_at DESC` so we get the most recent DID for
|
||||
// this handle (a single user can re-use a handle if account
|
||||
// history allows, but the latest is the active one).
|
||||
sqlx::query_scalar::<_, String>(
|
||||
"SELECT did FROM posts WHERE handle = $1 ORDER BY indexed_at DESC LIMIT 1",
|
||||
)
|
||||
.bind(h)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
.map_err(db_err)?
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let Some(target_did) = target_did else {
|
||||
return Err((
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({
|
||||
"error": "NotFound",
|
||||
"message": "no DID or handle provided, or no posts for that handle",
|
||||
})),
|
||||
));
|
||||
};
|
||||
|
||||
// Fetch the user's most recent posts (newest first). We return up to
|
||||
// 50 — enough for a profile view, and the client can paginate with
|
||||
// /api/timeline/home if it needs more.
|
||||
let mut posts: Vec<PostRow> = sqlx::query_as::<_, PostRow>(
|
||||
r#"SELECT uri, did, handle, rkey, collection, text, cid,
|
||||
parent_uri, root_uri, embed, langs, created_at,
|
||||
like_count, repost_count
|
||||
FROM posts
|
||||
WHERE did = $1
|
||||
AND collection IN ('app.twi.post','app.bsky.feed.post')
|
||||
ORDER BY indexed_at DESC, uri DESC
|
||||
LIMIT 50"#,
|
||||
)
|
||||
.bind(&target_did)
|
||||
.fetch_all(&state.db)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
|
||||
// Pick the best available handle: explicit query handle, then the
|
||||
// first non-empty handle from the posts we just pulled.
|
||||
let display_handle = handle_clean
|
||||
.clone()
|
||||
.or_else(|| {
|
||||
posts
|
||||
.iter()
|
||||
.find(|p| !p.handle.is_empty())
|
||||
.map(|p| p.handle.clone())
|
||||
})
|
||||
.unwrap_or_else(|| {
|
||||
// Last-resort synthetic handle. The schema note says
|
||||
// "@<did-snip>" is acceptable; we keep it short and safe.
|
||||
short_did_for_display(&target_did)
|
||||
});
|
||||
|
||||
decorate_handles(&mut posts);
|
||||
|
||||
let followers: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*)::BIGINT FROM follows WHERE subject_did = $1",
|
||||
)
|
||||
.bind(&target_did)
|
||||
.fetch_one(&state.db)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
|
||||
let following: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*)::BIGINT FROM follows WHERE follower_did = $1",
|
||||
)
|
||||
.bind(&target_did)
|
||||
.fetch_one(&state.db)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
|
||||
Ok(Json(ProfileResponse {
|
||||
did: target_did,
|
||||
handle: display_handle,
|
||||
posts,
|
||||
followers,
|
||||
following,
|
||||
}))
|
||||
}
|
||||
|
||||
// -- post by uri (thread hydration) ----------------------------------------
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ThreadResponse {
|
||||
post: Option<PostRow>,
|
||||
thread: ThreadView,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
like_count: Option<i64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
repost_count: Option<i64>,
|
||||
/// `true` when the requesting viewer (`viewer_did` query param)
|
||||
/// has a like row for this post. `None` if the viewer was not
|
||||
/// specified — the client should treat `None` as "unknown, hide
|
||||
/// the liked state" rather than "not liked". (Phase 5b review
|
||||
/// fix H4 — without this, the UI shows a generic count but
|
||||
/// can't tell whether the user has already liked the post.)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
viewer_liked: Option<bool>,
|
||||
/// Same as `viewer_liked` but for reposts.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
viewer_reposted: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ThreadView {
|
||||
parent: Option<PostRow>,
|
||||
root: Option<PostRow>,
|
||||
}
|
||||
|
||||
/// Optional query parameters for `/api/post/{uri}`. The Tauri client
|
||||
/// passes its current session DID so the response can include
|
||||
/// `viewer_liked` / `viewer_reposted` booleans.
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
struct PostQuery {
|
||||
#[serde(default)]
|
||||
viewer_did: Option<String>,
|
||||
}
|
||||
|
||||
/// `GET /api/post/{uri}` — single-post lookup with parent + root
|
||||
/// hydrated in one round trip.
|
||||
///
|
||||
/// Used by the UI when the user clicks a "show thread" link on a reply:
|
||||
/// rather than three sequential fetches (`/api/post/{uri}` → fetch parent
|
||||
/// → fetch root), the server returns the post plus its `parent_uri` /
|
||||
/// `root_uri` rows in one go. Missing parents / roots come back as
|
||||
/// `null` so the client can render "unknown / not in index" without
|
||||
/// retrying.
|
||||
///
|
||||
/// When the post is found, the response also includes `like_count`
|
||||
/// and `repost_count` from the `likes` / `reposts` tables so the UI
|
||||
/// can render engagement numbers next to the action buttons. (We
|
||||
/// skip the count queries entirely when the post is missing so
|
||||
/// the "not in index" path stays cheap.)
|
||||
///
|
||||
/// When the caller supplies `viewer_did`, the response also includes
|
||||
/// `viewer_liked` / `viewer_reposted` so the client can highlight the
|
||||
/// like/repost button when the viewer has already engaged. Lookups
|
||||
/// run as a single `EXISTS` query each, hitting the
|
||||
/// `likes_did_post_uri_idx` / `reposts_did_post_uri_idx` unique
|
||||
/// indexes, so the cost is O(1) per viewer.
|
||||
///
|
||||
/// The path parameter is captured by `axum::extract::Path` as the raw
|
||||
/// remainder after `/api/post/`, so the colons in a `did:plc:…` URI are
|
||||
/// preserved verbatim — we never need URL decoding.
|
||||
async fn post_by_uri(
|
||||
State(state): State<AppState>,
|
||||
Path(uri): Path<String>,
|
||||
Query(q): Query<PostQuery>,
|
||||
) -> Result<Json<ThreadResponse>, (StatusCode, Json<Value>)> {
|
||||
if uri.is_empty() {
|
||||
return Err(bad_request("uri is required"));
|
||||
}
|
||||
let uri = percent_decode(&uri);
|
||||
let post: Option<PostRow> = sqlx::query_as::<_, PostRow>(
|
||||
r#"SELECT uri, did, handle, rkey, collection, text, cid,
|
||||
parent_uri, root_uri, embed, langs, created_at,
|
||||
like_count, repost_count
|
||||
FROM posts
|
||||
WHERE uri = $1
|
||||
LIMIT 1"#,
|
||||
)
|
||||
.bind(&uri)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
|
||||
// Pull the two refs off the row before we move it into the response
|
||||
// — `post` is consumed by the `Some(p)` arm but we still need
|
||||
// `parent_uri` / `root_uri` for the hydration lookups.
|
||||
let (parent_uri, root_uri, like_count, repost_count) = match post.as_ref() {
|
||||
Some(p) => (
|
||||
p.parent_uri.clone(),
|
||||
p.root_uri.clone(),
|
||||
Some(p.like_count),
|
||||
Some(p.repost_count),
|
||||
),
|
||||
None => (None, None, None, None),
|
||||
};
|
||||
|
||||
let parent: Option<PostRow> = match parent_uri.as_deref() {
|
||||
Some(u) => fetch_one_post(&state, u).await?,
|
||||
None => None,
|
||||
};
|
||||
let root: Option<PostRow> = match root_uri.as_deref() {
|
||||
Some(u) if Some(u) != parent_uri.as_deref() => {
|
||||
fetch_one_post(&state, u).await?
|
||||
}
|
||||
// Self-thread (single-post thread): `root` == `parent`. Avoid the
|
||||
// duplicate fetch — surface the parent row as the root too so the
|
||||
// UI can render the chain without an extra round trip.
|
||||
Some(_) => parent.clone(),
|
||||
None => None,
|
||||
};
|
||||
|
||||
// Viewer-scoped engagement state. We only run these queries when
|
||||
// (a) the post was found (otherwise `None` so the UI can ignore
|
||||
// viewer state on a missing post) and (b) the caller actually
|
||||
// passed a viewer_did.
|
||||
let (viewer_liked, viewer_reposted) = if post.is_some() {
|
||||
if let Some(viewer) = q.viewer_did.as_deref() {
|
||||
let liked: bool = sqlx::query_scalar(
|
||||
"SELECT EXISTS(SELECT 1 FROM likes WHERE did = $1 AND post_uri = $2)",
|
||||
)
|
||||
.bind(viewer)
|
||||
.bind(&uri)
|
||||
.fetch_one(&state.db)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
let reposted: bool = sqlx::query_scalar(
|
||||
"SELECT EXISTS(SELECT 1 FROM reposts WHERE did = $1 AND post_uri = $2)",
|
||||
)
|
||||
.bind(viewer)
|
||||
.bind(&uri)
|
||||
.fetch_one(&state.db)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
(Some(liked), Some(reposted))
|
||||
} else {
|
||||
(None, None)
|
||||
}
|
||||
} else {
|
||||
(None, None)
|
||||
};
|
||||
|
||||
// We hand `parent` / `root` to the response and `post` last so the
|
||||
// borrow on `parent_uri` / `root_uri` is already released.
|
||||
Ok(Json(ThreadResponse {
|
||||
post,
|
||||
thread: ThreadView { parent, root },
|
||||
like_count,
|
||||
repost_count,
|
||||
viewer_liked,
|
||||
viewer_reposted,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn fetch_one_post(
|
||||
state: &AppState,
|
||||
uri: &str,
|
||||
) -> Result<Option<PostRow>, (StatusCode, Json<Value>)> {
|
||||
sqlx::query_as::<_, PostRow>(
|
||||
r#"SELECT uri, did, handle, rkey, collection, text, cid,
|
||||
parent_uri, root_uri, embed, langs, created_at,
|
||||
like_count, repost_count
|
||||
FROM posts
|
||||
WHERE uri = $1
|
||||
LIMIT 1"#,
|
||||
)
|
||||
.bind(uri)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
.map_err(db_err)
|
||||
}
|
||||
|
||||
/// Minimal percent-decode for the path capture. axum's `Path<String>`
|
||||
/// already decodes percent-escapes for us, so this is a no-op for
|
||||
/// the happy path. It only fires if the URI itself contains a stray
|
||||
/// `%XX` sequence the caller wants kept verbatim (e.g. a literal `%`
|
||||
/// in a path component, which we don't have here).
|
||||
#[allow(dead_code)]
|
||||
fn percent_decode(s: &str) -> String {
|
||||
let bytes = s.as_bytes();
|
||||
let mut out = String::with_capacity(s.len());
|
||||
let mut i = 0;
|
||||
while i < bytes.len() {
|
||||
if bytes[i] == b'%' && i + 2 < bytes.len() {
|
||||
if let (Some(h), Some(l)) = (
|
||||
hex_digit(bytes[i + 1]),
|
||||
hex_digit(bytes[i + 2]),
|
||||
) {
|
||||
out.push((h * 16 + l) as char);
|
||||
i += 3;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// Push ASCII bytes verbatim; for any multi-byte UTF-8 sequence
|
||||
// we push the lead byte as a char (which is valid because the
|
||||
// resulting char's code point is `< 128` only for ASCII). For
|
||||
// non-ASCII bytes the call sites never reach this branch
|
||||
// because `Path<String>` already decoded the URI for us.
|
||||
out.push(bytes[i] as char);
|
||||
i += 1;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn hex_digit(b: u8) -> Option<u8> {
|
||||
match b {
|
||||
b'0'..=b'9' => Some(b - b'0'),
|
||||
b'a'..=b'f' => Some(b - b'a' + 10),
|
||||
b'A'..=b'F' => Some(b - b'A' + 10),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
// -- search -----------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct SearchQuery {
|
||||
q: String,
|
||||
#[serde(default)]
|
||||
limit: Option<i64>,
|
||||
}
|
||||
|
||||
async fn search(
|
||||
State(state): State<AppState>,
|
||||
Query(q): Query<SearchQuery>,
|
||||
) -> Result<Json<SearchResponse>, (StatusCode, Json<Value>)> {
|
||||
let needle = q.q.trim();
|
||||
if needle.is_empty() {
|
||||
return Err(bad_request("q is required"));
|
||||
}
|
||||
// Cap query length to avoid pathological ILIKE patterns on huge input.
|
||||
if needle.len() > 100 {
|
||||
return Err(bad_request("q must be at most 100 characters"));
|
||||
}
|
||||
let limit = q.limit.unwrap_or(DEFAULT_LIMIT).clamp(1, MAX_LIMIT);
|
||||
|
||||
// ILIKE search. We escape LIKE wildcards in the user input so a
|
||||
// search for "10%" doesn't suddenly act as a glob.
|
||||
let escaped = escape_like(needle);
|
||||
let pattern = format!("%{escaped}%");
|
||||
|
||||
let mut posts: Vec<PostRow> = sqlx::query_as::<_, PostRow>(
|
||||
r#"SELECT uri, did, handle, rkey, collection, text, cid,
|
||||
parent_uri, root_uri, embed, langs, created_at,
|
||||
like_count, repost_count
|
||||
FROM posts
|
||||
WHERE text ILIKE $1 ESCAPE '\'
|
||||
AND collection IN ('app.twi.post','app.bsky.feed.post')
|
||||
ORDER BY indexed_at DESC
|
||||
LIMIT $2"#,
|
||||
)
|
||||
.bind(&pattern)
|
||||
.bind(limit)
|
||||
.fetch_all(&state.db)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
|
||||
decorate_handles(&mut posts);
|
||||
|
||||
Ok(Json(SearchResponse {
|
||||
posts,
|
||||
q: needle.to_string(),
|
||||
}))
|
||||
}
|
||||
|
||||
// -- healthz ----------------------------------------------------------------
|
||||
|
||||
async fn healthz(State(state): State<AppState>) -> impl IntoResponse {
|
||||
let stats = &state.stats;
|
||||
Json(json!({
|
||||
"ok": true,
|
||||
"lag_ms": stats.lag_ms(),
|
||||
"events_processed": stats.events_processed(),
|
||||
"jetstream_connected": stats.jetstream_connected(),
|
||||
}))
|
||||
}
|
||||
|
||||
// -- helpers ----------------------------------------------------------------
|
||||
|
||||
/// Fill in a synthetic `handle` for posts that have an empty one
|
||||
/// (Jetstream-indexed posts without a handle backfill). Operates in
|
||||
/// place. We deliberately do not mutate the database row — this is a
|
||||
/// display-time concern only.
|
||||
fn decorate_handles(posts: &mut [PostRow]) {
|
||||
for p in posts.iter_mut() {
|
||||
if p.handle.is_empty() {
|
||||
p.handle = short_did_for_display(&p.did);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn short_did_for_display(did: &str) -> String {
|
||||
// Match the spec: "@{first-12-chars-of-did}…". Use char-based slicing
|
||||
// so we never panic on a UTF-8 boundary (e.g. `did:web:münchen.de`).
|
||||
let snip: String = did.chars().take(12).collect();
|
||||
format!("@{snip}…")
|
||||
}
|
||||
|
||||
/// Escape `%`, `_`, and `\` for use inside a `LIKE ... ESCAPE '\'`
|
||||
/// pattern.
|
||||
fn escape_like(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
for c in s.chars() {
|
||||
match c {
|
||||
'\\' | '%' | '_' => {
|
||||
out.push('\\');
|
||||
out.push(c);
|
||||
}
|
||||
other => out.push(other),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn db_err(e: impl std::fmt::Display) -> (StatusCode, Json<Value>) {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({
|
||||
"error": "InternalServerError",
|
||||
"message": e.to_string(),
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
fn bad_request(msg: &str) -> (StatusCode, Json<Value>) {
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({
|
||||
"error": "InvalidRequest",
|
||||
"message": msg,
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
// -- tests ------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn escape_like_handles_wildcards() {
|
||||
assert_eq!(escape_like("hello"), "hello");
|
||||
assert_eq!(escape_like("100%"), "100\\%");
|
||||
assert_eq!(escape_like("a_b"), "a\\_b");
|
||||
assert_eq!(escape_like("back\\slash"), "back\\\\slash");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn short_did_format_matches_spec() {
|
||||
let s = short_did_for_display("did:plc:abcdefghijklmnop");
|
||||
assert_eq!(s, "@did:plc:abcd…");
|
||||
let s = short_did_for_display("short");
|
||||
assert_eq!(s, "@short…");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn percent_decode_handles_common_escapes() {
|
||||
assert_eq!(
|
||||
percent_decode("at%3A%2F%2Fdid%3Aplc%3Aabc"),
|
||||
"at://did:plc:abc"
|
||||
);
|
||||
// No escapes → identity.
|
||||
assert_eq!(percent_decode("at://did:plc:abc"), "at://did:plc:abc");
|
||||
// Truncated `%` at the end → kept verbatim (don't crash).
|
||||
assert_eq!(percent_decode("abc%"), "abc%");
|
||||
// Non-hex after `%` → kept verbatim.
|
||||
assert_eq!(percent_decode("abc%zz"), "abc%zz");
|
||||
// Mixed case hex digits.
|
||||
assert_eq!(percent_decode("a%2Bb"), "a+b");
|
||||
assert_eq!(percent_decode("a%2bb"), "a+b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decorate_handles_fills_empty_only() {
|
||||
let mut rows = vec![
|
||||
PostRow {
|
||||
uri: "at://x/app.twi.post/1".into(),
|
||||
did: "did:plc:abcdefghij".into(),
|
||||
handle: String::new(),
|
||||
rkey: "1".into(),
|
||||
collection: "app.twi.post".into(),
|
||||
text: "hi".into(),
|
||||
cid: "c".into(),
|
||||
parent_uri: None,
|
||||
root_uri: None,
|
||||
embed: None,
|
||||
langs: crate::routes::types::Langs(vec![]),
|
||||
created_at: Utc::now(),
|
||||
like_count: 0,
|
||||
repost_count: 0,
|
||||
},
|
||||
PostRow {
|
||||
uri: "at://x/app.twi.post/2".into(),
|
||||
did: "did:plc:abc".into(),
|
||||
handle: "alice".into(),
|
||||
rkey: "2".into(),
|
||||
collection: "app.twi.post".into(),
|
||||
text: "hi".into(),
|
||||
cid: "c".into(),
|
||||
parent_uri: None,
|
||||
root_uri: None,
|
||||
embed: None,
|
||||
langs: crate::routes::types::Langs(vec![]),
|
||||
created_at: Utc::now(),
|
||||
like_count: 0,
|
||||
repost_count: 0,
|
||||
},
|
||||
];
|
||||
decorate_handles(&mut rows);
|
||||
assert!(rows[0].handle.starts_with('@'));
|
||||
assert!(rows[0].handle.ends_with('…'));
|
||||
assert_eq!(rows[1].handle, "alice");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user