snapshot of https://github.com/mountain-loop/yaak.git (main, 2026-09-23; blobs > 131072B omitted)
Some checks failed
Lint and Test / Lint/Test (push) Has been cancelled
Generate Sponsors README / deploy (push) Has been skipped

This commit is contained in:
upstream-mirror
2026-09-23 17:39:41 +05:00
commit 906fb231cc
1544 changed files with 164131 additions and 0 deletions

View File

@@ -0,0 +1,25 @@
[package]
name = "yaak-playground"
version = "0.1.0"
edition = "2024"
publish = false
description = "A small, disposable API for trying Yaak against (yaak.run)"
[[bin]]
name = "yaak-playground"
path = "src/main.rs"
[dependencies]
axum = "0.7"
bytes = "1.11.1"
clap = { version = "4.5", features = ["derive", "env"] }
env_logger = "0.11"
log = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
tokio = { workspace = true, features = ["rt-multi-thread", "macros", "signal", "net"] }
tower-http = { version = "0.6", features = ["cors"] }
uuid = { version = "1", features = ["v4"] }
[dev-dependencies]
tower = { version = "0.5", features = ["util"] }

View File

@@ -0,0 +1,21 @@
# yaak-playground
The API behind [yaak.run](https://yaak.run), for trying Yaak without bringing your own.
- Users and todos are fixed sample data.
- Posts can be created, changed, and deleted. Each client IP gets its own copy, which goes back
to the sample data `YAAK_PLAYGROUND_RESET_AFTER_SECS` after its first write.
- `POST /auth/token` returns a bearer token for `GET /auth/me`. `POST /auth/session` sets a
session cookie for `GET /auth/session`. Any username with the password `yaak` logs in.
- `GET /` lists every endpoint and `GET /openapi.json` describes them.
Nothing is written to disk, so a restart resets everything.
```sh
cargo run -p yaak-playground # http://127.0.0.1:9228
```
Run `yaak-playground --help` for the limits. Behind a load balancer that sets
`X-Forwarded-For`, set `YAAK_PLAYGROUND_TRUST_FORWARDED_FOR=true`.
The image is `Dockerfile.playground` at the repo root.

View File

@@ -0,0 +1,185 @@
use crate::AppState;
use crate::data::{self, User};
use crate::error::{ApiError, ApiResult, parse_json};
use axum::extract::State;
use axum::extract::rejection::BytesRejection;
use axum::http::{HeaderMap, HeaderValue, StatusCode, header};
use axum::response::{IntoResponse, Json, Response};
use bytes::Bytes;
use serde::Deserialize;
use serde_json::{Value, json};
use std::collections::HashMap;
use std::sync::Mutex;
use std::time::{Duration, Instant};
pub const PASSWORD: &str = "yaak";
const SESSION_COOKIE: &str = "session";
const TTL: Duration = Duration::from_secs(60 * 60);
const MAX_CREDENTIALS: usize = 10_000;
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum Kind {
Bearer,
Session,
}
/// Issued tokens and session IDs, kept apart so a token can't be sent as a cookie or the
/// other way around.
pub struct Credentials {
entries: Mutex<HashMap<String, Entry>>,
}
struct Entry {
user_id: u64,
kind: Kind,
expires: Instant,
}
impl Credentials {
pub fn new() -> Self {
Self { entries: Mutex::new(HashMap::new()) }
}
fn issue(&self, user_id: u64, kind: Kind) -> String {
let value = uuid::Uuid::new_v4().simple().to_string();
let now = Instant::now();
let mut entries = self.entries.lock().unwrap_or_else(|e| e.into_inner());
if entries.len() >= MAX_CREDENTIALS {
entries.retain(|_, e| e.expires > now);
}
if entries.len() >= MAX_CREDENTIALS
&& let Some(oldest) =
entries.iter().min_by_key(|(_, e)| e.expires).map(|(k, _)| k.clone())
{
entries.remove(&oldest);
}
entries.insert(value.clone(), Entry { user_id, kind, expires: now + TTL });
value
}
fn lookup(&self, value: &str, kind: Kind) -> Option<&'static User> {
let entries = self.entries.lock().unwrap_or_else(|e| e.into_inner());
let entry = entries.get(value)?;
if entry.kind != kind || entry.expires <= Instant::now() {
return None;
}
data::user(entry.user_id)
}
fn revoke(&self, value: &str) {
self.entries.lock().unwrap_or_else(|e| e.into_inner()).remove(value);
}
}
#[derive(Deserialize)]
struct Login {
username: String,
password: String,
}
fn log_in(body: Result<Bytes, BytesRejection>) -> ApiResult<&'static User> {
let login: Login = parse_json(body)?;
data::user_by_username(&login.username)
.filter(|_| login.password == PASSWORD)
.ok_or_else(|| ApiError::unauthorized("Invalid username or password", None))
}
pub async fn create_token(
State(state): State<AppState>,
body: Result<Bytes, BytesRejection>,
) -> ApiResult<Json<Value>> {
let user = log_in(body)?;
let token = state.credentials.issue(user.id, Kind::Bearer);
Ok(Json(json!({
"accessToken": token,
"tokenType": "Bearer",
"expiresIn": TTL.as_secs(),
})))
}
pub async fn me(
State(state): State<AppState>,
headers: HeaderMap,
) -> ApiResult<Json<&'static User>> {
let Some(authorization) = headers.get(header::AUTHORIZATION).and_then(|v| v.to_str().ok())
else {
return Err(ApiError::unauthorized("Missing Authorization header", Some("Bearer")));
};
let token = match authorization.split_once(' ') {
Some((scheme, token)) if scheme.eq_ignore_ascii_case("bearer") => token.trim(),
_ => {
return Err(ApiError::unauthorized(
"Authorization header must be \"Bearer <token>\"",
Some("Bearer"),
));
}
};
state.credentials.lookup(token, Kind::Bearer).map(Json).ok_or_else(|| {
ApiError::unauthorized("Invalid or expired token", Some("Bearer error=\"invalid_token\""))
})
}
pub async fn create_session(
State(state): State<AppState>,
headers: HeaderMap,
body: Result<Bytes, BytesRejection>,
) -> ApiResult<Response> {
let user = log_in(body)?;
let id = state.credentials.issue(user.id, Kind::Session);
let cookie = format!(
"{SESSION_COOKIE}={id}; Path=/; Max-Age={}; HttpOnly; SameSite=Lax{}",
TTL.as_secs(),
secure_attribute(&headers)
);
let mut res = Json(user).into_response();
res.headers_mut().insert(header::SET_COOKIE, HeaderValue::from_str(&cookie).expect("ascii"));
Ok(res)
}
pub async fn session(
State(state): State<AppState>,
headers: HeaderMap,
) -> ApiResult<Json<&'static User>> {
let Some(id) = session_cookie(&headers) else {
return Err(ApiError::unauthorized("Missing session cookie", None));
};
state
.credentials
.lookup(id, Kind::Session)
.map(Json)
.ok_or_else(|| ApiError::unauthorized("Invalid or expired session", None))
}
pub async fn delete_session(State(state): State<AppState>, headers: HeaderMap) -> Response {
if let Some(id) = session_cookie(&headers) {
state.credentials.revoke(id);
}
let cookie = format!(
"{SESSION_COOKIE}=; Path=/; Max-Age=0; HttpOnly; SameSite=Lax{}",
secure_attribute(&headers)
);
let mut res = StatusCode::NO_CONTENT.into_response();
res.headers_mut().insert(header::SET_COOKIE, HeaderValue::from_str(&cookie).expect("ascii"));
res
}
fn session_cookie(headers: &HeaderMap) -> Option<&str> {
headers
.get_all(header::COOKIE)
.iter()
.filter_map(|v| v.to_str().ok())
.flat_map(|v| v.split(';'))
.filter_map(|pair| pair.trim().split_once('='))
.find(|(name, _)| *name == SESSION_COOKIE)
.map(|(_, value)| value)
}
/// `Secure` only when the request reached the proxy over HTTPS, so the cookie still works
/// against a local instance on plain HTTP.
fn secure_attribute(headers: &HeaderMap) -> &'static str {
let https = headers
.get("x-forwarded-proto")
.and_then(|v| v.to_str().ok())
.is_some_and(|v| v.eq_ignore_ascii_case("https"));
if https { "; Secure" } else { "" }
}

View File

@@ -0,0 +1,59 @@
use clap::Parser;
use std::net::{SocketAddr, ToSocketAddrs};
/// A small, disposable API for trying Yaak against.
#[derive(Parser, Debug, Clone)]
#[command(name = "yaak-playground", version, about, long_about = None)]
pub struct Config {
/// Interface to listen on. 0.0.0.0 inside a container.
#[arg(long, env = "HOST", default_value = "127.0.0.1")]
pub host: String,
#[arg(long, env = "PORT", default_value_t = 9228)]
pub port: u16,
/// Requests allowed per client IP per minute. 0 disables the limit.
#[arg(
long,
env = "YAAK_PLAYGROUND_RATE_LIMIT_PER_MINUTE",
default_value_t = 300
)]
pub rate_limit_per_minute: u32,
/// Take the client IP from `X-Forwarded-For` (first hop) instead of the socket. Only behind
/// a load balancer that sets the header.
#[arg(
long,
env = "YAAK_PLAYGROUND_TRUST_FORWARDED_FOR",
default_value_t = false
)]
pub trust_forwarded_for: bool,
/// Seconds after a client's first write before its posts go back to the sample data.
#[arg(long, env = "YAAK_PLAYGROUND_RESET_AFTER_SECS", default_value_t = 3600)]
pub reset_after_secs: u64,
/// Clients whose writes are kept at once. The oldest is reset early to make room.
#[arg(long, env = "YAAK_PLAYGROUND_MAX_CLIENTS", default_value_t = 2000)]
pub max_clients: usize,
#[arg(
long,
env = "YAAK_PLAYGROUND_MAX_POSTS_PER_CLIENT",
default_value_t = 100
)]
pub max_posts_per_client: usize,
#[arg(long, env = "YAAK_PLAYGROUND_MAX_REQUEST_BYTES", default_value_t = 64 * 1024)]
pub max_request_bytes: usize,
}
impl Config {
pub fn listen_addr(&self) -> Result<SocketAddr, String> {
(self.host.as_str(), self.port)
.to_socket_addrs()
.map_err(|e| format!("Invalid HOST {:?}: {e}", self.host))?
.next()
.ok_or_else(|| format!("HOST {:?} resolved to no address", self.host))
}
}

View File

@@ -0,0 +1,59 @@
use serde::{Deserialize, Serialize};
use std::sync::LazyLock;
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct User {
pub id: u64,
pub name: String,
pub username: String,
pub email: String,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Post {
pub id: u64,
pub user_id: u64,
pub title: String,
pub body: String,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Todo {
pub id: u64,
pub user_id: u64,
pub title: String,
pub completed: bool,
}
#[derive(Deserialize)]
struct Seed {
users: Vec<User>,
posts: Vec<Post>,
todos: Vec<Todo>,
}
static SEED: LazyLock<Seed> =
LazyLock::new(|| serde_json::from_str(include_str!("seed.json")).expect("valid seed.json"));
pub fn users() -> &'static [User] {
&SEED.users
}
pub fn posts() -> &'static [Post] {
&SEED.posts
}
pub fn todos() -> &'static [Todo] {
&SEED.todos
}
pub fn user(id: u64) -> Option<&'static User> {
users().iter().find(|u| u.id == id)
}
pub fn user_by_username(username: &str) -> Option<&'static User> {
users().iter().find(|u| u.username == username)
}

View File

@@ -0,0 +1,57 @@
use axum::extract::rejection::BytesRejection;
use axum::http::{HeaderValue, StatusCode, header};
use axum::response::{IntoResponse, Json, Response};
use bytes::Bytes;
use serde::de::DeserializeOwned;
use serde_json::json;
pub type ApiResult<T> = Result<T, ApiError>;
pub struct ApiError {
status: StatusCode,
message: String,
challenge: Option<&'static str>,
}
impl ApiError {
pub fn new(status: StatusCode, message: impl Into<String>) -> Self {
Self { status, message: message.into(), challenge: None }
}
pub fn bad_request(message: impl Into<String>) -> Self {
Self::new(StatusCode::BAD_REQUEST, message)
}
pub fn not_found(message: impl Into<String>) -> Self {
Self::new(StatusCode::NOT_FOUND, message)
}
/// `challenge` becomes the `WWW-Authenticate` header.
pub fn unauthorized(message: impl Into<String>, challenge: Option<&'static str>) -> Self {
Self { status: StatusCode::UNAUTHORIZED, message: message.into(), challenge }
}
}
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
let mut res = (self.status, Json(json!({ "error": self.message }))).into_response();
if let Some(challenge) = self.challenge {
res.headers_mut().insert(header::WWW_AUTHENTICATE, HeaderValue::from_static(challenge));
}
res
}
}
pub fn parse_id(raw: &str, noun: &str) -> ApiResult<u64> {
raw.parse().map_err(|_| ApiError::not_found(format!("No {noun} with ID {raw}")))
}
/// Taken as a `Result` so an oversized body gets a JSON error like everything else.
pub fn parse_json<T: DeserializeOwned>(body: Result<Bytes, BytesRejection>) -> ApiResult<T> {
let body = body.map_err(|e| ApiError::new(e.status(), e.body_text()))?;
if body.is_empty() {
return Err(ApiError::bad_request("Request body must be JSON"));
}
serde_json::from_slice(&body)
.map_err(|e| ApiError::bad_request(format!("Request body is not valid: {e}")))
}

View File

@@ -0,0 +1,170 @@
//! yaak-playground: the API behind yaak.run, for trying Yaak without bringing your own.
//!
//! Reads come from fixed sample data. Writes are kept per client IP and reset after a while,
//! so nothing one caller sends is ever served to another. Nothing touches disk.
mod auth;
mod config;
mod data;
mod error;
mod limits;
mod resources;
mod store;
use axum::Router;
use axum::extract::{ConnectInfo, DefaultBodyLimit, Request, State};
use axum::http::{HeaderMap, HeaderValue, Method, StatusCode, header};
use axum::middleware::{self, Next};
use axum::response::{IntoResponse, Json, Response};
use axum::routing::{get, post};
pub use config::Config;
use error::ApiError;
use limits::RateLimiter;
use serde_json::{Value, json};
use std::net::{IpAddr, SocketAddr};
use std::sync::Arc;
use std::time::Duration;
use store::PostStore;
use tower_http::cors::{AllowOrigin, CorsLayer};
const OPENAPI: &str = include_str!("openapi.json");
#[derive(Clone)]
struct AppState {
config: Arc<Config>,
rate_limiter: Arc<RateLimiter>,
posts: Arc<PostStore>,
credentials: Arc<auth::Credentials>,
}
#[derive(Clone, Copy)]
struct ClientIp(IpAddr);
/// Serve with `into_make_service_with_connect_info::<SocketAddr>()`; the client's address
/// keys both the rate limit and its private copy of the posts.
pub fn router(config: Config) -> Router {
let state = AppState {
rate_limiter: Arc::new(RateLimiter::new(config.rate_limit_per_minute)),
posts: Arc::new(PostStore::new(
Duration::from_secs(config.reset_after_secs),
config.max_clients,
config.max_posts_per_client,
)),
credentials: Arc::new(auth::Credentials::new()),
config: Arc::new(config),
};
let cors = CorsLayer::new()
.allow_origin(AllowOrigin::any())
.allow_methods([
Method::GET,
Method::POST,
Method::PUT,
Method::PATCH,
Method::DELETE,
Method::OPTIONS,
])
.allow_headers([header::CONTENT_TYPE, header::AUTHORIZATION])
.expose_headers([
header::LOCATION,
header::RETRY_AFTER,
header::WWW_AUTHENTICATE,
]);
Router::new()
.route("/", get(index))
.route("/health", get(health))
.route("/openapi.json", get(openapi))
.route("/users", get(resources::list_users))
.route("/users/:id", get(resources::get_user))
.route("/users/:id/todos", get(resources::list_user_todos))
.route("/todos", get(resources::list_todos))
.route("/posts", get(resources::list_posts).post(resources::create_post))
.route(
"/posts/:id",
get(resources::get_post)
.put(resources::replace_post)
.patch(resources::update_post)
.delete(resources::delete_post),
)
.route("/auth/token", post(auth::create_token))
.route("/auth/me", get(auth::me))
.route(
"/auth/session",
post(auth::create_session).get(auth::session).delete(auth::delete_session),
)
.fallback(not_found)
.layer(middleware::from_fn_with_state(state.clone(), identify_client))
.layer(DefaultBodyLimit::max(state.config.max_request_bytes))
.layer(cors)
.with_state(state)
}
async fn identify_client(
State(state): State<AppState>,
ConnectInfo(peer): ConnectInfo<SocketAddr>,
mut req: Request,
next: Next,
) -> Response {
let ip = client_ip(&state.config, req.headers(), peer);
if let Err(wait) = state.rate_limiter.check(ip) {
let secs = wait.as_secs().max(1);
let mut res = ApiError::new(
StatusCode::TOO_MANY_REQUESTS,
format!("Rate limit reached. Try again in {secs}s"),
)
.into_response();
res.headers_mut().insert(header::RETRY_AFTER, HeaderValue::from(secs));
return res;
}
req.extensions_mut().insert(ClientIp(ip));
next.run(req).await
}
fn client_ip(config: &Config, headers: &HeaderMap, peer: SocketAddr) -> IpAddr {
if config.trust_forwarded_for
&& let Some(forwarded) = headers.get("x-forwarded-for").and_then(|v| v.to_str().ok())
&& let Some(first) = forwarded.split(',').next()
&& let Ok(ip) = first.trim().parse::<IpAddr>()
{
return ip;
}
peer.ip()
}
async fn index(State(state): State<AppState>) -> Json<Value> {
let spec: Value = serde_json::from_str(OPENAPI).expect("valid openapi.json");
Json(json!({
"name": "Yaak Playground",
"description": "A small API for trying out Yaak. Posts you create, change, or delete are only visible to you, and go back to the sample data after a while.",
"resetAfterSeconds": state.config.reset_after_secs,
"login": { "username": "ada", "password": auth::PASSWORD },
"openapi": "/openapi.json",
"endpoints": operations(&spec),
}))
}
/// Every `METHOD /path` the spec documents.
fn operations(spec: &Value) -> Vec<String> {
let mut ops = Vec::new();
for (path, item) in spec["paths"].as_object().into_iter().flatten() {
for method in ["get", "post", "put", "patch", "delete"] {
if item.get(method).is_some() {
ops.push(format!("{} {path}", method.to_uppercase()));
}
}
}
ops
}
async fn health() -> Json<Value> {
Json(json!({ "ok": true, "version": env!("CARGO_PKG_VERSION") }))
}
async fn openapi() -> Response {
([(header::CONTENT_TYPE, "application/json")], OPENAPI).into_response()
}
async fn not_found() -> ApiError {
ApiError::not_found("Not found. GET / lists every endpoint")
}

View File

@@ -0,0 +1,50 @@
use std::collections::HashMap;
use std::net::IpAddr;
use std::sync::Mutex;
use std::time::{Duration, Instant};
/// One token bucket per client IP, refilled continuously.
pub struct RateLimiter {
per_minute: u32,
buckets: Mutex<HashMap<IpAddr, Bucket>>,
}
struct Bucket {
tokens: f64,
last: Instant,
}
impl RateLimiter {
/// `per_minute == 0` disables limiting.
pub fn new(per_minute: u32) -> Self {
Self { per_minute, buckets: Mutex::new(HashMap::new()) }
}
/// Take one token for `client`, or say how long until one is available.
pub fn check(&self, client: IpAddr) -> Result<(), Duration> {
if self.per_minute == 0 {
return Ok(());
}
let capacity = self.per_minute as f64;
let per_second = capacity / 60.0;
let now = Instant::now();
let mut buckets = self.buckets.lock().unwrap_or_else(|e| e.into_inner());
if buckets.len() > 1024 {
buckets.retain(|_, b| now.duration_since(b.last).as_secs_f64() * per_second < capacity);
}
let bucket = buckets.entry(client).or_insert(Bucket { tokens: capacity, last: now });
let elapsed = now.duration_since(bucket.last).as_secs_f64();
bucket.tokens = (bucket.tokens + elapsed * per_second).min(capacity);
bucket.last = now;
if bucket.tokens >= 1.0 {
bucket.tokens -= 1.0;
Ok(())
} else {
let wait = (1.0 - bucket.tokens) / per_second;
Err(Duration::from_secs_f64(wait.max(0.001)))
}
}
}

View File

@@ -0,0 +1,29 @@
use clap::Parser;
use log::info;
use std::net::SocketAddr;
use yaak_playground::{Config, router};
#[tokio::main]
async fn main() {
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
let config = Config::parse();
let bind = config.listen_addr().unwrap_or_else(|e| {
eprintln!("{e}");
std::process::exit(1);
});
let app = router(config);
let listener = tokio::net::TcpListener::bind(bind).await.unwrap_or_else(|e| {
eprintln!("Failed to bind {bind}: {e}");
std::process::exit(1);
});
info!("yaak-playground listening on http://{bind}");
axum::serve(listener, app.into_make_service_with_connect_info::<SocketAddr>())
.with_graceful_shutdown(async {
let _ = tokio::signal::ctrl_c().await;
info!("Shutting down");
})
.await
.expect("server error");
}

View File

@@ -0,0 +1,396 @@
{
"openapi": "3.1.0",
"info": {
"title": "Yaak Playground",
"version": "1.0.0",
"description": "A small API for trying out Yaak. Users and todos are fixed sample data. Posts you create, change, or delete are only visible to you, and go back to the sample data after a while.\n\nLog in with any username (like `ada`) and the password `yaak`."
},
"servers": [{ "url": "https://yaak.run" }],
"tags": [
{ "name": "Users" },
{ "name": "Posts" },
{ "name": "Todos" },
{ "name": "Auth", "description": "Two ways to stay logged in: a bearer token you send yourself, or a session cookie the cookie jar sends for you." }
],
"paths": {
"/users": {
"get": {
"tags": ["Users"],
"operationId": "listUsers",
"summary": "List users",
"responses": {
"200": {
"description": "Every user",
"content": {
"application/json": {
"schema": { "type": "array", "items": { "$ref": "#/components/schemas/User" } }
}
}
}
}
}
},
"/users/{id}": {
"get": {
"tags": ["Users"],
"operationId": "getUser",
"summary": "Get a user",
"parameters": [{ "$ref": "#/components/parameters/Id" }],
"responses": {
"200": {
"description": "The user",
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/User" } } }
},
"404": { "$ref": "#/components/responses/NotFound" }
}
}
},
"/users/{id}/todos": {
"get": {
"tags": ["Users", "Todos"],
"operationId": "listUserTodos",
"summary": "List a user's todos",
"parameters": [
{ "$ref": "#/components/parameters/Id" },
{ "$ref": "#/components/parameters/Completed" },
{ "$ref": "#/components/parameters/Limit" }
],
"responses": {
"200": {
"description": "The user's todos",
"content": {
"application/json": {
"schema": { "type": "array", "items": { "$ref": "#/components/schemas/Todo" } }
}
}
},
"400": { "$ref": "#/components/responses/BadRequest" },
"404": { "$ref": "#/components/responses/NotFound" }
}
}
},
"/todos": {
"get": {
"tags": ["Todos"],
"operationId": "listTodos",
"summary": "List todos",
"parameters": [
{ "$ref": "#/components/parameters/UserId" },
{ "$ref": "#/components/parameters/Completed" },
{ "$ref": "#/components/parameters/Limit" }
],
"responses": {
"200": {
"description": "Matching todos",
"content": {
"application/json": {
"schema": { "type": "array", "items": { "$ref": "#/components/schemas/Todo" } }
}
}
},
"400": { "$ref": "#/components/responses/BadRequest" }
}
}
},
"/posts": {
"get": {
"tags": ["Posts"],
"operationId": "listPosts",
"summary": "List posts",
"parameters": [
{ "$ref": "#/components/parameters/UserId" },
{ "$ref": "#/components/parameters/Limit" }
],
"responses": {
"200": {
"description": "Matching posts",
"content": {
"application/json": {
"schema": { "type": "array", "items": { "$ref": "#/components/schemas/Post" } }
}
}
},
"400": { "$ref": "#/components/responses/BadRequest" }
}
},
"post": {
"tags": ["Posts"],
"operationId": "createPost",
"summary": "Create a post",
"requestBody": {
"required": true,
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/PostInput" } } }
},
"responses": {
"201": {
"description": "The new post",
"headers": {
"Location": { "description": "Where to fetch the new post", "schema": { "type": "string" } }
},
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/Post" } } }
},
"400": { "$ref": "#/components/responses/BadRequest" },
"409": {
"description": "You already have the most posts allowed",
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
}
}
}
},
"/posts/{id}": {
"get": {
"tags": ["Posts"],
"operationId": "getPost",
"summary": "Get a post",
"parameters": [{ "$ref": "#/components/parameters/Id" }],
"responses": {
"200": {
"description": "The post",
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/Post" } } }
},
"404": { "$ref": "#/components/responses/NotFound" }
}
},
"put": {
"tags": ["Posts"],
"operationId": "replacePost",
"summary": "Replace a post",
"parameters": [{ "$ref": "#/components/parameters/Id" }],
"requestBody": {
"required": true,
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/PostInput" } } }
},
"responses": {
"200": {
"description": "The updated post",
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/Post" } } }
},
"400": { "$ref": "#/components/responses/BadRequest" },
"404": { "$ref": "#/components/responses/NotFound" }
}
},
"patch": {
"tags": ["Posts"],
"operationId": "updatePost",
"summary": "Update part of a post",
"parameters": [{ "$ref": "#/components/parameters/Id" }],
"requestBody": {
"required": true,
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/PostPatch" } } }
},
"responses": {
"200": {
"description": "The updated post",
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/Post" } } }
},
"400": { "$ref": "#/components/responses/BadRequest" },
"404": { "$ref": "#/components/responses/NotFound" }
}
},
"delete": {
"tags": ["Posts"],
"operationId": "deletePost",
"summary": "Delete a post",
"parameters": [{ "$ref": "#/components/parameters/Id" }],
"responses": {
"204": { "description": "Deleted" },
"404": { "$ref": "#/components/responses/NotFound" }
}
}
},
"/auth/token": {
"post": {
"tags": ["Auth"],
"operationId": "createToken",
"summary": "Log in for a bearer token",
"requestBody": {
"required": true,
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/Login" } } }
},
"responses": {
"200": {
"description": "A token to send as `Authorization: Bearer <token>`",
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/Token" } } }
},
"400": { "$ref": "#/components/responses/BadRequest" },
"401": { "$ref": "#/components/responses/Unauthorized" }
}
}
},
"/auth/me": {
"get": {
"tags": ["Auth"],
"operationId": "getMe",
"summary": "Get the user a bearer token belongs to",
"security": [{ "bearerAuth": [] }],
"responses": {
"200": {
"description": "The logged-in user",
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/User" } } }
},
"401": { "$ref": "#/components/responses/Unauthorized" }
}
}
},
"/auth/session": {
"post": {
"tags": ["Auth"],
"operationId": "createSession",
"summary": "Log in with a session cookie",
"requestBody": {
"required": true,
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/Login" } } }
},
"responses": {
"200": {
"description": "The logged-in user. The session cookie comes back in `Set-Cookie`.",
"headers": {
"Set-Cookie": { "description": "The `session` cookie", "schema": { "type": "string" } }
},
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/User" } } }
},
"400": { "$ref": "#/components/responses/BadRequest" },
"401": { "$ref": "#/components/responses/Unauthorized" }
}
},
"get": {
"tags": ["Auth"],
"operationId": "getSession",
"summary": "Get the user a session cookie belongs to",
"security": [{ "cookieAuth": [] }],
"responses": {
"200": {
"description": "The logged-in user",
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/User" } } }
},
"401": { "$ref": "#/components/responses/Unauthorized" }
}
},
"delete": {
"tags": ["Auth"],
"operationId": "deleteSession",
"summary": "Log out",
"responses": {
"204": { "description": "Logged out, and the cookie is cleared" }
}
}
}
},
"components": {
"parameters": {
"Id": {
"name": "id",
"in": "path",
"required": true,
"schema": { "type": "integer", "minimum": 1 },
"example": 1
},
"UserId": {
"name": "userId",
"in": "query",
"description": "Only return items belonging to this user",
"schema": { "type": "integer", "minimum": 1 }
},
"Completed": {
"name": "completed",
"in": "query",
"schema": { "type": "boolean" }
},
"Limit": {
"name": "limit",
"in": "query",
"description": "Return at most this many items",
"schema": { "type": "integer", "minimum": 0 }
}
},
"responses": {
"BadRequest": {
"description": "Something in the request is invalid",
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
},
"NotFound": {
"description": "Nothing has that ID",
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
},
"Unauthorized": {
"description": "Missing or invalid credentials",
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
}
},
"schemas": {
"User": {
"type": "object",
"required": ["id", "name", "username", "email"],
"properties": {
"id": { "type": "integer", "example": 1 },
"name": { "type": "string", "example": "Ada Lovelace" },
"username": { "type": "string", "example": "ada" },
"email": { "type": "string", "format": "email", "example": "ada@example.com" }
}
},
"Post": {
"type": "object",
"required": ["id", "userId", "title", "body"],
"properties": {
"id": { "type": "integer", "example": 1 },
"userId": { "type": "integer", "example": 1 },
"title": { "type": "string" },
"body": { "type": "string" }
}
},
"PostInput": {
"type": "object",
"required": ["userId", "title", "body"],
"properties": {
"userId": { "type": "integer", "example": 1 },
"title": { "type": "string", "maxLength": 200, "example": "Hello from Yaak" },
"body": { "type": "string", "maxLength": 2000, "example": "My first post" }
}
},
"PostPatch": {
"type": "object",
"properties": {
"userId": { "type": "integer" },
"title": { "type": "string", "maxLength": 200 },
"body": { "type": "string", "maxLength": 2000 }
}
},
"Todo": {
"type": "object",
"required": ["id", "userId", "title", "completed"],
"properties": {
"id": { "type": "integer", "example": 1 },
"userId": { "type": "integer", "example": 1 },
"title": { "type": "string" },
"completed": { "type": "boolean" }
}
},
"Login": {
"type": "object",
"required": ["username", "password"],
"properties": {
"username": { "type": "string", "example": "ada" },
"password": { "type": "string", "example": "yaak" }
}
},
"Token": {
"type": "object",
"required": ["accessToken", "tokenType", "expiresIn"],
"properties": {
"accessToken": { "type": "string" },
"tokenType": { "type": "string", "const": "Bearer" },
"expiresIn": { "type": "integer", "description": "Seconds until the token expires", "example": 3600 }
}
},
"Error": {
"type": "object",
"required": ["error"],
"properties": { "error": { "type": "string" } }
}
},
"securitySchemes": {
"bearerAuth": { "type": "http", "scheme": "bearer" },
"cookieAuth": { "type": "apiKey", "in": "cookie", "name": "session" }
}
}
}

View File

@@ -0,0 +1,229 @@
use crate::data::{self, Post, Todo, User};
use crate::error::{ApiError, ApiResult, parse_id, parse_json};
use crate::{AppState, ClientIp};
use axum::extract::rejection::BytesRejection;
use axum::extract::{Extension, Path, Query, State};
use axum::http::{HeaderValue, StatusCode, header};
use axum::response::{IntoResponse, Json, Response};
use bytes::Bytes;
use serde::Deserialize;
use std::collections::HashMap;
const MAX_TITLE_CHARS: usize = 200;
const MAX_BODY_CHARS: usize = 2000;
type Params = Query<HashMap<String, String>>;
struct Filters {
user_id: Option<u64>,
completed: Option<bool>,
limit: Option<usize>,
}
fn filters(params: &HashMap<String, String>) -> ApiResult<Filters> {
fn parse<T: std::str::FromStr>(
params: &HashMap<String, String>,
name: &str,
expected: &str,
) -> ApiResult<Option<T>> {
params
.get(name)
.map(|v| {
v.parse().map_err(|_| {
ApiError::bad_request(format!("Query parameter `{name}` must be {expected}"))
})
})
.transpose()
}
Ok(Filters {
user_id: parse(params, "userId", "a user ID")?,
completed: parse(params, "completed", "true or false")?,
limit: parse(params, "limit", "a number")?,
})
}
pub async fn list_users() -> Json<&'static [User]> {
Json(data::users())
}
pub async fn get_user(Path(id): Path<String>) -> ApiResult<Json<&'static User>> {
let user_id = parse_id(&id, "user")?;
data::user(user_id)
.map(Json)
.ok_or_else(|| ApiError::not_found(format!("No user with ID {id}")))
}
pub async fn list_todos(Query(params): Params) -> ApiResult<Json<Vec<&'static Todo>>> {
let f = filters(&params)?;
Ok(Json(select_todos(f.user_id, f.completed, f.limit)))
}
pub async fn list_user_todos(
Path(id): Path<String>,
Query(params): Params,
) -> ApiResult<Json<Vec<&'static Todo>>> {
let user = get_user(Path(id)).await?;
let f = filters(&params)?;
Ok(Json(select_todos(Some(user.id), f.completed, f.limit)))
}
fn select_todos(
user_id: Option<u64>,
completed: Option<bool>,
limit: Option<usize>,
) -> Vec<&'static Todo> {
data::todos()
.iter()
.filter(|t| user_id.is_none_or(|id| t.user_id == id))
.filter(|t| completed.is_none_or(|c| t.completed == c))
.take(limit.unwrap_or(usize::MAX))
.collect()
}
pub async fn list_posts(
State(state): State<AppState>,
Extension(ClientIp(ip)): Extension<ClientIp>,
Query(params): Params,
) -> ApiResult<Json<Vec<Post>>> {
let f = filters(&params)?;
let posts = state.posts.read(ip, |posts| {
posts
.iter()
.filter(|p| f.user_id.is_none_or(|id| p.user_id == id))
.take(f.limit.unwrap_or(usize::MAX))
.cloned()
.collect()
});
Ok(Json(posts))
}
pub async fn get_post(
State(state): State<AppState>,
Extension(ClientIp(ip)): Extension<ClientIp>,
Path(id): Path<String>,
) -> ApiResult<Json<Post>> {
let post_id = parse_id(&id, "post")?;
state
.posts
.read(ip, |posts| posts.iter().find(|p| p.id == post_id).cloned())
.map(Json)
.ok_or_else(|| post_not_found(&id))
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct NewPost {
user_id: u64,
title: String,
body: String,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct PostPatch {
user_id: Option<u64>,
title: Option<String>,
body: Option<String>,
}
pub async fn create_post(
State(state): State<AppState>,
Extension(ClientIp(ip)): Extension<ClientIp>,
body: Result<Bytes, BytesRejection>,
) -> ApiResult<Response> {
let input: NewPost = parse_json(body)?;
validate(input.user_id, &input.title, &input.body)?;
let max = state.posts.max_posts;
let post = state.posts.write(ip, |client| {
if client.posts.len() >= max {
return Err(ApiError::new(
StatusCode::CONFLICT,
format!(
"You can have at most {max} posts. Delete one first, or wait for the reset"
),
));
}
Ok(client.insert(input.user_id, input.title, input.body))
})?;
let location = HeaderValue::from_str(&format!("/posts/{}", post.id)).expect("ascii");
Ok((StatusCode::CREATED, [(header::LOCATION, location)], Json(post)).into_response())
}
pub async fn replace_post(
State(state): State<AppState>,
Extension(ClientIp(ip)): Extension<ClientIp>,
Path(id): Path<String>,
body: Result<Bytes, BytesRejection>,
) -> ApiResult<Json<Post>> {
let post_id = parse_id(&id, "post")?;
let input: NewPost = parse_json(body)?;
validate(input.user_id, &input.title, &input.body)?;
state.posts.write(ip, |client| {
let post =
client.posts.iter_mut().find(|p| p.id == post_id).ok_or_else(|| post_not_found(&id))?;
post.user_id = input.user_id;
post.title = input.title;
post.body = input.body;
Ok(Json(post.clone()))
})
}
pub async fn update_post(
State(state): State<AppState>,
Extension(ClientIp(ip)): Extension<ClientIp>,
Path(id): Path<String>,
body: Result<Bytes, BytesRejection>,
) -> ApiResult<Json<Post>> {
let post_id = parse_id(&id, "post")?;
let patch: PostPatch = parse_json(body)?;
state.posts.write(ip, |client| {
let post =
client.posts.iter_mut().find(|p| p.id == post_id).ok_or_else(|| post_not_found(&id))?;
let user_id = patch.user_id.unwrap_or(post.user_id);
let title = patch.title.unwrap_or_else(|| post.title.clone());
let body = patch.body.unwrap_or_else(|| post.body.clone());
validate(user_id, &title, &body)?;
post.user_id = user_id;
post.title = title;
post.body = body;
Ok(Json(post.clone()))
})
}
pub async fn delete_post(
State(state): State<AppState>,
Extension(ClientIp(ip)): Extension<ClientIp>,
Path(id): Path<String>,
) -> ApiResult<StatusCode> {
let post_id = parse_id(&id, "post")?;
state.posts.write(ip, |client| {
let index =
client.posts.iter().position(|p| p.id == post_id).ok_or_else(|| post_not_found(&id))?;
client.posts.remove(index);
Ok(StatusCode::NO_CONTENT)
})
}
fn post_not_found(id: &str) -> ApiError {
ApiError::not_found(format!("No post with ID {id}"))
}
fn validate(user_id: u64, title: &str, body: &str) -> ApiResult<()> {
if data::user(user_id).is_none() {
return Err(ApiError::bad_request(format!("No user with ID {user_id}")));
}
if title.trim().is_empty() {
return Err(ApiError::bad_request("`title` can't be empty"));
}
if title.chars().count() > MAX_TITLE_CHARS {
return Err(ApiError::bad_request(format!(
"`title` must be {MAX_TITLE_CHARS} characters or fewer"
)));
}
if body.chars().count() > MAX_BODY_CHARS {
return Err(ApiError::bad_request(format!(
"`body` must be {MAX_BODY_CHARS} characters or fewer"
)));
}
Ok(())
}

View File

@@ -0,0 +1,88 @@
{
"users": [
{ "id": 1, "name": "Ada Lovelace", "username": "ada", "email": "ada@example.com" },
{ "id": 2, "name": "Grace Hopper", "username": "grace", "email": "grace@example.com" },
{ "id": 3, "name": "Alan Turing", "username": "alan", "email": "alan@example.com" },
{ "id": 4, "name": "Katherine Johnson", "username": "katherine", "email": "katherine@example.com" },
{ "id": 5, "name": "Margaret Hamilton", "username": "margaret", "email": "margaret@example.com" }
],
"posts": [
{
"id": 1,
"userId": 1,
"title": "Notes on the Analytical Engine",
"body": "Requests are just messages. The trick is knowing exactly what you sent."
},
{
"id": 2,
"userId": 1,
"title": "Why I keep my variables in environments",
"body": "Switching from staging to production should be one click, not a find and replace."
},
{
"id": 3,
"userId": 2,
"title": "Easier to ask forgiveness than permission",
"body": "Unless the API returns a 403. Then it's time to check your token."
},
{
"id": 4,
"userId": 2,
"title": "Debugging the first bug",
"body": "The Timeline tab shows every redirect, header, and byte. No moths required."
},
{
"id": 5,
"userId": 3,
"title": "Can machines send requests?",
"body": "This one just did. Look through the response headers to see what came back."
},
{
"id": 6,
"userId": 3,
"title": "On computable status codes",
"body": "2xx is good news, 4xx usually means the request needs fixing, and 5xx usually means the server does."
},
{
"id": 7,
"userId": 4,
"title": "Checking the math on pagination",
"body": "Add a limit query parameter to get fewer results back."
},
{
"id": 8,
"userId": 4,
"title": "Plotting a course between requests",
"body": "Pull a value out of one response and send it in the next. That's all chaining is."
},
{
"id": 9,
"userId": 5,
"title": "Error handling saved the landing",
"body": "An API that returns clear errors is an API you can build on."
},
{
"id": 10,
"userId": 5,
"title": "Logging in, then everything else",
"body": "Get a token from one request and use it for the rest. Set it once on a folder and every request inside inherits it."
}
],
"todos": [
{ "id": 1, "userId": 1, "title": "Send your first request", "completed": true },
{ "id": 2, "userId": 1, "title": "Create an environment", "completed": false },
{ "id": 3, "userId": 1, "title": "Switch to a sub-environment", "completed": false },
{ "id": 4, "userId": 2, "title": "Import an OpenAPI spec", "completed": true },
{ "id": 5, "userId": 2, "title": "Add a header to a folder", "completed": false },
{ "id": 6, "userId": 2, "title": "Read the response timeline", "completed": true },
{ "id": 7, "userId": 3, "title": "Chain a value from another response", "completed": false },
{ "id": 8, "userId": 3, "title": "Log in and use the token", "completed": false },
{ "id": 9, "userId": 3, "title": "Copy a request as curl", "completed": true },
{ "id": 10, "userId": 4, "title": "Sync a workspace to a folder", "completed": false },
{ "id": 11, "userId": 4, "title": "Commit the workspace to Git", "completed": false },
{ "id": 12, "userId": 4, "title": "Encrypt a secret", "completed": false },
{ "id": 13, "userId": 5, "title": "Create a post", "completed": false },
{ "id": 14, "userId": 5, "title": "Delete the post you created", "completed": false },
{ "id": 15, "userId": 5, "title": "Delete the example workspace", "completed": false }
]
}

View File

@@ -0,0 +1,83 @@
use crate::data::{self, Post};
use std::collections::HashMap;
use std::net::IpAddr;
use std::sync::{Mutex, MutexGuard};
use std::time::{Duration, Instant};
/// Posts as each client sees them. A client that has never written reads the sample data; its
/// first write takes a private copy, which is thrown away `reset_after` later. Nothing one
/// client writes is ever served to another.
pub struct PostStore {
clients: Mutex<HashMap<IpAddr, ClientPosts>>,
reset_after: Duration,
max_clients: usize,
pub max_posts: usize,
}
pub struct ClientPosts {
pub posts: Vec<Post>,
next_id: u64,
created: Instant,
}
impl ClientPosts {
pub fn insert(&mut self, user_id: u64, title: String, body: String) -> Post {
let post = Post { id: self.next_id, user_id, title, body };
self.next_id += 1;
self.posts.push(post.clone());
post
}
}
impl PostStore {
pub fn new(reset_after: Duration, max_clients: usize, max_posts: usize) -> Self {
Self { clients: Mutex::new(HashMap::new()), reset_after, max_clients, max_posts }
}
pub fn read<R>(&self, client: IpAddr, f: impl FnOnce(&[Post]) -> R) -> R {
let mut clients = self.lock();
self.drop_if_expired(&mut clients, client, Instant::now());
match clients.get(&client) {
Some(c) => f(&c.posts),
None => f(data::posts()),
}
}
pub fn write<R>(&self, client: IpAddr, f: impl FnOnce(&mut ClientPosts) -> R) -> R {
let mut clients = self.lock();
let now = Instant::now();
self.drop_if_expired(&mut clients, client, now);
if !clients.contains_key(&client) {
if clients.len() >= self.max_clients {
clients.retain(|_, c| now.duration_since(c.created) < self.reset_after);
}
if clients.len() >= self.max_clients
&& let Some(oldest) =
clients.iter().min_by_key(|(_, c)| c.created).map(|(ip, _)| *ip)
{
clients.remove(&oldest);
}
let posts = data::posts().to_vec();
let next_id = posts.iter().map(|p| p.id).max().unwrap_or(0) + 1;
clients.insert(client, ClientPosts { posts, next_id, created: now });
}
f(clients.get_mut(&client).expect("inserted above"))
}
fn lock(&self) -> MutexGuard<'_, HashMap<IpAddr, ClientPosts>> {
self.clients.lock().unwrap_or_else(|e| e.into_inner())
}
fn drop_if_expired(
&self,
clients: &mut HashMap<IpAddr, ClientPosts>,
client: IpAddr,
now: Instant,
) {
if clients.get(&client).is_some_and(|c| now.duration_since(c.created) >= self.reset_after) {
clients.remove(&client);
}
}
}

View File

@@ -0,0 +1,262 @@
use axum::Router;
use axum::body::{Body, to_bytes};
use axum::extract::ConnectInfo;
use axum::http::{HeaderMap, Request, StatusCode, header};
use serde_json::{Value, json};
use std::collections::BTreeSet;
use std::net::SocketAddr;
use tower::ServiceExt;
use yaak_playground::{Config, router};
const ALICE: [u8; 4] = [203, 0, 113, 1];
const BOB: [u8; 4] = [203, 0, 113, 2];
fn config() -> Config {
Config {
host: "127.0.0.1".to_string(),
port: 0,
rate_limit_per_minute: 1000,
trust_forwarded_for: false,
reset_after_secs: 3600,
max_clients: 10,
max_posts_per_client: 20,
max_request_bytes: 4096,
}
}
struct Res {
status: StatusCode,
headers: HeaderMap,
json: Value,
}
async fn call(
app: &Router,
client: [u8; 4],
method: &str,
uri: &str,
headers: &[(&str, &str)],
body: Option<Value>,
) -> Res {
let mut req = Request::builder().method(method).uri(uri);
for (name, value) in headers {
req = req.header(*name, *value);
}
let body = match body {
Some(v) => Body::from(v.to_string()),
None => Body::empty(),
};
let mut req = req.body(body).unwrap();
req.extensions_mut().insert(ConnectInfo(SocketAddr::from((client, 40000))));
let res = app.clone().oneshot(req).await.unwrap();
let status = res.status();
let headers = res.headers().clone();
let bytes = to_bytes(res.into_body(), 1024 * 1024).await.unwrap();
let json = if bytes.is_empty() { Value::Null } else { serde_json::from_slice(&bytes).unwrap() };
Res { status, headers, json }
}
#[tokio::test]
async fn users_and_todos() {
let app = router(config());
let res = call(&app, ALICE, "GET", "/users", &[], None).await;
assert_eq!(res.status, StatusCode::OK);
assert_eq!(res.json[0]["username"], "ada");
let res = call(&app, ALICE, "GET", "/users/2", &[], None).await;
assert_eq!(res.json["name"], "Grace Hopper");
for uri in ["/users/99", "/users/abc", "/users/99/todos"] {
let res = call(&app, ALICE, "GET", uri, &[], None).await;
assert_eq!(res.status, StatusCode::NOT_FOUND, "{uri}");
assert!(res.json["error"].is_string());
}
let res = call(&app, ALICE, "GET", "/users/1/todos?completed=false&limit=1", &[], None).await;
assert_eq!(res.json.as_array().unwrap().len(), 1);
assert_eq!(res.json[0]["userId"], 1);
assert_eq!(res.json[0]["completed"], false);
let res = call(&app, ALICE, "GET", "/todos?userId=3", &[], None).await;
assert!(res.json.as_array().unwrap().iter().all(|t| t["userId"] == 3));
let res = call(&app, ALICE, "GET", "/todos?completed=maybe", &[], None).await;
assert_eq!(res.status, StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn writes_are_private_to_the_client() {
let app = router(config());
let new_post = json!({ "userId": 1, "title": "Hello", "body": "From Yaak" });
let res = call(&app, ALICE, "POST", "/posts", &[], Some(new_post)).await;
assert_eq!(res.status, StatusCode::CREATED);
let id = res.json["id"].as_u64().unwrap();
assert_eq!(res.headers[header::LOCATION], format!("/posts/{id}"));
let uri = format!("/posts/{id}");
assert_eq!(call(&app, ALICE, "GET", &uri, &[], None).await.status, StatusCode::OK);
assert_eq!(call(&app, BOB, "GET", &uri, &[], None).await.status, StatusCode::NOT_FOUND);
let res = call(&app, ALICE, "PATCH", &uri, &[], Some(json!({ "title": "Edited" }))).await;
assert_eq!(res.json["title"], "Edited");
assert_eq!(res.json["body"], "From Yaak");
let replacement = json!({ "userId": 2, "title": "Replaced", "body": "" });
let res = call(&app, ALICE, "PUT", &uri, &[], Some(replacement)).await;
assert_eq!(res.json["userId"], 2);
let res = call(&app, ALICE, "DELETE", "/posts/1", &[], None).await;
assert_eq!(res.status, StatusCode::NO_CONTENT);
assert_eq!(call(&app, ALICE, "GET", "/posts/1", &[], None).await.status, StatusCode::NOT_FOUND);
assert_eq!(call(&app, BOB, "GET", "/posts/1", &[], None).await.status, StatusCode::OK);
let res = call(&app, ALICE, "GET", "/posts?userId=2", &[], None).await;
assert!(res.json.as_array().unwrap().iter().any(|p| p["title"] == "Replaced"));
}
#[tokio::test]
async fn invalid_posts_are_rejected() {
let app = router(config());
let cases = [
json!({ "title": "No user", "body": "" }),
json!({ "userId": 99, "title": "Unknown user", "body": "" }),
json!({ "userId": 1, "title": " ", "body": "" }),
json!({ "userId": 1, "title": "x".repeat(201), "body": "" }),
];
for body in cases {
let res = call(&app, ALICE, "POST", "/posts", &[], Some(body.clone())).await;
assert_eq!(res.status, StatusCode::BAD_REQUEST, "{body}");
assert!(res.json["error"].is_string());
}
let res = call(&app, ALICE, "POST", "/posts", &[], None).await;
assert_eq!(res.status, StatusCode::BAD_REQUEST);
let huge = json!({ "userId": 1, "title": "Big", "body": "x".repeat(5000) });
let res = call(&app, ALICE, "POST", "/posts", &[], Some(huge)).await;
assert_eq!(res.status, StatusCode::PAYLOAD_TOO_LARGE);
assert!(res.json["error"].is_string());
}
#[tokio::test]
async fn post_cap_and_reset() {
let app = router(Config { max_posts_per_client: 11, ..config() });
let post = json!({ "userId": 1, "title": "One more", "body": "" });
assert_eq!(
call(&app, ALICE, "POST", "/posts", &[], Some(post.clone())).await.status,
StatusCode::CREATED
);
assert_eq!(
call(&app, ALICE, "POST", "/posts", &[], Some(post)).await.status,
StatusCode::CONFLICT
);
let app = router(Config { reset_after_secs: 0, ..config() });
call(&app, ALICE, "DELETE", "/posts/1", &[], None).await;
assert_eq!(call(&app, ALICE, "GET", "/posts/1", &[], None).await.status, StatusCode::OK);
}
#[tokio::test]
async fn bearer_token_flow() {
let app = router(config());
let res = call(&app, ALICE, "GET", "/auth/me", &[], None).await;
assert_eq!(res.status, StatusCode::UNAUTHORIZED);
assert_eq!(res.headers[header::WWW_AUTHENTICATE], "Bearer");
let bad = json!({ "username": "ada", "password": "nope" });
assert_eq!(
call(&app, ALICE, "POST", "/auth/token", &[], Some(bad)).await.status,
StatusCode::UNAUTHORIZED
);
let good = json!({ "username": "grace", "password": "yaak" });
let res = call(&app, ALICE, "POST", "/auth/token", &[], Some(good)).await;
assert_eq!(res.status, StatusCode::OK);
let token = res.json["accessToken"].as_str().unwrap().to_string();
let auth = format!("Bearer {token}");
let res = call(&app, BOB, "GET", "/auth/me", &[("authorization", &auth)], None).await;
assert_eq!(res.json["username"], "grace");
let res =
call(&app, ALICE, "GET", "/auth/me", &[("authorization", "Bearer wrong")], None).await;
assert_eq!(res.status, StatusCode::UNAUTHORIZED);
let cookie = format!("session={token}");
let res = call(&app, ALICE, "GET", "/auth/session", &[("cookie", &cookie)], None).await;
assert_eq!(res.status, StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn session_cookie_flow() {
let app = router(config());
let login = json!({ "username": "alan", "password": "yaak" });
let res =
call(&app, ALICE, "POST", "/auth/session", &[("x-forwarded-proto", "https")], Some(login))
.await;
assert_eq!(res.status, StatusCode::OK);
let set_cookie = res.headers[header::SET_COOKIE].to_str().unwrap();
assert!(set_cookie.contains("HttpOnly") && set_cookie.contains("; Secure"), "{set_cookie}");
let cookie = set_cookie.split(';').next().unwrap().to_string();
let headers = [("cookie", cookie.as_str())];
let res = call(&app, ALICE, "GET", "/auth/session", &headers, None).await;
assert_eq!(res.json["username"], "alan");
let id = cookie.trim_start_matches("session=");
let auth = format!("Bearer {id}");
let res = call(&app, ALICE, "GET", "/auth/me", &[("authorization", &auth)], None).await;
assert_eq!(res.status, StatusCode::UNAUTHORIZED);
let res = call(&app, ALICE, "DELETE", "/auth/session", &headers, None).await;
assert_eq!(res.status, StatusCode::NO_CONTENT);
assert!(res.headers[header::SET_COOKIE].to_str().unwrap().contains("Max-Age=0"));
assert_eq!(
call(&app, ALICE, "GET", "/auth/session", &headers, None).await.status,
StatusCode::UNAUTHORIZED
);
}
#[tokio::test]
async fn rate_limit() {
let app = router(Config { rate_limit_per_minute: 2, ..config() });
call(&app, ALICE, "GET", "/users", &[], None).await;
call(&app, ALICE, "GET", "/users", &[], None).await;
let res = call(&app, ALICE, "GET", "/users", &[], None).await;
assert_eq!(res.status, StatusCode::TOO_MANY_REQUESTS);
assert!(res.headers.contains_key(header::RETRY_AFTER));
assert_eq!(call(&app, BOB, "GET", "/users", &[], None).await.status, StatusCode::OK);
}
#[tokio::test]
async fn openapi_matches_the_routes() {
let app = router(config());
let spec = call(&app, ALICE, "GET", "/openapi.json", &[], None).await.json;
let mut documented = BTreeSet::new();
for (path, item) in spec["paths"].as_object().unwrap() {
for (method, _) in item.as_object().unwrap() {
let method = method.to_uppercase();
let uri = path.replace("{id}", "1");
// A fresh client per call, so a DELETE can't hide the GET that follows it
let client = [198, 51, 100, documented.len() as u8];
let res = call(&app, client, &method, &uri, &[], None).await;
assert!(
res.status != StatusCode::NOT_FOUND && res.status != StatusCode::METHOD_NOT_ALLOWED,
"{method} {path} is documented but not routed ({})",
res.status
);
documented.insert(format!("{method} {path}"));
}
}
let index = call(&app, ALICE, "GET", "/", &[], None).await.json;
let listed: BTreeSet<String> = index["endpoints"]
.as_array()
.unwrap()
.iter()
.map(|e| e.as_str().unwrap().to_string())
.collect();
assert_eq!(listed, documented);
assert_eq!(call(&app, ALICE, "GET", "/nope", &[], None).await.status, StatusCode::NOT_FOUND);
}

View File

@@ -0,0 +1,40 @@
[package]
name = "yaak-web"
version = "0.1.0"
edition = "2024"
publish = false
description = "The server behind Yaak in the browser: executes sends, and can serve the app"
# The send engine (yaak-http) and the model types it speaks (yaak-models, for
# HttpRequest / Cookie / HttpResponseEventData). Deliberately NOT yaak (the
# render + storage orchestration), yaak-plugins, or the RPC router: this crate
# opens no database, runs no plugins, and renders nothing. yaak-models comes
# along only because yaak-http's types are its types; nothing here calls into
# its query layer.
[[bin]]
name = "yaak-web"
path = "src/main.rs"
[dependencies]
async-trait = "0.1"
axum = "0.7"
base64 = "0.22.1"
bytes = "1.11.1"
clap = { version = "4.5", features = ["derive", "env"] }
env_logger = "0.11"
futures-util = "0.3"
log = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
tokio = { workspace = true, features = ["rt-multi-thread", "macros", "signal", "sync", "io-util", "time", "net"] }
tower-http = { version = "0.6", features = ["compression-gzip", "compression-zstd", "cors", "fs"] }
ts-rs = { workspace = true }
url = "2"
uuid = { version = "1", features = ["v4"] }
yaak-http = { workspace = true }
yaak-models = { workspace = true }
[dev-dependencies]
tempfile = "3"
tower = { version = "0.5", features = ["util"] }

View File

@@ -0,0 +1,237 @@
# yaak-web
The network half of Yaak in a browser — and, with `--serve`, the half that
hands the browser the app in the first place.
A tab can't see an HTTP response the way a desktop app can: CORS hides most
headers (2 of 8 in a typical response), redirects are followed silently, and
there is no timeline. So the tab renders the request and posts it here, and this
process puts it on the network with the desktop's own engine (`yaak-http`) and
streams back everything that happened — every header, every redirect hop, DNS
timing, the body — for the tab to store.
It is a **stateless executor**. It keeps nothing: no database, no files, no
sessions, no cookies between calls. Every byte it sees comes from the tab in the
request, and every byte it returns is stored by the tab. Restart it any time.
## Self-hosting it
One container, no configuration, nothing behind it:
```shell
docker run -p 8080:8080 ghcr.io/mountain-loop/yaak-web
```
Open <http://localhost:8080>. The image carries the built web client and this
binary, which serves it — so the app and its sends are on one origin, and the
tab's send URL is a path (`/v1/http/send`) rather than an address anyone has to
configure. The image is `linux/amd64` and `linux/arm64`, built from
`Dockerfile.web` at the repo root.
Your data lives in your browser (SQLite compiled to wasm, in IndexedDB), not in
the container. The container is stateless: nothing is written to disk, so
upgrading is `docker pull` and nothing else.
Two settings are worth knowing about:
```shell
docker run -p 8080:8080 \
-e YAAK_WEB_ALLOW_PRIVATE_NETWORKS=true \
-e YAAK_WEB_RATE_LIMIT_PER_MINUTE=0 \
ghcr.io/mountain-loop/yaak-web
```
- **`YAAK_WEB_ALLOW_PRIVATE_NETWORKS=true`** lets sends reach loopback,
private and link-local addresses. Off by default, and it should stay off on
anything strangers can reach — see [What it refuses](#what-it-refuses-and-why).
Turn it on for an instance on your own network, where calling the API on the
next machine is the whole point. Note that "private" is relative to the
_container_: `127.0.0.1` is the container itself, and reaching the Docker
host means `host.docker.internal` (or `--network host`).
- **`YAAK_WEB_RATE_LIMIT_PER_MINUTE`** defaults to 120 sends per client IP,
which suits a public instance and not a team of your own; `0` disables it.
Behind a reverse proxy, add `YAAK_WEB_TRUST_FORWARDED_FOR=true` so the rate
limit sees real client addresses instead of its own — and only then, since
otherwise anyone can spoof the header. If the reverse proxy buffers responses,
tell it not to: sends are streamed, and the `X-Accel-Buffering: no` header this
binary sets is honoured by nginx-shaped ones.
## Running it from source
```shell
cargo run -p yaak-web -- --serve dist/apps/yaak-client
```
after a `YAAK_TARGET=web SKIP_WASM_BUILD=1 npx vp -C apps/yaak-client build`.
Without `--serve` it is the send executor alone, which is what the frontend
dev server wants:
```shell
cargo run -p yaak-web
YAAK_TARGET=web npm run dev --workspace @yaakapp/yaak-client
```
The dev server passes `/v1` through to this binary, so a dev build sends to its
own origin exactly like a production build does — one address to open, and no
CORS in the loop. `PORT` moves this server and the dev server follows it. A
production build also sends to its own origin, unless `VITE_YAAK_WEB_URL` was
set when it was built.
Reaching the dev server from another machine is `HOST=0.0.0.0`. Vite allows
addresses but not names, so opening it as a hostname also needs
`ALLOWED_HOSTS=that-name`.
## Configuration
Every flag has an environment variable, so a container needs no arguments;
`--help` lists them all. The listen address uses the platform conventions —
`HOST` and `PORT` — so the published image runs unchanged on a
platform-as-a-service that assigns a port. Everything else is `YAAK_WEB_*`.
| Flag | Default | What |
| -------------------------- | ----------- | --------------------------------------------------------------------------------------------- |
| `--serve` | off | Also serve a built web client from this directory, on the same origin. |
| `--host` | `127.0.0.1` | Interface to listen on. The image sets `0.0.0.0`. May be a name; `localhost` resolves. |
| `--port` | `9227` | Port to listen on. The image sets `8080`. |
| `--allow-private-networks` | off | Allow sends to loopback, private and link-local addresses. |
| `--allowed-origins` | `*` | CORS origins, comma-separated. Unused when the app is served from here: same origin, no CORS. |
| `--max-request-bytes` | 16 MiB | Largest rendered request accepted from the tab. |
| `--max-response-bytes` | 64 MiB | Largest upstream body relayed before the send is cut off. |
| `--max-timeout-secs` | 60 | Ceiling on a send's timeout; a request asking for more (or none) gets this. |
| `--rate-limit-per-minute` | 120 | Sends per client IP per minute; 0 disables. |
| `--max-concurrent` | 256 | Sends in flight at once. |
| `--trust-forwarded-for` | off | Take the client IP from `X-Forwarded-For`. Only behind a load balancer that sets it. |
## Logging
Default logs contain a startup message and operational warnings and errors, not request URLs, client IPs,
DNS lookups, or per-request timing. To diagnose a self-hosted instance, opt in with
`RUST_LOG=warn,yaak_http=error,yaak_web=debug`. This includes request URLs and IPs,
so only enable it while debugging. Hosting infrastructure may maintain its own
access logs independently.
## Serving the app
`--serve DIR` puts a file server behind the API routes: `/v1/*` is matched
first, everything else comes from `DIR`, and a path with no file behind it gets
`index.html` so the app's own routes survive a refresh. Responses are compressed
(gzip or zstd) on the fly. `/assets/*` is cached forever — Vite content-hashes
those names — and everything else is `no-cache`, so a new deploy arrives on the
next reload.
Serving files changes nothing about sending: the same rendered request, the same
destination policy, the same stateless executor. It exists so that a
self-hosted Yaak is one thing to run rather than two.
## Split deployments
The app and the sender can still be separate services — one CDN-hosted bundle and
one server elsewhere, or one server shared by several fronts. Then the bundle has
to be told where to send, at build time:
```shell
docker build -f Dockerfile.web \
--build-arg VITE_YAAK_WEB_URL=https://send.example.com .
```
and the server needs the CORS origins its callers use, since the requests are no
longer same-origin:
```shell
docker run -p 8080:8080 \
-e YAAK_WEB_ALLOWED_ORIGINS=https://yaak.example.com \
ghcr.io/mountain-loop/yaak-web \
yaak-web
```
The trailing `yaak-web` is a command override: the same image run without
`--serve`, so it executes sends and serves no app.
## What it refuses, and why
A hosted sender is, by construction, a machine that makes HTTP requests on
behalf of strangers. Left alone that is an open relay into whatever network it
sits on. So by default it refuses to connect to:
- loopback (`127/8`, `::1`), private (`10/8`, `172.16/12`, `192.168/16`,
`fc00::/7`), link-local (`169.254/16` — where cloud metadata lives — and
`fe80::/10`), carrier-grade NAT, multicast, reserved and unspecified ranges,
IPv4 addresses carried inside IPv6 forms (`::ffff:a.b.c.d`, the well-known
NAT64 prefix, 6to4), and the whole NAT64 local-use range;
- anything not `http://` or `https://`.
The check runs **on the resolved addresses, after DNS**, for every hop of a
redirect chain, so a public hostname that points at an internal address is
caught, and so is a `Location:` header that points at one. It also refuses body
types that would read files on its own disk (`binary`, multipart file
fields), since no browser tab could legitimately mean those.
Refusal reasons are included in debug logs when enabled. On a public instance
(`web.yaak.app`, or anything else strangers can reach), private-network protection
must stay on: the machine's private
network is the host's, not the user's, so a `localhost` or LAN API is not the
user's to reach through it — the desktop app is what reaches those. On an
instance you run for yourself, that reasoning is inverted, and
`--allow-private-networks` inverts the policy with it. It allows every range
above, including `169.254.169.254`, so use it only where the network on the
other side is one the users are entitled to.
There is no authentication either way: an instance is anonymous, protected by
the per-client rate limit and the destination policy. Anything more (a shared
token, per-user quotas) is a later slice and would sit in front of `send_http`
in `lib.rs`, or in middleware on the exported router. Put TLS in front of a public instance.
## The wire
`POST /v1/http/send` with a JSON body:
```json
{
"request": { "url": "https://…", "method": "GET", "headers": […], "body": {…}, "bodyType": null, "urlParameters": […] },
"settings": { "validateCertificates": true, "followRedirects": true, "timeoutMs": 0, "sendCookies": true, "storeCookies": true },
"cookies": [ … ]
}
```
`request` is a Yaak `HttpRequest` in the desktop's own model shape with every
template already rendered by the tab; the server builds the URL, headers and
body from it exactly the way the desktop does after rendering. `cookies` is the
jar's contents (or `null` for no jar).
The reply is `application/x-ndjson`, one JSON frame per line, in the order things
happened:
| `type` | When | Carries |
| ---------- | ----------------------------------------- | ---------------------------------------------------------------------------------- |
| `event` | as the engine produces them | one timeline event, in the desktop's `http_response_event.event` shape |
| `response` | once, when the final hop's headers arrive | status, all headers, request headers as sent, remote address, HTTP version, timing |
| `body` | as the body is read | a decompressed chunk, base64 |
| `done` | last, on success | elapsed, byte counts, and the cookie jar as the send left it |
| `error` | last, on failure | the reason, and any cookies collected before the failure |
Refusals that happen before anything is sent (a blocked destination, a bad body,
rate limit, capacity) are plain HTTP errors (`403`, `400`, `429`, `503`) with
`{"error": "…"}`, not streams.
Why a streamed HTTP response and not a WebSocket: one `POST` is stateless by
construction, cancellable by closing the connection, readable with `curl`, and
needs no upgrade handling on either side. A WebSocket only earns its keep when
traffic is bidirectional, which a single send is not.
The TypeScript side of this contract is generated from `src/wire.rs` by ts-rs
into `bindings/` (run `cargo test -p yaak-web` after changing a frame)
and published to the tab as `@yaakapp-internal/web`, so a change to the
wire on one side is a type error on the other.
`GET /v1/health` reports the version and the effective limits.
## What comes later
Not built, by design, but the router is shaped for it: a WebSocket relay
(`/v1/ws/relay`) and a gRPC relay (`/v1/grpc/relay`) would be long-lived,
bidirectional endpoints on the same binary, behind the same destination policy
and limits. They differ from this endpoint in holding per-connection
in-memory state while a connection is open (never persisted), which brings
connection limits and a larger abuse surface — the reason they are separate
work.

View File

@@ -0,0 +1,135 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type Cookie = {
name: string;
value: string;
domain: CookieDomain;
expires: CookieExpires;
path: string;
secure: boolean;
httpOnly: boolean;
sameSite: CookieSameSite | null;
};
export type CookieDomain = { HostOnly: string } | { Suffix: string } | "NotPresent" | "Empty";
export type CookieExpires = { AtUtc: string } | "SessionEnd";
export type CookieSameSite = "Strict" | "Lax" | "None";
export type HttpRequest = {
model: "http_request";
id: string;
createdAt: string;
updatedAt: string;
workspaceId: string;
folderId: string | null;
authentication: Record<string, any>;
authenticationType: string | null;
body: Record<string, any>;
bodyType: string | null;
description: string;
headers: Array<HttpRequestHeader>;
method: string;
name: string;
sortPriority: number;
url: string;
/**
* URL parameters used for both path placeholders (`:id`) and query string entries.
*/
urlParameters: Array<HttpUrlParameter>;
settingSendCookies: InheritedBoolSetting;
settingStoreCookies: InheritedBoolSetting;
settingValidateCertificates: InheritedBoolSetting;
settingFollowRedirects: InheritedBoolSetting;
settingRequestTimeout: InheritedIntSetting;
settingHttpVersion: InheritedHttpVersionSetting;
};
export type HttpRequestHeader = { enabled?: boolean; name: string; value: string; id?: string };
/**
* Serializable representation of HTTP response events for DB storage.
* This mirrors `yaak_http::sender::HttpResponseEvent` but with serde support.
* The `From` impl is in yaak-http to avoid circular dependencies.
*/
export type HttpResponseEventData =
| {
type: "setting";
name: string;
value: string;
source_model?: string;
source_id?: string;
source_name?: string;
}
| { type: "info"; message: string }
| {
type: "redirect";
url: string;
status: number;
behavior: string;
dropped_body: boolean;
dropped_headers: Array<string>;
}
| {
type: "send_url";
method: string;
scheme: string;
username: string;
password: string;
host: string;
port: number;
path: string;
query: string;
fragment: string;
}
| { type: "receive_url"; version: string; status: string }
| { type: "header_up"; name: string; value: string }
| { type: "header_down"; name: string; value: string }
| { type: "chunk_sent"; bytes: number }
| { type: "chunk_received"; bytes: number }
| {
type: "dns_resolved";
hostname: string;
addresses: Array<string>;
duration: bigint;
overridden: boolean;
};
export type HttpResponseHeader = { name: string; value: string };
/**
* The resolved send settings, values only: what an executor has to obey, with the sources
* (which model each came from) left behind in [`ResolvedHttpRequestSettings`]. This is what
* crosses from a tab to the Yaak server, and what the server reads.
*/
export type HttpSendSettings = {
validateCertificates: boolean;
followRedirects: boolean;
/**
* Milliseconds. Zero or negative means no timeout.
*/
timeoutMs: number;
sendCookies: boolean;
storeCookies: boolean;
httpVersion: HttpVersion;
};
export type HttpUrlParameter = {
enabled?: boolean;
/**
* Colon-prefixed parameters are treated as path parameters if they match, like `/users/:id`
* Other entries are appended as query parameters
*/
name: string;
value: string;
id?: string;
};
export type HttpVersion = "auto" | "http1" | "http2";
export type InheritedBoolSetting = { enabled?: boolean; value: boolean };
export type InheritedHttpVersionSetting = { enabled?: boolean; value: HttpVersion };
export type InheritedIntSetting = { enabled?: boolean; value: number };

View File

@@ -0,0 +1,64 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { Cookie, HttpRequest, HttpResponseEventData, HttpResponseHeader, HttpSendSettings } from "./gen_models";
/**
* One line of the reply stream. Tags are snake_case like the timeline event tags; fields are
* camelCase like every model the tab stores.
*/
export type Frame = { "type": "event", event: HttpResponseEventData, } | { "type": "response", status: number, statusReason: string | null,
/**
* The URL that answered, after redirects.
*/
url: string, remoteAddr: string | null, version: string | null, headers: Array<HttpResponseHeader>,
/**
* The headers that were actually sent on the final hop, cookies and all.
*/
requestHeaders: Array<HttpResponseHeader>,
/**
* `Content-Length` as declared by the server, if it declared one.
*/
contentLength: number | null,
/**
* Milliseconds from the start of the send to the response head.
*/
elapsedHeaders: number,
/**
* Milliseconds spent in DNS on the last lookup, or zero.
*/
elapsedDns: number, } | { "type": "body", data: string, } | { "type": "done",
/**
* Milliseconds from the start of the send to the end of the body.
*/
elapsed: number,
/**
* Bytes of body relayed, after decompression.
*/
contentLength: number,
/**
* Bytes on the wire as declared by the server, or the relayed size when unknown.
*/
contentLengthCompressed: number,
/**
* The jar as the send left it, for the tab to persist. `None` when the tab sent none.
*/
cookies: Array<Cookie> | null, } | { "type": "error", message: string, cookies: Array<Cookie> | null, };
/**
* The body of `POST /v1/http/send`.
*/
export type SendRequest = {
/**
* The request to send, in the desktop's own model shape but with every template already
* rendered by the tab. The server builds the URL, headers and body from it exactly the way
* the desktop does after rendering.
*/
request: HttpRequest,
/**
* The resolved settings, values only. Where they came from is the tab's to record in
* its timeline; the server only needs to obey them.
*/
settings: HttpSendSettings,
/**
* The cookies to start with. `None` means no jar at all: nothing sent, nothing kept.
*/
cookies: Array<Cookie> | null, };

View File

@@ -0,0 +1,4 @@
// The server's wire contract, generated by ts-rs from src/wire.rs
// (`cargo test -p yaak-web`). The tab imports these so a change to a
// frame on the Rust side is a type error in packages/platform/src/web.
export type { Frame, SendRequest } from "./bindings/gen_web";

View File

@@ -0,0 +1,6 @@
{
"name": "@yaakapp-internal/web",
"version": "1.0.0",
"private": true,
"main": "index.ts"
}

View File

@@ -0,0 +1,98 @@
use clap::Parser;
use std::net::{SocketAddr, ToSocketAddrs};
use std::path::PathBuf;
/// The server behind Yaak running in a browser.
///
/// The tab renders the request and owns the data; this binary puts the bytes on the network
/// and streams back what came back, and with `--serve` hands the browser the app as well.
/// Nothing is written to disk or a database.
#[derive(Parser, Debug, Clone)]
#[command(name = "yaak-web", version, about, long_about = None)]
pub struct Config {
/// Interface to listen on. 127.0.0.1 for a local instance; 0.0.0.0 inside a container,
/// which is what makes the port reachable from outside it.
#[arg(long, env = "HOST", default_value = "127.0.0.1")]
pub host: String,
/// Port to listen on. Named `PORT` because that is the variable a platform-as-a-service
/// assigns and expects to be obeyed, so the same image runs there and under `docker run`
/// with nothing changed.
#[arg(long, env = "PORT", default_value_t = 9227)]
pub port: u16,
/// Port the app is served on elsewhere. A browser that opens this server directly is
/// redirected there, keeping whatever hostname it used — so `home:9227` becomes
/// `home:1424` without this server knowing what "home" is.
///
/// Set by `vp run web:dev`, where the app is on the dev server and this is only the
/// executor behind it. Irrelevant with `--serve`, which puts both on one port.
#[arg(long, env = "YAAK_WEB_APP_PORT")]
pub app_port: Option<u16>,
/// Also serve a built web client from this directory, on the same origin as the API.
/// Unknown paths fall back to `index.html` so the app's own routes work on a refresh.
/// Without this the binary is only the send executor.
#[arg(long, env = "YAAK_WEB_SERVE", value_name = "DIR")]
pub serve: Option<PathBuf>,
/// Allow sends to loopback, private and link-local addresses. Off by default, because a
/// server reachable by strangers is an open relay into the network it sits on. Turn it on
/// only for an instance whose users are meant to reach that network — a self-hosted one
/// on a LAN, where the point is to call the API on the next machine.
#[arg(long, env = "YAAK_WEB_ALLOW_PRIVATE_NETWORKS", default_value_t = false)]
pub allow_private_networks: bool,
/// Browser origins allowed to call this server (CORS), comma-separated. `*` allows any.
/// A local dev instance wants the Vite origin; a hosted instance wants its own web origin.
#[arg(
long,
env = "YAAK_WEB_ALLOWED_ORIGINS",
default_value = "*",
value_delimiter = ','
)]
pub allowed_origins: Vec<String>,
/// Largest request the server accepts from the tab (the rendered request JSON, body included).
#[arg(long, env = "YAAK_WEB_MAX_REQUEST_BYTES", default_value_t = 16 * 1024 * 1024)]
pub max_request_bytes: usize,
/// Largest upstream response body the server will relay before cutting the send off.
#[arg(long, env = "YAAK_WEB_MAX_RESPONSE_BYTES", default_value_t = 64 * 1024 * 1024)]
pub max_response_bytes: usize,
/// Ceiling on a send's timeout, in seconds. A request asking for longer (or for no timeout)
/// gets this instead.
#[arg(long, env = "YAAK_WEB_MAX_TIMEOUT_SECS", default_value_t = 60)]
pub max_timeout_secs: u64,
/// Sends allowed per client IP per minute. 0 disables the limit. This and the concurrency
/// cap are the whole of what protects an instance: there is no authentication.
#[arg(long, env = "YAAK_WEB_RATE_LIMIT_PER_MINUTE", default_value_t = 120)]
pub rate_limit_per_minute: u32,
/// Sends in flight at once across all clients.
#[arg(long, env = "YAAK_WEB_MAX_CONCURRENT", default_value_t = 256)]
pub max_concurrent: usize,
/// Take the client IP from `X-Forwarded-For` (first hop) instead of the socket. Only turn
/// this on behind a load balancer that sets the header; otherwise anyone can spoof their way
/// past the rate limit.
#[arg(long, env = "YAAK_WEB_TRUST_FORWARDED_FOR", default_value_t = false)]
pub trust_forwarded_for: bool,
}
impl Config {
/// The address to listen on.
///
/// Resolved rather than parsed, so `HOST` may be a name — `localhost` is the one people
/// actually type, and it is not an `IpAddr`. The first address wins; a host resolving to
/// several is a machine with several interfaces, and any of them is a listen address.
pub fn listen_addr(&self) -> Result<SocketAddr, String> {
(self.host.as_str(), self.port)
.to_socket_addrs()
.map_err(|e| format!("Invalid HOST {:?}: {e}", self.host))?
.next()
.ok_or_else(|| format!("HOST {:?} resolved to no address", self.host))
}
}

View File

@@ -0,0 +1,271 @@
//! Where a send may go.
//!
//! A hosted sender is, by construction, a machine that makes HTTP requests on
//! behalf of strangers. Left alone that is an open relay into whatever network
//! it sits on: cloud metadata endpoints, internal admin panels, the database
//! next door. So every destination is checked twice — once on the URL before a
//! hop is attempted (literal IPs, host allow/deny lists) and once on the
//! addresses a hostname actually resolves to, right before the connection is
//! made. The second check is the one that matters for a hostname pointing at
//! an internal address, and it runs on every redirect hop because the engine
//! resolves every hop.
use async_trait::async_trait;
use log::debug;
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
use std::sync::Arc;
use tokio::sync::mpsc;
use url::Url;
use yaak_http::dns::AddressFilter;
use yaak_http::sender::{HttpResponse, HttpResponseEvent, HttpSender};
use yaak_http::types::SendableHttpRequest;
/// The destination policy, shared by every send: public addresses only, unless the operator
/// has said otherwise. A hosted server's "private network" is the cloud's, not the user's, so
/// the default is public-only; a self-hosted instance on a LAN can be told that its private
/// network *is* the user's, which is what `--allow-private-networks` means.
#[derive(Clone, Default)]
pub struct DestinationPolicy {
allow_private: bool,
}
impl DestinationPolicy {
pub fn new(allow_private: bool) -> Self {
Self { allow_private }
}
/// Check a URL before a hop is attempted: scheme and literal IPs. A hostname that passes
/// here still has its resolved addresses checked by [`Self::address_filter`].
pub fn check_url(&self, raw: &str) -> Result<(), String> {
let url = Url::parse(raw).map_err(|e| format!("Invalid URL {raw:?}: {e}"))?;
match url.scheme() {
"http" | "https" => {}
other => return Err(format!("Refusing to send over {other:?}; only http and https")),
}
let host = url.host_str().ok_or_else(|| format!("URL {raw:?} has no host"))?;
let host = host.trim_matches(|c| c == '[' || c == ']');
// A literal IP never reaches the resolver, so it is checked here. Hostnames are checked
// where their addresses become known.
if let Ok(ip) = host.parse::<IpAddr>() {
self.check_ip(ip)?;
}
Ok(())
}
/// The veto the engine's resolver applies to every address a hostname resolves to.
pub fn address_filter(&self) -> AddressFilter {
let policy = self.clone();
Arc::new(move |ip| policy.check_ip(ip))
}
pub fn check_ip(&self, ip: IpAddr) -> Result<(), String> {
if self.allow_private {
return Ok(());
}
match non_public_reason(ip) {
Some(reason) => Err(format!(
"Refusing to connect to {ip}: {reason}. This server only sends to public addresses"
)),
None => Ok(()),
}
}
}
/// Why an address is not a public internet address, or `None` if it is one.
///
/// Every range here is one a hosted relay must never be talked into reaching: the machine
/// itself, the network it sits on, and the link-local range where cloud metadata services
/// (169.254.169.254) live. IPv4 addresses carried inside fixed-layout IPv6 forms — IPv4-mapped,
/// the well-known NAT64 prefix, 6to4 — are unwrapped and judged as IPv4, since that is where
/// the packets end up; the NAT64 local-use range is refused outright. This is the stable-Rust
/// stand-in for `IpAddr::is_global`, which is still behind `#![feature(ip)]`; a network-specific
/// NAT64 prefix is not knowable here.
pub fn non_public_reason(ip: IpAddr) -> Option<&'static str> {
match ip {
IpAddr::V4(v4) => non_public_v4(v4),
IpAddr::V6(v6) => {
if let Some(v4) = v6.to_ipv4_mapped() {
return non_public_v4(v4);
}
if let Some(v4) = embedded_v4(&v6) {
return non_public_v4(v4);
}
if v6.is_loopback() {
Some("loopback")
} else if v6.is_unspecified() {
Some("unspecified")
} else if v6.is_unique_local() {
Some("unique local (fc00::/7)")
} else if v6.is_unicast_link_local() {
Some("link-local (fe80::/10)")
} else if v6.is_multicast() {
Some("multicast")
} else if v6.segments()[..3] == [0x64, 0xff9b, 1] {
Some("NAT64 local-use (64:ff9b:1::/48)")
} else if v6.segments()[..4] == [0x100, 0, 0, 0] {
Some("discard-only (100::/64)")
} else if (v6.segments()[0] & 0xffc0) == 0xfec0 {
Some("site-local (fec0::/10)")
} else if v6.segments()[0] == 0x2001 && v6.segments()[1] == 0x0db8 {
Some("documentation (2001:db8::/32)")
} else {
None
}
}
}
}
fn non_public_v4(v4: Ipv4Addr) -> Option<&'static str> {
let o = v4.octets();
if v4.is_loopback() {
Some("loopback (127.0.0.0/8)")
} else if v4.is_private() {
Some("private (10/8, 172.16/12, 192.168/16)")
} else if v4.is_link_local() {
Some("link-local (169.254.0.0/16, where cloud metadata lives)")
} else if v4.is_unspecified() || o[0] == 0 {
Some("this network (0.0.0.0/8)")
} else if o[0] == 100 && (o[1] & 0xc0) == 64 {
Some("carrier-grade NAT (100.64.0.0/10)")
} else if v4.is_broadcast() {
Some("broadcast")
} else if v4.is_multicast() {
Some("multicast (224.0.0.0/4)")
} else if o[0] >= 240 {
Some("reserved (240.0.0.0/4)")
} else if v4.is_documentation() {
Some("documentation")
} else if o[0] == 192 && o[1] == 0 && o[2] == 0 {
Some("IETF protocol assignments (192.0.0.0/24)")
} else if o[0] == 198 && (o[1] & 0xfe) == 18 {
Some("benchmarking (198.18.0.0/15)")
} else {
None
}
}
/// The IPv4 address an IPv6 address stands for, when it is one of the fixed-layout translation
/// forms: the NAT64 well-known prefix (64:ff9b::/96) or 6to4 (2002::/16, IPv4 in the next 32
/// bits). The NAT64 local-use range (64:ff9b:1::/48) is a pool operators carve their own
/// prefix from, at a length only they know, so it is refused wholesale in [`non_public_reason`]
/// rather than decoded — the same call `std`'s (still unstable) `Ipv6Addr::is_global` makes.
fn embedded_v4(v6: &Ipv6Addr) -> Option<Ipv4Addr> {
let s = v6.segments();
let o = v6.octets();
if s[0] == 0x64 && s[1] == 0xff9b && s[2..6].iter().all(|x| *x == 0) {
return Some(Ipv4Addr::new(o[12], o[13], o[14], o[15]));
}
if s[0] == 0x2002 {
return Some(Ipv4Addr::new(o[2], o[3], o[4], o[5]));
}
None
}
/// An [`HttpSender`] that checks each hop's URL against the policy before delegating.
///
/// The engine's redirect loop calls the sender once per hop with the hop's URL, so wrapping
/// the sender is what makes `Location:` headers subject to the same rules as the first URL —
/// including a redirect to a literal internal IP, which the resolver would never see.
pub struct GuardedSender<S> {
inner: S,
policy: DestinationPolicy,
}
impl<S: HttpSender> GuardedSender<S> {
pub fn new(inner: S, policy: DestinationPolicy) -> Self {
Self { inner, policy }
}
}
#[async_trait]
impl<S: HttpSender> HttpSender for GuardedSender<S> {
async fn send(
&self,
request: SendableHttpRequest,
event_tx: mpsc::Sender<HttpResponseEvent>,
) -> yaak_http::error::Result<HttpResponse> {
if let Err(reason) = self.policy.check_url(&request.url) {
debug!("Refused {} {}: {reason}", request.method, request.url);
return Err(yaak_http::error::Error::RequestError(reason));
}
self.inner.send(request, event_tx).await
}
}
#[cfg(test)]
mod tests {
use super::*;
fn ip(s: &str) -> IpAddr {
s.parse().unwrap()
}
#[test]
fn refuses_the_ranges_a_relay_must_never_reach() {
for addr in [
"127.0.0.1",
"127.9.9.9",
"10.0.0.1",
"172.16.0.1",
"172.31.255.255",
"192.168.1.1",
"169.254.169.254",
"169.254.0.1",
"0.0.0.0",
"100.64.0.1",
"255.255.255.255",
"224.0.0.1",
"240.0.0.1",
"::1",
"::",
"fc00::1",
"fd12::1",
"fe80::1",
"::ffff:127.0.0.1",
"::ffff:169.254.169.254",
"64:ff9b::7f00:1",
"ff02::1",
] {
assert!(non_public_reason(ip(addr)).is_some(), "{addr} should be refused");
}
}
#[test]
fn allows_public_addresses() {
for addr in [
"1.1.1.1",
"8.8.8.8",
"93.184.216.34",
"172.32.0.1",
"2606:4700:4700::1111",
] {
assert!(non_public_reason(ip(addr)).is_none(), "{addr} should be allowed");
}
}
#[test]
fn literal_private_addresses_in_urls_are_refused() {
let policy = DestinationPolicy::new(false);
assert!(policy.check_url("http://127.0.0.1/").is_err());
assert!(policy.check_url("http://[::1]/").is_err());
assert!(policy.check_url("http://169.254.169.254/latest/meta-data").is_err());
}
#[test]
fn allow_private_networks_opens_the_local_ranges_but_not_other_schemes() {
let policy = DestinationPolicy::new(true);
assert!(policy.check_url("http://127.0.0.1/").is_ok());
assert!(policy.check_ip(ip("10.0.0.1")).is_ok());
assert!(policy.check_ip(ip("169.254.169.254")).is_ok());
assert!(policy.check_url("file:///etc/passwd").is_err());
}
#[test]
fn only_http_schemes() {
let policy = DestinationPolicy::new(false);
assert!(policy.check_url("ftp://example.com/").is_err());
assert!(policy.check_url("file:///etc/passwd").is_err());
assert!(policy.check_url("https://example.com/").is_ok());
}
}

View File

@@ -0,0 +1,295 @@
//! yaak-web: the network half of Yaak in a browser.
//!
//! A tab can't see a response the way a desktop app can — CORS hides most
//! headers, redirects are followed silently, there is no timeline. So the tab
//! renders the request and hands it here; this process puts it on the network
//! with the desktop's own engine and streams back everything that happened,
//! for the tab to store. It keeps nothing: no database, no files, no session.
//!
//! Embed [`router`] in your own Axum server, or run the `yaak-web` binary
//! configured by flags or `YAAK_WEB_*` environment variables.
//! See README.md for running and deploying it, and `guard.rs` for what it
//! refuses to talk to.
mod config;
mod guard;
mod limits;
mod send;
mod wire;
use axum::Router;
use axum::body::Body;
use axum::extract::{ConnectInfo, DefaultBodyLimit, Request, State};
use axum::http::{HeaderMap, HeaderValue, Method, StatusCode, header};
use axum::middleware::{self, Next};
use axum::response::{IntoResponse, Json, Redirect, Response};
use axum::routing::{get, post};
pub use config::Config;
use guard::DestinationPolicy;
use limits::RateLimiter;
use log::{debug, info, warn};
use send::{Refusal, SendLimits};
use serde_json::json;
use std::net::{IpAddr, SocketAddr};
use std::path::Path;
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::sync::Semaphore;
use tower_http::compression::CompressionLayer;
use tower_http::cors::{AllowOrigin, CorsLayer};
use tower_http::services::{ServeDir, ServeFile};
use wire::SendRequest;
/// Minimal operational logging; request diagnostics require an explicit `RUST_LOG` setting.
pub const DEFAULT_LOG_FILTER: &str = "warn,yaak_http=error,yaak_web::startup=info";
#[derive(Clone)]
struct AppState {
config: Arc<Config>,
limits: Arc<SendLimits>,
rate_limiter: Arc<RateLimiter>,
in_flight: Arc<Semaphore>,
}
/// Build the API router and, when configured, the web client's static-file fallback.
///
/// The returned router owns its state and accepts normal Axum middleware via `.layer()`.
/// Construction does not parse arguments, initialize logging, bind a socket, or install
/// shutdown handlers. `config.host` and `config.port` are used only by the standalone binary.
///
/// Serve with `into_make_service_with_connect_info::<SocketAddr>()` so the send endpoint
/// can extract the peer address for rate limiting.
pub fn router(config: Config) -> Router {
let policy = DestinationPolicy::new(config.allow_private_networks);
if config.allow_private_networks {
warn!(
"Sends to loopback, private and link-local addresses are ALLOWED. Only run this way \
on an instance strangers cannot reach"
);
}
let state = AppState {
limits: Arc::new(SendLimits {
policy,
max_response_bytes: config.max_response_bytes,
max_timeout: Duration::from_secs(config.max_timeout_secs),
}),
rate_limiter: Arc::new(RateLimiter::new(config.rate_limit_per_minute)),
in_flight: Arc::new(Semaphore::new(config.max_concurrent)),
config: Arc::new(config),
};
let cors = CorsLayer::new()
.allow_methods([Method::GET, Method::POST, Method::OPTIONS])
.allow_headers([header::CONTENT_TYPE])
.allow_origin(allowed_origins(&state.config.allowed_origins));
let api = Router::new()
.route("/v1/health", get(health))
// A WebSocket or gRPC relay would sit beside this as `/v1/ws/relay` and `/v1/grpc/relay`
// on the same router, behind the same policy, limits and auth. Not built; see README.
.route("/v1/http/send", post(send_http))
.layer(DefaultBodyLimit::max(state.config.max_request_bytes))
.layer(cors)
.with_state(state.clone());
match &state.config.serve {
Some(dir) => {
info!("Serving the web client from {}", dir.display());
api.merge(web_router(dir))
}
// Without `--serve` there is no app here, only `/v1`. Someone who opens this
// port in a browser guessed wrong about which of the two dev servers hosts the
// app, so send them to the right one when we have been told where it is.
None => {
let app_port = state.config.app_port;
api.fallback(move |headers: HeaderMap| async move { no_app_here(app_port, headers) })
}
}
}
/// The built web client, served on the same origin as the API.
///
/// This is what makes a single container zero-configuration: the tab's send URL is a path on
/// the page's own origin, so there is no CORS, no second service and no URL to bake in. It is
/// only a file server — a send behaves exactly as it does without this flag.
///
/// Merged as a fallback, so the `/v1` routes are matched first and a request that matches no
/// file at all gets `index.html` (the app routes client-side; a deep link must survive a
/// refresh).
fn web_router(dir: &Path) -> Router {
let index = ServeFile::new(dir.join("index.html"));
Router::new()
// `fallback`, not `not_found_service`: the app's own routes are real pages, so
// index.html is served with the 200 the browser expects, not a 404 carrying HTML.
.fallback_service(ServeDir::new(dir).fallback(index))
.layer(middleware::from_fn(cache_control))
.layer(CompressionLayer::new())
}
/// Vite gives everything in `/assets` a content-hashed name, so those can be cached forever.
/// Everything else — `index.html` above all, including the copy served for an unknown path —
/// must be revalidated, or a browser keeps serving the deploy before last.
async fn cache_control(req: Request, next: Next) -> Response {
let hashed_name = req.uri().path().starts_with("/assets/");
let mut res = next.run(req).await;
if !res.status().is_success() {
return res;
}
let is_html = res
.headers()
.get(header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.is_some_and(|v| v.starts_with("text/html"));
let value =
if hashed_name && !is_html { "public, max-age=31536000, immutable" } else { "no-cache" };
res.headers_mut().insert(header::CACHE_CONTROL, HeaderValue::from_static(value));
res
}
/// The name in a `Host` header, without the port this server is reached on.
///
/// An IPv6 literal keeps its brackets and its own colons: the last colon only separates a
/// port when what follows it isn't part of the address, which is what the `]` test decides.
fn host_without_port(host: &str) -> &str {
match host.rfind(':') {
Some(i) if !host[i..].contains(']') => &host[..i],
_ => host,
}
}
/// What the send executor does with a browser that came looking for the app.
///
/// With an app port configured this is a redirect, built from the `Host` header so the
/// hostname the browser already used is the one it keeps — this server has no idea which of
/// its addresses someone typed, and does not need to.
fn no_app_here(app_port: Option<u16>, headers: HeaderMap) -> Response {
if let Some(port) = app_port
&& let Some(host) = headers.get(header::HOST).and_then(|v| v.to_str().ok())
{
return Redirect::temporary(&format!("http://{}:{port}/", host_without_port(host)))
.into_response();
}
(
StatusCode::NOT_FOUND,
[(header::CONTENT_TYPE, "text/plain; charset=utf-8")],
"This is the Yaak send executor. It serves the API under /v1 and no app.\n\n\
To serve the app from here too, restart with --serve <DIR> pointing at a built\n\
web client.\n",
)
.into_response()
}
fn allowed_origins(origins: &[String]) -> AllowOrigin {
if origins.iter().any(|o| o.trim() == "*") {
return AllowOrigin::any();
}
let parsed: Vec<HeaderValue> =
origins.iter().filter_map(|o| HeaderValue::from_str(o.trim()).ok()).collect();
AllowOrigin::list(parsed)
}
async fn health(State(state): State<AppState>) -> impl IntoResponse {
Json(json!({
"ok": true,
"version": env!("CARGO_PKG_VERSION"),
"maxResponseBytes": state.config.max_response_bytes,
"maxTimeoutSecs": state.config.max_timeout_secs,
}))
}
fn error_response(status: StatusCode, message: impl Into<String>) -> Response {
let message = message.into();
(status, Json(json!({ "error": message }))).into_response()
}
/// The client's address for rate limiting: the socket peer, or the first `X-Forwarded-For`
/// hop when the operator has said the header can be trusted.
fn client_ip(config: &Config, headers: &HeaderMap, peer: SocketAddr) -> IpAddr {
if config.trust_forwarded_for
&& let Some(forwarded) = headers.get("x-forwarded-for").and_then(|v| v.to_str().ok())
&& let Some(first) = forwarded.split(',').next()
&& let Ok(ip) = first.trim().parse::<IpAddr>()
{
return ip;
}
peer.ip()
}
async fn send_http(
State(state): State<AppState>,
ConnectInfo(peer): ConnectInfo<SocketAddr>,
headers: HeaderMap,
Json(body): Json<SendRequest>,
) -> Response {
let ip = client_ip(&state.config, &headers, peer);
if let Err(wait) = state.rate_limiter.check(ip) {
debug!("Rate limited {ip}");
let mut res = error_response(
StatusCode::TOO_MANY_REQUESTS,
format!("Rate limit reached; try again in {}s", wait.as_secs().max(1)),
);
res.headers_mut().insert(header::RETRY_AFTER, HeaderValue::from(wait.as_secs().max(1)));
return res;
}
let Ok(permit) = state.in_flight.clone().try_acquire_owned() else {
debug!("At capacity; refusing {ip}");
return error_response(StatusCode::SERVICE_UNAVAILABLE, "This server is at capacity");
};
let prepared = match send::prepare(state.limits.clone(), body).await {
Ok(p) => p,
Err(Refusal::Unsupported(m)) => return error_response(StatusCode::BAD_REQUEST, m),
Err(Refusal::Invalid(m)) => return error_response(StatusCode::BAD_REQUEST, m),
Err(Refusal::Destination(m)) => {
debug!("Refused send from {ip}: {m}");
return error_response(StatusCode::FORBIDDEN, m);
}
};
let description = prepared.describe();
debug!("{ip} -> {description}");
let started = Instant::now();
let (tx, rx) = tokio::sync::mpsc::channel(send::FRAME_CHANNEL_CAPACITY);
tokio::spawn(async move {
prepared.run(tx).await;
send::log_outcome(&description, started, "finished");
drop(permit);
});
let stream = tokio_stream_from(rx);
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "application/x-ndjson")
.header(header::CACHE_CONTROL, "no-store")
// Some reverse proxies buffer streamed responses unless told not to
.header("x-accel-buffering", "no")
.body(Body::from_stream(stream))
.expect("valid response")
}
fn tokio_stream_from<T: Send + 'static>(
mut rx: tokio::sync::mpsc::Receiver<T>,
) -> impl futures_util::Stream<Item = T> + Send + 'static {
futures_util::stream::poll_fn(move |cx| rx.poll_recv(cx))
}
#[cfg(test)]
mod tests {
use super::host_without_port;
#[test]
fn strips_the_port_and_keeps_the_name() {
assert_eq!(host_without_port("home:9227"), "home");
assert_eq!(host_without_port("home"), "home");
assert_eq!(host_without_port("192.168.1.5:9227"), "192.168.1.5");
assert_eq!(host_without_port("192.168.1.5"), "192.168.1.5");
// An IPv6 literal is full of colons, and only the one outside the brackets is a port.
assert_eq!(host_without_port("[::1]:9227"), "[::1]");
assert_eq!(host_without_port("[::1]"), "[::1]");
assert_eq!(host_without_port("[2606:4700::1111]:8080"), "[2606:4700::1111]");
assert_eq!(host_without_port("[2606:4700::1111]"), "[2606:4700::1111]");
}
}

View File

@@ -0,0 +1,90 @@
//! Per-client rate limiting, kept deliberately small.
//!
//! One token bucket per client IP, refilled continuously, in a mutex-guarded
//! map that is swept of idle entries as it goes. Good enough to keep one
//! caller from monopolising a hosted instance; not a substitute for whatever
//! sits in front of it in production.
use std::collections::HashMap;
use std::net::IpAddr;
use std::sync::Mutex;
use std::time::{Duration, Instant};
pub struct RateLimiter {
per_minute: u32,
buckets: Mutex<HashMap<IpAddr, Bucket>>,
}
struct Bucket {
tokens: f64,
last: Instant,
}
impl RateLimiter {
/// `per_minute == 0` disables limiting.
pub fn new(per_minute: u32) -> Self {
Self { per_minute, buckets: Mutex::new(HashMap::new()) }
}
/// Take one token for `client`, or say how long until one is available.
pub fn check(&self, client: IpAddr) -> Result<(), Duration> {
if self.per_minute == 0 {
return Ok(());
}
let capacity = self.per_minute as f64;
let per_second = capacity / 60.0;
let now = Instant::now();
let mut buckets = self.buckets.lock().unwrap_or_else(|e| e.into_inner());
// Sweep buckets that have been idle long enough to be full again; there is nothing
// to remember about them.
if buckets.len() > 1024 {
buckets.retain(|_, b| now.duration_since(b.last).as_secs_f64() * per_second < capacity);
}
let bucket = buckets.entry(client).or_insert(Bucket { tokens: capacity, last: now });
let elapsed = now.duration_since(bucket.last).as_secs_f64();
bucket.tokens = (bucket.tokens + elapsed * per_second).min(capacity);
bucket.last = now;
if bucket.tokens >= 1.0 {
bucket.tokens -= 1.0;
Ok(())
} else {
let wait = (1.0 - bucket.tokens) / per_second;
Err(Duration::from_secs_f64(wait.max(0.001)))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_full_bucket_then_a_wait() {
let limiter = RateLimiter::new(3);
let ip: IpAddr = "203.0.113.5".parse().unwrap();
assert!(limiter.check(ip).is_ok());
assert!(limiter.check(ip).is_ok());
assert!(limiter.check(ip).is_ok());
let wait = limiter.check(ip).expect_err("fourth call in a burst should wait");
assert!(wait > Duration::ZERO && wait <= Duration::from_secs(20));
}
#[test]
fn clients_are_independent_and_zero_disables() {
let limiter = RateLimiter::new(1);
let a: IpAddr = "203.0.113.5".parse().unwrap();
let b: IpAddr = "203.0.113.6".parse().unwrap();
assert!(limiter.check(a).is_ok());
assert!(limiter.check(a).is_err());
assert!(limiter.check(b).is_ok());
let unlimited = RateLimiter::new(0);
for _ in 0..1000 {
assert!(unlimited.check(a).is_ok());
}
}
}

View File

@@ -0,0 +1,35 @@
//! Standalone server for Yaak in a browser.
use clap::Parser;
use log::info;
use std::net::SocketAddr;
use yaak_web::{Config, router};
#[tokio::main]
async fn main() {
env_logger::Builder::from_env(
env_logger::Env::default().default_filter_or(yaak_web::DEFAULT_LOG_FILTER),
)
.init();
let config = Config::parse();
let bind = config.listen_addr().unwrap_or_else(|e| {
eprintln!("{e}");
std::process::exit(1);
});
let rate_limit_per_minute = config.rate_limit_per_minute;
let app = router(config);
let listener = tokio::net::TcpListener::bind(bind).await.unwrap_or_else(|e| {
eprintln!("Failed to bind {bind}: {e}");
std::process::exit(1);
});
info!(target: "yaak_web::startup", "yaak-web listening on http://{bind} (rate limit: {}/min)", rate_limit_per_minute,);
axum::serve(listener, app.into_make_service_with_connect_info::<SocketAddr>())
.with_graceful_shutdown(async {
let _ = tokio::signal::ctrl_c().await;
info!("Shutting down");
})
.await
.expect("server error");
}

View File

@@ -0,0 +1,359 @@
//! The one thing this binary does: execute a rendered request and stream back what happened.
//!
//! This is the "execute" half of the desktop's `send_http_request` — the part after rendering
//! and before storage — driven through the same `HttpTransaction` the desktop drives, with the
//! same redirect loop, cookie jar, decompression and timeline events. Everything the desktop
//! would write to its database is written to the reply stream instead, and the tab stores it.
use crate::guard::{DestinationPolicy, GuardedSender};
use crate::wire::{Frame, SendRequest};
use base64::Engine;
use bytes::Bytes;
use log::{debug, warn};
use std::convert::Infallible;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{Duration, Instant};
use tokio::io::AsyncReadExt;
use tokio::sync::{mpsc, watch};
use yaak_http::client::{HttpConnectionOptions, HttpConnectionProxySetting};
use yaak_http::cookies::CookieStore;
use yaak_http::sender::{HttpResponseEvent, ReqwestSender};
use yaak_http::transaction::HttpTransaction;
use yaak_http::types::{SendableHttpRequest, SendableHttpRequestOptions};
use yaak_models::models::HttpResponseHeader;
/// How many frames may sit unread by the client before body reading pauses. Backpressure, so a
/// slow tab slows the upstream read rather than filling memory.
pub const FRAME_CHANNEL_CAPACITY: usize = 64;
const EVENT_CHANNEL_CAPACITY: usize = 256;
const BODY_READ_CHUNK: usize = 64 * 1024;
/// What a send needs from the process, beyond the request itself.
pub struct SendLimits {
pub policy: DestinationPolicy,
pub max_response_bytes: usize,
pub max_timeout: Duration,
}
/// Why a send was refused before anything was put on the network. Distinct from a failure
/// mid-stream: these become a plain HTTP error, not a stream with an error frame.
#[derive(Debug)]
pub enum Refusal {
/// The request asks for something a browser-originated send cannot mean.
Unsupported(String),
/// The destination is not one this server will talk to.
Destination(String),
/// The request could not be turned into something sendable.
Invalid(String),
}
pub type FrameSender = mpsc::Sender<Result<Bytes, Infallible>>;
/// Check and prepare a send, then hand back the task that runs it. Refusals happen here, before
/// the caller has committed to a streaming response.
pub async fn prepare(limits: Arc<SendLimits>, send: SendRequest) -> Result<PreparedSend, Refusal> {
let request = send.request;
// The engine reads files for these body types. There are no files here that a browser tab
// could legitimately mean, and letting a request name a path on this machine would be a
// local file read for anyone who can reach the server.
if request.body_type.as_deref() == Some("binary") {
return Err(Refusal::Unsupported(
"Binary file bodies can't be sent from the browser: the server has no access to your files"
.to_string(),
));
}
if request.body_type.as_deref() == Some("multipart/form-data") {
let names_a_file =
request.body.get("form").and_then(|f| f.as_array()).is_some_and(|entries| {
entries.iter().any(|e| {
e.get("enabled").and_then(|v| v.as_bool()).unwrap_or(true)
&& e.get("file").and_then(|v| v.as_str()).is_some_and(|f| !f.is_empty())
})
});
if names_a_file {
return Err(Refusal::Unsupported(
"Multipart file fields can't be sent from the browser: the server has no access to your files"
.to_string(),
));
}
}
// The tab's requested timeout, capped. Zero means "none", which here means the cap.
let requested = if send.settings.timeout_ms > 0 {
Some(Duration::from_millis(send.settings.timeout_ms as u64))
} else {
None
};
let timeout = requested.map_or(limits.max_timeout, |t| t.min(limits.max_timeout));
let timeout_capped = requested.is_none_or(|t| t > limits.max_timeout);
let sendable = SendableHttpRequest::from_http_request(
&request,
SendableHttpRequestOptions {
timeout: Some(timeout),
follow_redirects: send.settings.follow_redirects,
},
)
.await
.map_err(|e| Refusal::Invalid(e.to_string()))?;
// The first hop, checked up front so a bad destination is a clean refusal rather than a
// stream that opens and immediately errors. Every later hop is checked by GuardedSender.
limits.policy.check_url(&sendable.url).map_err(Refusal::Destination)?;
Ok(PreparedSend {
limits,
sendable,
settings: send.settings,
cookies: send.cookies,
timeout,
timeout_capped,
})
}
pub struct PreparedSend {
limits: Arc<SendLimits>,
sendable: SendableHttpRequest,
settings: yaak_models::models::HttpSendSettings,
cookies: Option<Vec<yaak_models::models::Cookie>>,
timeout: Duration,
timeout_capped: bool,
}
impl PreparedSend {
pub fn describe(&self) -> String {
format!("{} {}", self.sendable.method, self.sendable.url)
}
/// Run the send, writing frames to `frames` until the terminal frame. Returns when the
/// stream is complete or the client has gone away.
pub async fn run(mut self, frames: FrameSender) {
let cookie_store = self.cookies.take().map(CookieStore::from_cookies);
let store_for_result = cookie_store.clone();
let outcome = self.execute(frames.clone(), cookie_store).await;
let cookies = store_for_result.as_ref().map(|s| s.get_all_cookies());
let terminal = match outcome {
Ok(done) => Frame::Done {
elapsed: done.elapsed,
content_length: done.content_length,
content_length_compressed: done.content_length_compressed,
cookies,
},
Err(message) => Frame::Error { message, cookies },
};
let _ = write_frame(&frames, &terminal).await;
}
async fn execute(
self,
frames: FrameSender,
cookie_store: Option<CookieStore>,
) -> Result<DoneStats, String> {
let limits = self.limits;
let (client, resolver) = HttpConnectionOptions {
id: uuid::Uuid::new_v4().to_string(),
validate_certificates: self.settings.validate_certificates,
http_version: self.settings.http_version,
// The proxy connects directly. Going through a system proxy would move DNS, and
// therefore the address check, somewhere this process can't see.
proxy: HttpConnectionProxySetting::Disabled,
client_certificate: None,
dns_overrides: Vec::new(),
address_filter: Some(limits.policy.address_filter()),
}
.build_client()
.map_err(|e| format!("Failed to build HTTP client: {e}"))?;
// Timeline events go into the same frame stream as everything else, as they happen.
// The desktop persists them from a task like this one; here the task serialises them.
let (event_tx, mut event_rx) = mpsc::channel::<HttpResponseEvent>(EVENT_CHANNEL_CAPACITY);
resolver.set_event_sender(Some(event_tx.clone())).await;
let dns_elapsed = Arc::new(AtomicU64::new(0));
let event_frames = frames.clone();
let event_dns = dns_elapsed.clone();
let event_task = tokio::spawn(async move {
while let Some(event) = event_rx.recv().await {
if let HttpResponseEvent::DnsResolved { duration, .. } = &event {
event_dns.store(*duration, Ordering::Relaxed);
}
let frame = Frame::Event { event: event.into() };
if write_frame(&event_frames, &frame).await.is_err() {
break;
}
}
});
// Cancellation: the client hanging up, or the overall deadline. The deadline exists
// because a per-hop timeout times each hop separately; ten slow redirects must not add
// up to ten timeouts.
let (cancel_tx, cancel_rx) = watch::channel(false);
let deadline = self.timeout * 2 + Duration::from_secs(5);
let deadline_cancel = cancel_tx.clone();
let deadline_task = tokio::spawn(async move {
tokio::time::sleep(deadline).await;
let _ = deadline_cancel.send(true);
});
let hangup_frames = frames.clone();
let hangup_task = tokio::spawn(async move {
hangup_frames.closed().await;
let _ = cancel_tx.send(true);
});
if self.timeout_capped {
let _ = event_tx.try_send(HttpResponseEvent::Info(format!(
"Timeout set to {:?} (this server's ceiling)",
self.timeout
)));
}
let sender = GuardedSender::new(ReqwestSender::with_client(client), limits.policy.clone());
let transaction = match cookie_store {
Some(store) => HttpTransaction::with_cookie_behavior(
sender,
store,
self.settings.send_cookies,
self.settings.store_cookies,
),
None => HttpTransaction::new(sender),
};
let started_at = Instant::now();
let result = transaction
.execute_with_cancellation(self.sendable, cancel_rx.clone(), event_tx.clone())
.await;
resolver.set_event_sender(None).await;
let mut response = match result {
Ok(response) => response,
Err(err) => {
drop(event_tx);
let _ = event_task.await;
deadline_task.abort();
hangup_task.abort();
return Err(describe_error(&err));
}
};
let elapsed_headers = started_at.elapsed().as_millis() as u64;
let head = Frame::Response {
status: response.status,
status_reason: response.status_reason.clone(),
url: response.url.clone(),
remote_addr: response.remote_addr.clone(),
version: response.version.clone(),
headers: to_wire_headers(&response.headers),
request_headers: to_wire_headers(&response.request_headers),
content_length: response.content_length,
elapsed_headers,
elapsed_dns: dns_elapsed.load(Ordering::Relaxed),
};
write_frame(&frames, &head).await.map_err(|_| "Client went away".to_string())?;
let declared_length = response.content_length;
let mut body = response
.into_body_stream()
.map_err(|e| format!("Failed to read response body: {e}"))?;
let mut buf = vec![0u8; BODY_READ_CHUNK];
let mut total: usize = 0;
let mut cancel_rx = cancel_rx;
let base64 = base64::engine::general_purpose::STANDARD;
let read_result: Result<(), String> = loop {
if *cancel_rx.borrow() {
break Err("Request canceled".to_string());
}
let read = tokio::select! {
biased;
_ = cancel_rx.changed() => break Err("Request canceled".to_string()),
r = body.read(&mut buf) => r,
};
match read {
Ok(0) => break Ok(()),
Ok(n) => {
total += n;
if total > limits.max_response_bytes {
break Err(format!(
"Response body exceeds this server's limit of {} bytes",
limits.max_response_bytes
));
}
let frame = Frame::Body { data: base64.encode(&buf[..n]) };
if write_frame(&frames, &frame).await.is_err() {
break Err("Client went away".to_string());
}
}
Err(e) => break Err(format!("Failed to read response body: {e}")),
}
};
drop(body);
// Let the timeline drain before the terminal frame, so nothing arrives after "done".
drop(event_tx);
let _ = event_task.await;
deadline_task.abort();
hangup_task.abort();
read_result?;
Ok(DoneStats {
elapsed: started_at.elapsed().as_millis() as u64,
content_length: total as u64,
content_length_compressed: declared_length.unwrap_or(total as u64),
})
}
}
/// A send error as a sentence, not a debug dump.
///
/// A connection error from reqwest arrives wrapped several layers deep, and the layer that
/// says something useful — "Refusing to connect to ::1: loopback" — is the innermost. The
/// desktop shows the outer `Debug`; a stranger reading its reply deserves the reason.
fn describe_error(err: &yaak_http::error::Error) -> String {
match err {
yaak_http::error::Error::Client(e) => {
let mut leaf: &dyn std::error::Error = e;
while let Some(next) = leaf.source() {
leaf = next;
}
let outer = e.to_string();
let inner = leaf.to_string();
if inner == outer { outer } else { format!("{outer}: {inner}") }
}
yaak_http::error::Error::RequestError(message) => format!("Request failed: {message}"),
other => other.to_string(),
}
}
struct DoneStats {
elapsed: u64,
content_length: u64,
content_length_compressed: u64,
}
fn to_wire_headers(headers: &[(String, String)]) -> Vec<HttpResponseHeader> {
headers
.iter()
.map(|(name, value)| HttpResponseHeader { name: name.clone(), value: value.clone() })
.collect()
}
async fn write_frame(frames: &FrameSender, frame: &Frame) -> Result<(), ()> {
let mut line = match serde_json::to_vec(frame) {
Ok(v) => v,
Err(e) => {
warn!("Failed to serialize response frame");
debug!("Frame serialization error: {e}");
return Err(());
}
};
line.push(b'\n');
frames.send(Ok(Bytes::from(line))).await.map_err(|_| ())
}
/// Request diagnostics are opt-in, including destinations and completion timing.
pub fn log_outcome(description: &str, started: Instant, outcome: &str) {
debug!("{description} -> {outcome} in {:?}", started.elapsed());
}

View File

@@ -0,0 +1,97 @@
//! What crosses the wire between a tab and this server.
//!
//! One `POST /v1/http/send` carries a request the tab has already rendered —
//! templates resolved, inheritance applied — plus the send settings and the
//! cookies the send starts with. The reply is a stream of newline-delimited
//! JSON frames: timeline events as they happen, the response head as soon as
//! headers arrive, body chunks as they are read, and one terminal frame.
//!
//! Nothing here names a workspace, a request id, or a response id. The server
//! does not know what the tab will call this response; it only knows what came
//! back.
//!
//! The TypeScript side of this contract is generated from these types into
//! `bindings/` (`cargo test -p yaak-web`) and published to the tab as
//! `@yaakapp-internal/web`, so a change here is a type error there.
use serde::{Deserialize, Serialize};
use ts_rs::TS;
use yaak_models::models::{
Cookie, HttpRequest, HttpResponseEventData, HttpResponseHeader, HttpSendSettings,
};
/// The body of `POST /v1/http/send`.
#[derive(Deserialize, Debug, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "gen_web.ts")]
pub struct SendRequest {
/// The request to send, in the desktop's own model shape but with every template already
/// rendered by the tab. The server builds the URL, headers and body from it exactly the way
/// the desktop does after rendering.
pub request: HttpRequest,
/// The resolved settings, values only. Where they came from is the tab's to record in
/// its timeline; the server only needs to obey them.
pub settings: HttpSendSettings,
/// The cookies to start with. `None` means no jar at all: nothing sent, nothing kept.
#[serde(default)]
pub cookies: Option<Vec<Cookie>>,
}
/// One line of the reply stream. Tags are snake_case like the timeline event tags; fields are
/// camelCase like every model the tab stores.
#[derive(Serialize, Debug, TS)]
#[serde(
tag = "type",
rename_all = "snake_case",
rename_all_fields = "camelCase"
)]
#[ts(export, export_to = "gen_web.ts")]
pub enum Frame {
/// A timeline event, in the same shape the desktop stores. Interleaved with everything
/// else in the order the engine produced it.
Event { event: HttpResponseEventData },
/// The response head. Sent once, as soon as the final hop's headers are in — before any of
/// the body — so the tab can show status and headers while the body streams.
Response {
status: u16,
status_reason: Option<String>,
/// The URL that answered, after redirects.
url: String,
remote_addr: Option<String>,
version: Option<String>,
headers: Vec<HttpResponseHeader>,
/// The headers that were actually sent on the final hop, cookies and all.
request_headers: Vec<HttpResponseHeader>,
/// `Content-Length` as declared by the server, if it declared one.
#[ts(type = "number | null")]
content_length: Option<u64>,
/// Milliseconds from the start of the send to the response head.
#[ts(type = "number")]
elapsed_headers: u64,
/// Milliseconds spent in DNS on the last lookup, or zero.
#[ts(type = "number")]
elapsed_dns: u64,
},
/// A piece of the response body, decompressed, base64-encoded.
Body { data: String },
/// The send finished. The last frame on a successful stream.
Done {
/// Milliseconds from the start of the send to the end of the body.
#[ts(type = "number")]
elapsed: u64,
/// Bytes of body relayed, after decompression.
#[ts(type = "number")]
content_length: u64,
/// Bytes on the wire as declared by the server, or the relayed size when unknown.
#[ts(type = "number")]
content_length_compressed: u64,
/// The jar as the send left it, for the tab to persist. `None` when the tab sent none.
cookies: Option<Vec<Cookie>>,
},
/// The send failed. The last frame on a failed stream. Cookies collected before the failure
/// still come back — the transaction may have set some before the hop that failed.
Error {
message: String,
cookies: Option<Vec<Cookie>>,
},
}

View File

@@ -0,0 +1,98 @@
use axum::Router;
use axum::body::{Body, to_bytes};
use axum::extract::{ConnectInfo, Request};
use axum::http::StatusCode;
use axum::routing::get;
use clap::Parser;
use std::io::{self, Write};
use std::net::SocketAddr;
use std::sync::{Arc, Mutex};
use tower::ServiceExt;
use yaak_web::{Config, DEFAULT_LOG_FILTER, router};
#[derive(Clone)]
struct LogCapture(Arc<Mutex<Vec<u8>>>);
impl Write for LogCapture {
fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
self.0.lock().unwrap().extend_from_slice(bytes);
Ok(bytes.len())
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
fn send_request(url: &str) -> Request {
let payload = serde_json::json!({
"request": { "url": url, "method": "GET" },
"settings": {
"validateCertificates": true,
"followRedirects": true,
"timeoutMs": 2000,
"sendCookies": true,
"storeCookies": true
}
});
Request::builder()
.method("POST")
.uri("/v1/http/send")
.header("content-type", "application/json")
.extension(ConnectInfo("192.0.2.42:1234".parse::<SocketAddr>().unwrap()))
.body(Body::from(payload.to_string()))
.unwrap()
}
#[tokio::test]
async fn default_logging_keeps_operational_warnings_without_request_details() {
let capture = LogCapture(Arc::new(Mutex::new(Vec::new())));
env_logger::Builder::new()
.parse_filters(DEFAULT_LOG_FILTER)
.target(env_logger::Target::Pipe(Box::new(capture.clone())))
.init();
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
let upstream = tokio::spawn(async move {
axum::serve(
listener,
Router::new().route("/private-path", get(|| async { "secret body" })),
)
.await
.unwrap();
});
let mut config = Config::parse_from(["yaak-web"]);
config.allow_private_networks = true;
config.rate_limit_per_minute = 1;
let app = router(config.clone());
assert!(String::from_utf8_lossy(&capture.0.lock().unwrap()).contains("ALLOWED"));
capture.0.lock().unwrap().clear();
// Exercise the HTTP engine and DNS lookup as well as the proxy's own logs.
let url = format!("http://localhost:{port}/private-path?token=secret");
let response = app.clone().oneshot(send_request(&url)).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = to_bytes(response.into_body(), 64 * 1024).await.unwrap();
assert!(String::from_utf8_lossy(&body).contains("\"type\":\"done\""));
assert_eq!(
app.oneshot(send_request(&url)).await.unwrap().status(),
StatusCode::TOO_MANY_REQUESTS
);
config.allow_private_networks = false;
let app = router(config.clone());
assert_eq!(
app.oneshot(send_request("http://127.0.0.1/private-path")).await.unwrap().status(),
StatusCode::FORBIDDEN
);
config.max_concurrent = 0;
assert_eq!(
router(config).oneshot(send_request(&url)).await.unwrap().status(),
StatusCode::SERVICE_UNAVAILABLE
);
upstream.abort();
let logs = capture.0.lock().unwrap();
assert!(logs.is_empty(), "Unexpected request logs: {}", String::from_utf8_lossy(&logs));
}

View File

@@ -0,0 +1,89 @@
use axum::body::{Body, to_bytes};
use axum::extract::Request;
use axum::http::{HeaderValue, StatusCode, header};
use axum::middleware::{self, Next};
use axum::response::Response;
use std::path::PathBuf;
use tower::ServiceExt;
use yaak_web::{Config, router};
fn config(serve: Option<PathBuf>) -> Config {
Config {
host: "127.0.0.1".to_string(),
port: 0,
app_port: None,
serve,
allow_private_networks: false,
allowed_origins: vec!["*".into()],
max_request_bytes: 1024,
max_response_bytes: 2048,
max_timeout_secs: 5,
rate_limit_per_minute: 10,
max_concurrent: 2,
trust_forwarded_for: false,
}
}
async fn mark_response(request: Request, next: Next) -> Response {
let mut response = next.run(request).await;
response.headers_mut().insert("x-hosted-middleware", HeaderValue::from_static("yes"));
response
}
#[tokio::test]
async fn embedded_router_serves_api_without_static_files() {
let app = router(config(None)).layer(middleware::from_fn(mark_response));
let response = app
.clone()
.oneshot(Request::builder().uri("/v1/health").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(response.headers()["x-hosted-middleware"], "yes");
let body = to_bytes(response.into_body(), 4096).await.unwrap();
let health: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(health["ok"], true);
assert_eq!(health["maxResponseBytes"], 2048);
assert_eq!(health["maxTimeoutSecs"], 5);
let response = app.oneshot(Request::new(Body::empty())).await.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn middleware_wraps_html_fallback_and_assets_alongside_api() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("index.html"), "<html>Yaak</html>").unwrap();
std::fs::create_dir(dir.path().join("assets")).unwrap();
std::fs::write(dir.path().join("assets/app-abc.js"), "// app").unwrap();
let app = router(config(Some(dir.path().into()))).layer(middleware::from_fn(mark_response));
for path in [
"/",
"/workspace/example",
"/assets/app-abc.js",
"/v1/health",
] {
let response = app
.clone()
.oneshot(Request::builder().uri(path).body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK, "{path}");
assert_eq!(response.headers()["x-hosted-middleware"], "yes", "{path}");
if path.starts_with("/assets/") {
assert_eq!(
response.headers()[header::CACHE_CONTROL],
"public, max-age=31536000, immutable"
);
} else if path == "/v1/health" {
assert_eq!(response.headers()[header::CONTENT_TYPE], "application/json");
} else {
assert_eq!(response.headers()[header::CACHE_CONTROL], "no-cache");
assert!(
response.headers()[header::CONTENT_TYPE].to_str().unwrap().starts_with("text/html")
);
assert_eq!(to_bytes(response.into_body(), 4096).await.unwrap(), "<html>Yaak</html>");
}
}
}