Crypto / protocol
- v4 envelope: X25519 + ML-KEM-768 hybrid (PQXDH-style HKDF over both
secrets). Heartbeats now carry an ML-KEM key; senders emit v4 whenever
the peer's heartbeat has one, v3 otherwise. Adds @noble/post-quantum.
- Heartbeats are signed by the KEZ identity and verified against the
recipient's primary before use. Previously a spoofed nostr pubkey
(from a re-wrapped replayed envelope) let an attacker substitute their
own heartbeat and read messages.
- Heartbeat retention counts from supersession, never deletes the
newest key, and GC runs after rotation — returning after a long
offline stretch no longer destroys the key the backlog needs.
- Verified-heartbeat cache per peer: 5-min TTL (one lookup per file
instead of per chunk) and anti-downgrade to identity ECDH.
- Always republish heartbeat on session start; rotate on the poll timer;
atomic IDB updates; wipe heartbeats on "Forget this account".
- Dedupe inbound envelopes by sender_sig (replays / re-broadcasts).
Server
- Proxy /v1/sigchains/* to the internal sig-server and hand out the
public https://<server>/v1/sigchains/... URL. Sigchain sync from the
app was failing ("Load failed") against http://sig-server:7878.
App
- Relay settings apply to profile / NIP-65 publishing (republished on
change); wss-only relays (ws:// for localhost); order-aware defaults.
- Clear chat also deletes attachments; retention changes apply
immediately with a warning when shortening.
- Photos: HEIC -> JPEG and downscale to 2048px (iOS canvas limit, strips
EXIF); snapshot the conversation at send time.
- Overlay a11y/Escape fixes, scroll-pin fix, svelte-check to 0 errors.
- Clear chat, image zoom, file-body fallback rendering, NIP-65 read
relays in publish set, relay settings UI, forward-secrecy settings.
Docs
- README rewritten to explain the whole app; SECURITY.md updated for v4;
heartbeat self-check (src/lib/heartbeat.selfcheck.ts).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
711 lines
29 KiB
Rust
711 lines
29 KiB
Rust
//! HTTP API routes — all in one file for v0.1 since each route is small.
|
|
//!
|
|
//! GET / placeholder SPA (or web_dir)
|
|
//! GET /v1/healthz liveness
|
|
//! GET /v1/u/:handle handle → primary + sigchain pointer + endpoints
|
|
//! POST /v1/register claim a handle (signed body)
|
|
//! GET /v1/sigchains/:scheme/:id[/head] } proxied to the internal
|
|
//! POST /v1/sigchains/:scheme/:id/events } sig-server
|
|
//! GET /.well-known/webfinger?resource=... fediverse-style discovery
|
|
//! POST /internal/nats/auth NATS auth callout (stub in v0.1)
|
|
|
|
use axum::Json;
|
|
use axum::extract::{Path, Query, State};
|
|
use axum::http::{StatusCode, header};
|
|
use axum::response::{Html, IntoResponse};
|
|
use axum::routing::{get, post};
|
|
use chrono::Utc;
|
|
use serde::{Deserialize, Serialize};
|
|
use serde_json::{Value, json};
|
|
use tower_http::services::ServeDir;
|
|
|
|
use crate::config::Config;
|
|
use crate::error::ApiError;
|
|
use crate::handles::validate_handle;
|
|
use crate::registration::SignedRegistration;
|
|
use crate::store::{HandleRecord, Store};
|
|
|
|
#[derive(Clone)]
|
|
pub struct AppState {
|
|
pub store: Store,
|
|
pub config: Config,
|
|
pub broker: crate::broker::Broker,
|
|
pub vapid: crate::push::VapidKeys,
|
|
pub push: crate::push::PushSender,
|
|
/// Per-IP rate limiter for `POST /v1/messages`. See TODO.md Day 2 #15.
|
|
pub send_rate_limit: crate::rate_limit::RateLimiter,
|
|
}
|
|
|
|
pub fn router(state: AppState) -> axum::Router {
|
|
let web_dir = state.config.web_dir.clone();
|
|
|
|
// Build the router with all API routes first, attach the SPA fallback,
|
|
// then apply state at the end (axum requires all routes to be added
|
|
// before `with_state` is called).
|
|
let mut router = axum::Router::new()
|
|
.route("/v1/healthz", get(healthz))
|
|
.route("/v1/u/:handle", get(lookup))
|
|
.route("/v1/by-primary/:primary", get(lookup_by_primary))
|
|
.route("/v1/register", post(register))
|
|
.route("/v1/profile/:handle/proofs", axum::routing::put(set_proofs))
|
|
.route("/v1/messages", post(crate::messages::send_message))
|
|
.route("/v1/inbox/:handle", get(crate::messages::inbox))
|
|
.route("/v1/inbox/:handle/stream", get(crate::messages::stream_inbox))
|
|
.route("/v1/push/vapid-public-key", get(push_vapid_key))
|
|
.route("/v1/push/subscribe/:handle", post(push_subscribe))
|
|
.route("/v1/push/unsubscribe/:handle", post(push_unsubscribe))
|
|
.route("/v1/push/subscriptions/:handle", get(push_list_subscriptions))
|
|
.route("/v1/sigchains/:scheme/:id", get(sigchain_proxy))
|
|
.route("/v1/sigchains/:scheme/:id/head", get(sigchain_proxy))
|
|
.route("/v1/sigchains/:scheme/:id/events", post(sigchain_proxy))
|
|
.route("/.well-known/webfinger", get(webfinger))
|
|
.route("/internal/nats/auth", post(nats_auth_callout));
|
|
|
|
router = if let Some(dir) = web_dir {
|
|
// Explicit no-cache for the files that gate everything else:
|
|
// • /sw.js — controls all client caching; stale = stuck on old build
|
|
// • /index.html — the SPA shell that loads hashed asset URLs
|
|
// • /manifest.webmanifest — drives PWA install behaviour
|
|
//
|
|
// Without these overrides Cloudflare cached our service worker
|
|
// for 4 hours and users got "ghost" deploys where the binary
|
|
// updated but the SW + bundle didn't, breaking notifications
|
|
// and session restore.
|
|
let dir_for_sw = dir.clone();
|
|
let dir_for_html = dir.clone();
|
|
let dir_for_manifest = dir.clone();
|
|
router
|
|
.route("/sw.js", get(move || serve_nocache(dir_for_sw.clone(), "sw.js")))
|
|
.route("/index.html", get(move || serve_nocache(dir_for_html.clone(), "index.html")))
|
|
.route(
|
|
"/manifest.webmanifest",
|
|
get(move || serve_nocache(dir_for_manifest.clone(), "manifest.webmanifest")),
|
|
)
|
|
.fallback_service(ServeDir::new(dir))
|
|
} else {
|
|
// No SPA dir; serve a built-in placeholder page at `/`.
|
|
router.route("/", get(placeholder_index))
|
|
};
|
|
|
|
router.with_state(state)
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// healthz
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
async fn healthz(State(state): State<AppState>) -> Json<Value> {
|
|
Json(json!({
|
|
"status": "ok",
|
|
"server": state.config.server,
|
|
"version": env!("CARGO_PKG_VERSION"),
|
|
}))
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// GET /v1/u/:handle — handle lookup
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
#[derive(Debug, Serialize)]
|
|
pub struct HandleResponse {
|
|
pub handle: String, // bare local-part: "tudisco"
|
|
pub fqhn: String, // fully qualified: "tudisco@kez.lat"
|
|
pub primary: String, // e.g. "ed25519:abc..."
|
|
pub sigchain_url: String, // where the sigchain lives
|
|
pub registered_at: String,
|
|
/// Claim subjects the user published for discovery (e.g.
|
|
/// ["github:alice"]). Peers verify each independently. Empty if none.
|
|
pub proofs: Vec<String>,
|
|
}
|
|
|
|
async fn lookup(
|
|
State(state): State<AppState>,
|
|
Path(handle): Path<String>,
|
|
) -> Result<Json<HandleResponse>, ApiError> {
|
|
let record = state
|
|
.store
|
|
.lookup(&handle)
|
|
.await?
|
|
.ok_or(ApiError::NotFound)?;
|
|
Ok(Json(handle_response(&state.config, &record)))
|
|
}
|
|
|
|
/// Reverse lookup: `ed25519:<hex>` → handle record. The Messages UI calls
|
|
/// this when an inbound envelope's `from` field is a primary it hasn't
|
|
/// seen before, so it can render "@alice" instead of "ed25519:abc…".
|
|
async fn lookup_by_primary(
|
|
State(state): State<AppState>,
|
|
Path(primary): Path<String>,
|
|
) -> Result<Json<HandleResponse>, ApiError> {
|
|
let parsed = kez_core::Identity::parse(&primary)
|
|
.map_err(|e| ApiError::BadRequest(format!("invalid primary: {e}")))?;
|
|
let record = state
|
|
.store
|
|
.lookup_by_primary(&parsed)
|
|
.await?
|
|
.ok_or(ApiError::NotFound)?;
|
|
Ok(Json(handle_response(&state.config, &record)))
|
|
}
|
|
|
|
fn handle_response(config: &Config, record: &HandleRecord) -> HandleResponse {
|
|
let scheme = record.primary.scheme();
|
|
let id = record.primary.value();
|
|
HandleResponse {
|
|
handle: record.handle.clone(),
|
|
fqhn: format!("{}@{}", record.handle, config.server),
|
|
primary: record.primary.to_string(),
|
|
// Public address, served by `sigchain_proxy` below. The
|
|
// sig-server itself (`sig_server_url`) is only reachable inside
|
|
// the deploy network, so it must never be handed to clients.
|
|
sigchain_url: format!("https://{}/v1/sigchains/{}/{}", config.server, scheme, id),
|
|
registered_at: record.registered_at.to_rfc3339(),
|
|
proofs: record
|
|
.proofs
|
|
.as_deref()
|
|
.and_then(|s| serde_json::from_str::<Vec<String>>(s).ok())
|
|
.unwrap_or_default(),
|
|
}
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// POST /v1/register — claim a handle
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
async fn register(
|
|
State(state): State<AppState>,
|
|
Json(req): Json<SignedRegistration>,
|
|
) -> Result<impl IntoResponse, ApiError> {
|
|
// Format-level validation (envelope, signature)
|
|
req.verify_format()?;
|
|
|
|
// Semantic checks
|
|
if req.payload.server != state.config.server {
|
|
return Err(ApiError::BadRequest(format!(
|
|
"registration server {:?} does not match this server {:?}",
|
|
req.payload.server, state.config.server
|
|
)));
|
|
}
|
|
validate_handle(&req.payload.handle)?;
|
|
req.check_timestamp(Utc::now())?;
|
|
|
|
let record = HandleRecord {
|
|
handle: req.payload.handle.clone(),
|
|
primary: req.payload.primary.clone(),
|
|
registered_at: Utc::now(),
|
|
proofs: None,
|
|
};
|
|
state.store.register(&record).await?;
|
|
|
|
Ok((
|
|
StatusCode::CREATED,
|
|
Json(handle_response(&state.config, &record)),
|
|
))
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// PUT /v1/profile/:handle/proofs — publish your claim subjects (authed)
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct SetProofsRequest {
|
|
/// Claim subjects, e.g. ["github:alice","dns:alice.com"].
|
|
pub proofs: Vec<String>,
|
|
}
|
|
|
|
async fn set_proofs(
|
|
State(state): State<AppState>,
|
|
Path(handle): Path<String>,
|
|
headers: axum::http::HeaderMap,
|
|
Json(req): Json<SetProofsRequest>,
|
|
) -> Result<StatusCode, ApiError> {
|
|
validate_handle(&handle)
|
|
.map_err(|e| ApiError::BadRequest(format!("invalid handle: {e}")))?;
|
|
|
|
let record = state.store.lookup(&handle).await?.ok_or(ApiError::NotFound)?;
|
|
|
|
// Auth: X-KEZ-Auth: <unix_ts>:<sig>, signed by the handle's primary
|
|
// over the canonical request line. Same 60s skew window as inbox.
|
|
let auth = headers
|
|
.get("X-KEZ-Auth")
|
|
.ok_or_else(|| ApiError::Unauthorized("missing X-KEZ-Auth header".into()))?
|
|
.to_str()
|
|
.map_err(|_| ApiError::Unauthorized("non-ASCII X-KEZ-Auth".into()))?;
|
|
verify_profile_auth(auth, &handle, record.primary.value(), Utc::now().timestamp())?;
|
|
|
|
// Cap to keep profiles small; reject absurd payloads.
|
|
if req.proofs.len() > 64 {
|
|
return Err(ApiError::BadRequest("too many proofs (max 64)".into()));
|
|
}
|
|
let json = serde_json::to_string(&req.proofs)?;
|
|
state.store.set_proofs(&handle, &json).await?;
|
|
Ok(StatusCode::NO_CONTENT)
|
|
}
|
|
|
|
/// Canonical message the proofs-setter signs. Distinct first line from
|
|
/// the inbox/stream auth so signatures can't be cross-replayed.
|
|
pub fn canonical_profile_message(handle: &str, ts: i64) -> String {
|
|
format!("PUT\n/v1/profile/{handle}/proofs\n{ts}")
|
|
}
|
|
|
|
fn verify_profile_auth(
|
|
auth: &str,
|
|
handle: &str,
|
|
pubkey_hex: &str,
|
|
now_ts: i64,
|
|
) -> Result<(), ApiError> {
|
|
let (ts_str, sig_hex) = auth
|
|
.split_once(':')
|
|
.ok_or_else(|| ApiError::Unauthorized("X-KEZ-Auth must be <ts>:<sig>".into()))?;
|
|
let ts: i64 = ts_str
|
|
.parse()
|
|
.map_err(|_| ApiError::Unauthorized("auth ts must be a unix timestamp".into()))?;
|
|
if (now_ts - ts).abs() > 60 {
|
|
return Err(ApiError::Unauthorized("auth header is stale".into()));
|
|
}
|
|
let message = canonical_profile_message(handle, ts);
|
|
kez_core::verify_ed25519_hex(pubkey_hex, message.as_bytes(), sig_hex)
|
|
.map_err(|_| ApiError::Unauthorized("signature did not verify".into()))?;
|
|
Ok(())
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Web Push — VAPID key + subscribe/unsubscribe
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
//
|
|
// Auth model is identical to the proofs endpoint: the caller signs a
|
|
// canonical request line with their primary Ed25519 key and puts
|
|
// `<unix_ts>:<sig_hex>` in `X-KEZ-Auth`. The subscribe/unsubscribe
|
|
// bodies stay tiny so push.js can hand us the SubscriptionJSON
|
|
// straight from the browser.
|
|
|
|
#[derive(Debug, Serialize)]
|
|
struct VapidKeyResponse {
|
|
/// uncompressed P-256 point, base64url-no-pad — passed straight to
|
|
/// `PushManager.subscribe({applicationServerKey})`.
|
|
key: String,
|
|
}
|
|
|
|
async fn push_vapid_key(State(state): State<AppState>) -> Json<VapidKeyResponse> {
|
|
Json(VapidKeyResponse {
|
|
key: state.vapid.public_b64url.clone(),
|
|
})
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct PushSubscribeRequest {
|
|
pub endpoint: String,
|
|
pub p256dh: String,
|
|
pub auth: String,
|
|
}
|
|
|
|
/// Canonical message the caller signs for push subscribe/unsubscribe.
|
|
/// Bound to the endpoint so a stolen header can't be replayed against
|
|
/// a different subscription (e.g. attacker swapping in their own URL).
|
|
pub fn canonical_push_message(verb: &str, handle: &str, endpoint: &str, ts: i64) -> String {
|
|
format!("{verb}\n/v1/push/{verb}/{handle}\n{endpoint}\n{ts}")
|
|
}
|
|
|
|
fn verify_push_auth(
|
|
auth: &str,
|
|
verb: &str,
|
|
handle: &str,
|
|
endpoint: &str,
|
|
pubkey_hex: &str,
|
|
now_ts: i64,
|
|
) -> Result<(), ApiError> {
|
|
let (ts_str, sig_hex) = auth
|
|
.split_once(':')
|
|
.ok_or_else(|| ApiError::Unauthorized("X-KEZ-Auth must be <ts>:<sig>".into()))?;
|
|
let ts: i64 = ts_str
|
|
.parse()
|
|
.map_err(|_| ApiError::Unauthorized("auth ts must be a unix timestamp".into()))?;
|
|
if (now_ts - ts).abs() > 60 {
|
|
return Err(ApiError::Unauthorized("auth header is stale".into()));
|
|
}
|
|
let message = canonical_push_message(verb, handle, endpoint, ts);
|
|
kez_core::verify_ed25519_hex(pubkey_hex, message.as_bytes(), sig_hex)
|
|
.map_err(|_| ApiError::Unauthorized("signature did not verify".into()))?;
|
|
Ok(())
|
|
}
|
|
|
|
async fn push_subscribe(
|
|
State(state): State<AppState>,
|
|
Path(handle): Path<String>,
|
|
headers: axum::http::HeaderMap,
|
|
Json(req): Json<PushSubscribeRequest>,
|
|
) -> Result<StatusCode, ApiError> {
|
|
validate_handle(&handle)
|
|
.map_err(|e| ApiError::BadRequest(format!("invalid handle: {e}")))?;
|
|
let record = state
|
|
.store
|
|
.lookup(&handle)
|
|
.await?
|
|
.ok_or(ApiError::NotFound)?;
|
|
|
|
let auth = headers
|
|
.get("X-KEZ-Auth")
|
|
.ok_or_else(|| ApiError::Unauthorized("missing X-KEZ-Auth header".into()))?
|
|
.to_str()
|
|
.map_err(|_| ApiError::Unauthorized("non-ASCII X-KEZ-Auth".into()))?;
|
|
verify_push_auth(
|
|
auth,
|
|
"subscribe",
|
|
&handle,
|
|
&req.endpoint,
|
|
record.primary.value(),
|
|
Utc::now().timestamp(),
|
|
)?;
|
|
|
|
let endpoint_for_log = req.endpoint.clone();
|
|
state
|
|
.store
|
|
.upsert_push_subscription(
|
|
&handle,
|
|
&crate::push::StoredSubscription {
|
|
endpoint: req.endpoint,
|
|
p256dh: req.p256dh,
|
|
auth: req.auth,
|
|
},
|
|
)
|
|
.await?;
|
|
// DEBUG, not INFO — the handle is sensitive enough that we
|
|
// don't want it in long-lived production logs. See TODO.md
|
|
// Day 1 #17. Diagnostic info (the push provider host) is fine.
|
|
tracing::debug!(
|
|
endpoint_host = %endpoint_for_log
|
|
.split_once("://").map(|(_, r)| r).unwrap_or(&endpoint_for_log)
|
|
.split(&['/', ':'][..]).next().unwrap_or("?"),
|
|
"push: subscription registered"
|
|
);
|
|
Ok(StatusCode::NO_CONTENT)
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct PushUnsubscribeRequest {
|
|
pub endpoint: String,
|
|
}
|
|
|
|
async fn push_unsubscribe(
|
|
State(state): State<AppState>,
|
|
Path(handle): Path<String>,
|
|
headers: axum::http::HeaderMap,
|
|
Json(req): Json<PushUnsubscribeRequest>,
|
|
) -> Result<StatusCode, ApiError> {
|
|
validate_handle(&handle)
|
|
.map_err(|e| ApiError::BadRequest(format!("invalid handle: {e}")))?;
|
|
let record = state
|
|
.store
|
|
.lookup(&handle)
|
|
.await?
|
|
.ok_or(ApiError::NotFound)?;
|
|
|
|
let auth = headers
|
|
.get("X-KEZ-Auth")
|
|
.ok_or_else(|| ApiError::Unauthorized("missing X-KEZ-Auth header".into()))?
|
|
.to_str()
|
|
.map_err(|_| ApiError::Unauthorized("non-ASCII X-KEZ-Auth".into()))?;
|
|
verify_push_auth(
|
|
auth,
|
|
"unsubscribe",
|
|
&handle,
|
|
&req.endpoint,
|
|
record.primary.value(),
|
|
Utc::now().timestamp(),
|
|
)?;
|
|
|
|
state.store.delete_push_subscription(&req.endpoint).await?;
|
|
Ok(StatusCode::NO_CONTENT)
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// GET /v1/push/subscriptions/:handle — self-heal check
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
//
|
|
// On every login (or session restore) the client calls this to verify
|
|
// the server still has the device's push subscription. Reasons it
|
|
// might be missing on the server:
|
|
// • Server dropped it after a 410 Gone from the push provider
|
|
// • Server lost its DB / was rebuilt
|
|
// • User browsed-data-cleared the device → has no local sub
|
|
// • Fresh device that never subscribed
|
|
//
|
|
// We return the LAST 16 chars of each endpoint URL — that's already
|
|
// unique enough across FCM/Mozilla/APNs and avoids leaking the full
|
|
// endpoint to any other party reading the response. The client
|
|
// matches its own browser PushSubscription.endpoint by suffix; if it
|
|
// has a local sub whose suffix isn't in the response, it re-registers.
|
|
|
|
#[derive(Debug, Serialize)]
|
|
struct PushSubscriptionsResponse {
|
|
/// Last-16-char tails of each registered endpoint URL — enough
|
|
/// for the client to identify whether *its* subscription is in
|
|
/// the set without us echoing the whole 200-char endpoint back.
|
|
endpoint_tails: Vec<String>,
|
|
}
|
|
|
|
async fn push_list_subscriptions(
|
|
State(state): State<AppState>,
|
|
Path(handle): Path<String>,
|
|
headers: axum::http::HeaderMap,
|
|
) -> Result<Json<PushSubscriptionsResponse>, ApiError> {
|
|
validate_handle(&handle)
|
|
.map_err(|e| ApiError::BadRequest(format!("invalid handle: {e}")))?;
|
|
let record = state
|
|
.store
|
|
.lookup(&handle)
|
|
.await?
|
|
.ok_or(ApiError::NotFound)?;
|
|
|
|
let auth = headers
|
|
.get("X-KEZ-Auth")
|
|
.ok_or_else(|| ApiError::Unauthorized("missing X-KEZ-Auth header".into()))?
|
|
.to_str()
|
|
.map_err(|_| ApiError::Unauthorized("non-ASCII X-KEZ-Auth".into()))?;
|
|
// Reuse the subscribe/unsubscribe canonical-msg shape with a
|
|
// dedicated verb so a captured "list" header can't be replayed
|
|
// as a subscribe (or vice versa).
|
|
let (ts_str, sig_hex) = auth
|
|
.split_once(':')
|
|
.ok_or_else(|| ApiError::Unauthorized("X-KEZ-Auth must be <ts>:<sig>".into()))?;
|
|
let ts: i64 = ts_str
|
|
.parse()
|
|
.map_err(|_| ApiError::Unauthorized("auth ts must be a unix timestamp".into()))?;
|
|
if (Utc::now().timestamp() - ts).abs() > 60 {
|
|
return Err(ApiError::Unauthorized("auth header is stale".into()));
|
|
}
|
|
let message = format!("GET\n/v1/push/subscriptions/{handle}\n{ts}");
|
|
kez_core::verify_ed25519_hex(record.primary.value(), message.as_bytes(), sig_hex)
|
|
.map_err(|_| ApiError::Unauthorized("signature did not verify".into()))?;
|
|
|
|
let subs = state.store.list_push_subscriptions(&handle).await?;
|
|
let endpoint_tails: Vec<String> = subs
|
|
.iter()
|
|
.map(|s| {
|
|
let n = s.endpoint.len();
|
|
s.endpoint[n.saturating_sub(16)..].to_string()
|
|
})
|
|
.collect();
|
|
Ok(Json(PushSubscriptionsResponse { endpoint_tails }))
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// GET /.well-known/webfinger — fediverse-style discovery
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct WebfingerQuery {
|
|
resource: String,
|
|
}
|
|
|
|
async fn webfinger(
|
|
State(state): State<AppState>,
|
|
Query(q): Query<WebfingerQuery>,
|
|
) -> Result<impl IntoResponse, ApiError> {
|
|
// Accept `acct:user@server` per RFC 7565.
|
|
let resource = q
|
|
.resource
|
|
.strip_prefix("acct:")
|
|
.ok_or_else(|| ApiError::BadRequest("resource must start with `acct:`".into()))?;
|
|
let (handle, server) = resource
|
|
.split_once('@')
|
|
.ok_or_else(|| ApiError::BadRequest("resource must be `acct:user@server`".into()))?;
|
|
if server != state.config.server {
|
|
return Err(ApiError::NotFound);
|
|
}
|
|
let record = state.store.lookup(handle).await?.ok_or(ApiError::NotFound)?;
|
|
let resp = handle_response(&state.config, &record);
|
|
|
|
let body = json!({
|
|
"subject": format!("acct:{}", resp.fqhn),
|
|
"links": [
|
|
{
|
|
"rel": "https://kez.example/spec/v1/handle",
|
|
"type": "application/json",
|
|
"href": format!("https://{}/v1/u/{}", state.config.server, handle),
|
|
},
|
|
{
|
|
"rel": "https://kez.example/spec/v1/sigchain",
|
|
"type": "application/jsonl",
|
|
"href": resp.sigchain_url,
|
|
}
|
|
]
|
|
});
|
|
Ok((
|
|
StatusCode::OK,
|
|
[(header::CONTENT_TYPE, "application/jrd+json")],
|
|
Json(body),
|
|
))
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// POST /internal/nats/auth — NATS auth callout (stub for v0.1)
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
//
|
|
// In v0.2 this will: parse the NATS auth request JWT, extract the
|
|
// client's nkey, look it up in the handle registry, sign a response
|
|
// JWT granting permissions to `kez.inbox.<pubkey>.>` and reject if
|
|
// not found. For now it returns 501 so misconfigured NATS deployments
|
|
// fail loudly instead of silently allowing everyone.
|
|
|
|
async fn nats_auth_callout(
|
|
State(_state): State<AppState>,
|
|
Json(_body): Json<Value>,
|
|
) -> impl IntoResponse {
|
|
(
|
|
StatusCode::NOT_IMPLEMENTED,
|
|
Json(json!({
|
|
"error": {
|
|
"code": "not_implemented",
|
|
"message": "NATS auth callout will be wired up in v0.2"
|
|
}
|
|
})),
|
|
)
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Placeholder SPA — until we ship the real Svelte build
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
/// Serve a single file from `dir/filename` with explicit no-cache
|
|
/// headers so Cloudflare / browsers always revalidate. Used for the
|
|
/// SPA shell, manifest, and (most critically) the service worker.
|
|
///
|
|
/// Sends BOTH RFC 7234 `Cache-Control` AND legacy `Pragma`/`Expires`
|
|
/// — Cloudflare's edge respects the modern header, but the trio
|
|
/// together gets every intermediate proxy to do the right thing.
|
|
async fn serve_nocache(dir: std::path::PathBuf, filename: &'static str) -> axum::response::Response {
|
|
let path = dir.join(filename);
|
|
let bytes = match tokio::fs::read(&path).await {
|
|
Ok(b) => b,
|
|
Err(e) => {
|
|
tracing::warn!(?path, error = %e, "serve_nocache: read failed");
|
|
return (StatusCode::NOT_FOUND, "not found").into_response();
|
|
}
|
|
};
|
|
let mime = match filename.rsplit_once('.').map(|(_, ext)| ext) {
|
|
Some("js") => "application/javascript; charset=utf-8",
|
|
Some("html") => "text/html; charset=utf-8",
|
|
Some("webmanifest") => "application/manifest+json; charset=utf-8",
|
|
_ => "application/octet-stream",
|
|
};
|
|
(
|
|
StatusCode::OK,
|
|
[
|
|
(header::CONTENT_TYPE, mime),
|
|
(
|
|
header::CACHE_CONTROL,
|
|
"no-store, no-cache, must-revalidate, max-age=0",
|
|
),
|
|
(header::PRAGMA, "no-cache"),
|
|
(header::EXPIRES, "0"),
|
|
],
|
|
bytes,
|
|
)
|
|
.into_response()
|
|
}
|
|
|
|
async fn placeholder_index(State(state): State<AppState>) -> Html<String> {
|
|
Html(format!(
|
|
r#"<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<title>kez-chat — {server}</title>
|
|
<style>
|
|
body {{
|
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
|
|
max-width: 640px;
|
|
margin: 4rem auto;
|
|
padding: 0 1rem;
|
|
color: #222;
|
|
line-height: 1.5;
|
|
}}
|
|
h1 {{ margin-bottom: 0.5rem; }}
|
|
code {{ background: #f3f3f3; padding: 0.15rem 0.3rem; border-radius: 3px; }}
|
|
.api {{ font-family: ui-monospace, "SF Mono", Menlo, monospace; font-size: 0.9rem; }}
|
|
.api li {{ margin: 0.3rem 0; }}
|
|
footer {{ margin-top: 3rem; color: #888; font-size: 0.85rem; }}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<h1>kez-chat</h1>
|
|
<p>Home server for <code>username@{server}</code>.</p>
|
|
|
|
<p>The Svelte web app isn't built yet — this is the placeholder. The HTTP API
|
|
is up though:</p>
|
|
|
|
<ul class="api">
|
|
<li><code>GET /v1/healthz</code></li>
|
|
<li><code>GET /v1/u/<handle></code></li>
|
|
<li><code>POST /v1/register</code></li>
|
|
<li><code>GET /.well-known/webfinger?resource=acct:user@{server}</code></li>
|
|
</ul>
|
|
|
|
<p>See <a href="https://git.ptud.biz/DukeInc/Kez">the project repo</a>
|
|
for the design doc and progress.</p>
|
|
|
|
<footer>kez-chat-server v{version} — server: <code>{server}</code></footer>
|
|
</body>
|
|
</html>"#,
|
|
server = state.config.server,
|
|
version = env!("CARGO_PKG_VERSION"),
|
|
))
|
|
}
|
|
|
|
|
|
/// Forward a sigchain request to the internal sig-server, same path and
|
|
/// body. The sig-server sits on the private deploy network (compose
|
|
/// service `sig-server`), so this is how browsers and external
|
|
/// verifiers reach it via the one public origin. It re-validates every
|
|
/// event itself (tag, seq, prev, signature), so no checks here.
|
|
async fn sigchain_proxy(
|
|
State(state): State<AppState>,
|
|
method: axum::http::Method,
|
|
uri: axum::http::Uri,
|
|
headers: axum::http::HeaderMap,
|
|
body: axum::body::Bytes,
|
|
) -> axum::response::Response {
|
|
static CLIENT: std::sync::OnceLock<reqwest::Client> = std::sync::OnceLock::new();
|
|
let client = CLIENT.get_or_init(|| {
|
|
reqwest::Client::builder()
|
|
.timeout(std::time::Duration::from_secs(15))
|
|
.build()
|
|
.expect("reqwest client")
|
|
});
|
|
let url = format!(
|
|
"{}{}",
|
|
state.config.sig_server_url.trim_end_matches('/'),
|
|
uri.path()
|
|
);
|
|
let mut req = client.request(method, &url).body(body);
|
|
if let Some(ct) = headers.get(header::CONTENT_TYPE) {
|
|
req = req.header(header::CONTENT_TYPE, ct);
|
|
}
|
|
match req.send().await {
|
|
Ok(resp) => {
|
|
let status = resp.status();
|
|
let ct = resp.headers().get(header::CONTENT_TYPE).cloned();
|
|
match resp.bytes().await {
|
|
Ok(bytes) => {
|
|
let mut out = (status, bytes).into_response();
|
|
if let Some(ct) = ct {
|
|
out.headers_mut().insert(header::CONTENT_TYPE, ct);
|
|
}
|
|
out
|
|
}
|
|
Err(e) => sig_server_unavailable(e),
|
|
}
|
|
}
|
|
Err(e) => sig_server_unavailable(e),
|
|
}
|
|
}
|
|
|
|
fn sig_server_unavailable(e: reqwest::Error) -> axum::response::Response {
|
|
tracing::warn!(error = %e, "sig-server proxy failed");
|
|
(
|
|
StatusCode::BAD_GATEWAY,
|
|
Json(json!({"error": {"code": "sig_server_unavailable", "message": "sigchain store unreachable"}})),
|
|
)
|
|
.into_response()
|
|
}
|