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
+845
View File
@@ -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");
}
}