summaryrefslogtreecommitdiff
path: root/crates/chat-server/src/app/http.rs
diff options
context:
space:
mode:
authorDoan Luu Duc Tai <doan@luuduct.ai>2026-10-31 13:08:08 -0500
committerDoan Luu Duc Tai <doan@luuduct.ai>2026-10-31 13:08:08 -0500
commita80df869a92c330fbad9695057720b11a18af901 (patch)
tree5307272821cb3adc3f0348413a3b904290890044 /crates/chat-server/src/app/http.rs
parent9139398d465f5899cc31bc4cdd54daa7392ca550 (diff)
Forbid Rust lints and enforce strict typed TypeScript linting
Diffstat (limited to 'crates/chat-server/src/app/http.rs')
-rw-r--r--crates/chat-server/src/app/http.rs243
1 files changed, 243 insertions, 0 deletions
diff --git a/crates/chat-server/src/app/http.rs b/crates/chat-server/src/app/http.rs
new file mode 100644
index 0000000..5b1941a
--- /dev/null
+++ b/crates/chat-server/src/app/http.rs
@@ -0,0 +1,243 @@
+// Copyright (C) 2026 Doan Luu Duc Tai <doan@luuduct.ai>
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+use crate::app::service::Service;
+use anyhow::Result;
+use axum::{
+ Json, Router,
+ body::Body,
+ extract::{DefaultBodyLimit, State},
+ http::{HeaderMap, HeaderValue, StatusCode},
+ response::{IntoResponse, Redirect, Response},
+ routing::{get, post},
+};
+use serde_json::{Value, json};
+use std::path::{Path, PathBuf};
+use std::sync::{Arc, Mutex};
+use tokio::sync::Semaphore;
+
+#[derive(Clone)]
+struct App {
+ service: Arc<Mutex<Service>>,
+ permits: Arc<Semaphore>,
+ root: PathBuf,
+ origin: String,
+ public: std::collections::BTreeMap<String, String>,
+}
+
+fn secured(mut response: Response) -> Response {
+ for (name, value) in [
+ ("cache-control", "no-store, max-age=0"),
+ (
+ "content-security-policy",
+ "default-src 'none'; script-src 'self' 'wasm-unsafe-eval'; worker-src 'self'; connect-src 'self'; style-src 'self'; img-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'; object-src 'none'",
+ ),
+ ("referrer-policy", "no-referrer"),
+ ("x-content-type-options", "nosniff"),
+ ("cross-origin-opener-policy", "same-origin"),
+ ("cross-origin-resource-policy", "same-origin"),
+ (
+ "permissions-policy",
+ "camera=(), microphone=(), geolocation=(), payment=(), usb=()",
+ ),
+ ] {
+ response
+ .headers_mut()
+ .insert(name, HeaderValue::from_static(value));
+ }
+ response
+}
+
+async fn api(State(app): State<App>, headers: HeaderMap, Json(request): Json<Value>) -> Response {
+ if headers.get("origin").and_then(|value| value.to_str().ok()) != Some(app.origin.as_str()) {
+ return secured(
+ (
+ StatusCode::FORBIDDEN,
+ Json(json!({"error":"Origin rejected"})),
+ )
+ .into_response(),
+ );
+ }
+ let Ok(permit) = Arc::clone(&app.permits).try_acquire_owned() else {
+ return secured(
+ (
+ StatusCode::TOO_MANY_REQUESTS,
+ Json(json!({"error":"Server busy"})),
+ )
+ .into_response(),
+ );
+ };
+ let result = tokio::task::spawn_blocking(move || {
+ let _permit = permit;
+ app.service
+ .lock()
+ .map_err(|_| anyhow::anyhow!("Service unavailable"))?
+ .call(&request)
+ })
+ .await;
+ match result {
+ Ok(Ok(value)) => secured(Json(value).into_response()),
+ _ => secured((StatusCode::BAD_REQUEST,Json(json!({"error":"Request rejected. Unlock again if your session expired; check credentials, message limits, and conversation access."}))).into_response()),
+ }
+}
+
+fn packaged_asset<'a>(root: &Path, packaged: &'a str, development: &'a str) -> &'a str {
+ if root.join(packaged).is_file() {
+ packaged
+ } else {
+ development
+ }
+}
+
+fn asset_location(root: &Path, path: &str) -> Option<(&'static str, &'static str)> {
+ let assets = [
+ (
+ "/",
+ "bin/chat/web/index.html",
+ "crates/chat-server/web/index.html",
+ "text/html; charset=utf-8",
+ ),
+ (
+ "/app.js",
+ "bin/chat/web/app.js",
+ "var/build/typescript/crates/chat-server/web/app.js",
+ "text/javascript; charset=utf-8",
+ ),
+ (
+ "/worker.js",
+ "bin/chat/web/worker.js",
+ "var/build/typescript/crates/chat-server/web/worker.js",
+ "text/javascript; charset=utf-8",
+ ),
+ (
+ "/wasi.mjs",
+ "bin/chat/wasi.mjs",
+ "var/build/typescript/crates/chat-crypto/browser/wasi.js",
+ "text/javascript; charset=utf-8",
+ ),
+ (
+ "/style.css",
+ "bin/chat/web/style.css",
+ "crates/chat-server/web/style.css",
+ "text/css; charset=utf-8",
+ ),
+ (
+ "/crypto.wasm",
+ "bin/chat/crypto.wasm",
+ "target/wasm32-wasip1/release/stack-chat-crypto.wasm",
+ "application/wasm",
+ ),
+ (
+ "/javascript.html",
+ "bin/chat/web/javascript.html",
+ "crates/chat-server/web/javascript.html",
+ "text/html; charset=utf-8",
+ ),
+ (
+ "/SOURCE.tar.gz",
+ "bin/chat/SOURCE.tar.gz",
+ "var/build/chat-source.tar.gz",
+ "application/gzip",
+ ),
+ (
+ "/LIMITATIONS.txt",
+ "docs/CHAT-APPLICATION.txt",
+ "docs/CHAT-APPLICATION.txt",
+ "text/plain; charset=utf-8",
+ ),
+ (
+ "/LICENSE",
+ "LICENSE",
+ "LICENSE",
+ "text/plain; charset=utf-8",
+ ),
+ ];
+ assets
+ .into_iter()
+ .find(|(route, _, _, _)| *route == path)
+ .map(|(_, packaged, development, mime)| (packaged_asset(root, packaged, development), mime))
+}
+
+async fn asset(State(app): State<App>, uri: axum::http::Uri) -> Response {
+ if uri.path() == "/THIRD-PARTY-LICENSES.html" {
+ let Some(origin) = app.public.get("SITE_ORIGIN") else {
+ return secured(StatusCode::INTERNAL_SERVER_ERROR.into_response());
+ };
+ return secured(
+ Redirect::permanent(&format!("{origin}/THIRD-PARTY-LICENSES.html")).into_response(),
+ );
+ }
+ let Some((path, mime)) = asset_location(&app.root, uri.path()) else {
+ return secured(StatusCode::NOT_FOUND.into_response());
+ };
+ if matches!(uri.path(), "/" | "/javascript.html") {
+ let Ok(bytes) = tokio::fs::read(app.root.join(path)).await else {
+ return secured(StatusCode::NOT_FOUND.into_response());
+ };
+ let rendered = String::from_utf8(bytes)
+ .map_err(anyhow::Error::from)
+ .and_then(|source| stack_config::render(&source, &app.public));
+ return rendered.map_or_else(
+ |_| secured(StatusCode::INTERNAL_SERVER_ERROR.into_response()),
+ |html| secured(([("content-type", mime)], html).into_response()),
+ );
+ }
+ let Ok(file) = tokio::fs::File::open(app.root.join(path)).await else {
+ return secured(StatusCode::NOT_FOUND.into_response());
+ };
+ let length = file.metadata().await.ok().map(|metadata| metadata.len());
+ let mut response = Response::new(Body::from_stream(tokio_util::io::ReaderStream::new(file)));
+ response
+ .headers_mut()
+ .insert("content-type", HeaderValue::from_static(mime));
+ if let Some(header_length) =
+ length.and_then(|value| HeaderValue::from_str(&value.to_string()).ok())
+ {
+ response
+ .headers_mut()
+ .insert("content-length", header_length);
+ }
+ secured(response)
+}
+
+pub(super) async fn serve(
+ listener: tokio::net::TcpListener,
+ service: Service,
+ root: &Path,
+ public: std::collections::BTreeMap<String, String>,
+) -> Result<()> {
+ let app = App {
+ origin: service.origin.clone(),
+ service: Arc::new(Mutex::new(service)),
+ permits: Arc::new(Semaphore::new(4)),
+ root: root.to_path_buf(),
+ public,
+ };
+ let routes = Router::new()
+ .route(
+ "/healthz",
+ get(|| async { secured("ok\n".into_response()) }),
+ )
+ .route("/api", post(api))
+ .fallback(get(asset))
+ .layer(DefaultBodyLimit::max(262_144))
+ .with_state(app);
+ axum::serve(listener, routes)
+ .with_graceful_shutdown(async {
+ drop(tokio::signal::ctrl_c().await);
+ })
+ .await?;
+ Ok(())
+}