use crate::browse::Browser;
use crate::config::{self, Paths};
use crate::platform;
use crate::receive::{self, Payload};
use crate::render::{self, Renderer};
use crate::store::{Doc, Store};
use axum::{
body::Body,
extract::{Path, Query, State},
http::{header, HeaderMap, HeaderValue, StatusCode},
response::{
sse::{Event, KeepAlive, Sse},
Html, IntoResponse, Response,
},
routing::{get, post},
Json, Router,
};
use serde::Deserialize;
use serde_json::json;
use std::convert::Infallible;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use std::time::Instant;
use tokio::sync::broadcast;
use tokio_stream::{wrappers::BroadcastStream, StreamExt};
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
pub const BUILD_SHA: &str = env!("SNYVI_GIT_SHA");
pub const BUILD_TARGET: &str = env!("SNYVI_TARGET");
const INDEX_HTML: &str = include_str!("../ui/index.html");
const APP_CSS: &str = include_str!("../ui/app.css");
const APP_JS: &str = include_str!("../ui/app.js");
const BOOT_JS: &str = include_str!("../ui/boot.js");
const MERMAID_JS_GZ: &[u8] = include_bytes!("../ui/mermaid.min.js.gz");
const CSP: &str = "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'";
const FONTS: &[(&str, &[u8])] = &[
("inter.woff2", include_bytes!("../ui/fonts/inter.woff2")),
(
"inter-italic.woff2",
include_bytes!("../ui/fonts/inter-italic.woff2"),
),
(
"jetbrains-mono.woff2",
include_bytes!("../ui/fonts/jetbrains-mono.woff2"),
),
(
"source-serif.woff2",
include_bytes!("../ui/fonts/source-serif.woff2"),
),
(
"source-serif-italic.woff2",
include_bytes!("../ui/fonts/source-serif-italic.woff2"),
),
];
pub struct App {
pub store: Store,
pub renderer: Renderer,
pub browse: Browser,
pub paths: Paths,
pub token: std::sync::RwLock<String>,
pub events: broadcast::Sender<String>,
pub shutdown: broadcast::Sender<()>,
pub started: Instant,
pub asset_v: String,
pub last_focus: std::sync::Mutex<Instant>,
pub windows: AtomicUsize,
pub streams: AtomicUsize,
pub online: std::sync::Mutex<std::collections::BTreeMap<String, usize>>,
}
impl App {
pub fn online(&self) -> serde_json::Value {
let m = self.online.lock().unwrap_or_else(|e| e.into_inner());
json!(*m)
}
pub fn has_window(&self) -> bool {
self.windows.load(Ordering::Relaxed) > 0
}
}
type S = State<Arc<App>>;
const QUEUE_MAX: usize = 500;
const QUEUE_BOOT: usize = 24;
fn waiting(app: &App) -> i64 {
app.store.waiting().unwrap_or(0)
}
pub async fn run(paths: Paths) -> anyhow::Result<()> {
let token = config::load_or_create_token(&paths)?;
let store = Store::open(&paths)?;
let renderer = Renderer::new();
let (tx, _) = broadcast::channel(64);
let (stop_tx, mut stop_rx) = broadcast::channel::<()>(1);
let asset_v = {
let mut h = blake3::Hasher::new();
h.update(INDEX_HTML.as_bytes());
h.update(APP_CSS.as_bytes());
h.update(APP_JS.as_bytes());
h.update(VERSION.as_bytes());
h.update(MERMAID_JS_GZ);
h.finalize().to_hex()[..8].to_string()
};
let app = Arc::new(App {
store,
renderer,
browse: Browser::new(),
paths: paths.clone(),
token: std::sync::RwLock::new(token),
events: tx,
shutdown: stop_tx,
started: Instant::now(),
asset_v,
last_focus: std::sync::Mutex::new(Instant::now() - std::time::Duration::from_secs(60)),
windows: AtomicUsize::new(0),
streams: AtomicUsize::new(0),
online: std::sync::Mutex::new(Default::default()),
});
crate::watch::spawn_browse_watcher(app.clone());
let router = Router::new()
.route("/", get(shell_home))
.route("/connect", get(shell_connect))
.route("/d/{id}", get(shell_doc))
.route("/b/{id}", get(shell_browse))
.route("/b/{id}/{*path}", get(shell_browse_file))
.route("/assets/app.css", get(asset_css))
.route("/assets/app.js", get(asset_js))
.route("/assets/boot.js", get(asset_boot))
.route("/assets/mermaid.js", get(asset_mermaid))
.route("/files/{id}/{*path}", get(doc_file))
.route("/assets/fonts/{name}", get(asset_font))
.route("/api/health", get(health))
.route("/api/about", get(about))
.route("/api/agents", get(agents))
.route("/api/tree", get(tree))
.route("/api/projects/{id}/tree", get(project_tree))
.route("/api/workflows/{id}/tree", get(workflow_tree))
.route("/api/inbox", get(inbox))
.route("/api/search", get(search))
.route("/api/docs", post(receive_doc))
.route("/api/docs/{id}", get(doc_json))
.route("/api/docs/{id}/pin", post(pin))
.route("/api/docs/{id}/read", post(mark_read))
.route("/api/queue", get(queue))
.route("/api/queue/clear", post(clear_queue))
.route("/api/docs/{id}/delete", post(delete_doc))
.route("/api/docs/{id}/undelete", post(undelete_doc))
.route("/api/docs/{id}/history", get(history))
.route("/api/projects/{id}/rename", post(rename_project))
.route("/api/workflows/{id}/rename", post(rename_workflow))
.route("/api/docs/{id}/split", get(doc_split))
.route("/api/docs/{id}/outline", get(doc_outline))
.route("/api/focus", post(focus))
.route("/api/shutdown", post(shutdown))
.route("/api/reset", get(reset_census).post(reset))
.route("/api/terminal", post(terminal))
.route("/api/browse", get(browse_list).post(browse_open))
.route("/api/browse/{id}/close", post(browse_close))
.route("/api/browse/{id}/tree", get(browse_tree))
.route("/api/browse/{id}/file", get(browse_file))
.route("/api/browse/{id}/raw", get(browse_raw))
.route("/api/browse/{id}/raw/{*path}", get(browse_raw_path))
.route("/api/browse/{id}/find", get(browse_find))
.route("/api/browse/{id}/outline", get(browse_outline))
.route("/api/docs/{id}/raw", get(doc_raw))
.route("/api/docs/{id}/blob", get(doc_blob))
.route("/api/compare/{a}/{b}", get(compare))
.route("/api/events", get(events))
.with_state(app);
let addr = format!("127.0.0.1:{}", config::port());
let listener = tokio::net::TcpListener::bind(&addr).await?;
eprintln!("snyvi {VERSION} listening on http://{addr}");
axum::serve(listener, router)
.with_graceful_shutdown(async move {
let term = async {
#[cfg(unix)]
{
let mut sig =
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
.expect("SIGTERM handler");
sig.recv().await;
}
#[cfg(not(unix))]
std::future::pending::<()>().await;
};
tokio::select! {
_ = tokio::signal::ctrl_c() => {},
_ = term => {},
_ = stop_rx.recv() => {},
}
})
.await?;
Ok(())
}
const TREE_WORKFLOWS: usize = 10;
const TREE_DOCS: usize = 10;
fn project_rows(
app: &App,
project_id: i64,
workflows: usize,
docs: usize,
whole: Option<i64>,
) -> Vec<crate::store::TreeWorkflow> {
let mut wfs = app
.store
.project_tree(project_id, workflows, docs)
.unwrap_or_default();
if let Some(id) = whole {
if let Ok(Some(full)) = app.store.workflow_tree(id) {
match wfs.iter().position(|w| w.id == full.id) {
Some(at) => wfs[at] = full,
None => wfs.insert(0, full),
}
}
}
wfs
}
fn subtree(app: &App, project_id: i64, whole: Option<i64>) -> serde_json::Value {
let wfs = project_rows(app, project_id, TREE_WORKFLOWS, TREE_DOCS, whole);
let mut m = serde_json::Map::new();
m.insert(
project_id.to_string(),
serde_json::to_value(wfs).unwrap_or_default(),
);
serde_json::Value::Object(m)
}
fn escape_json_for_script(s: &str) -> String {
s.replace("</", "<\\/")
}
fn shell(app: &App, mut boot: serde_json::Value, initial_html: &str, title: &str) -> Response {
if let Some(o) = boot.as_object_mut() {
o.insert("v".into(), serde_json::Value::String(app.asset_v.clone()));
o.insert(
"queue".into(),
serde_json::to_value(app.store.queue(QUEUE_BOOT).unwrap_or_default())
.unwrap_or_default(),
);
o.insert("waiting".into(), json!(waiting(app)));
o.insert("online".into(), app.online());
}
let page = INDEX_HTML
.replace("{{V}}", &app.asset_v)
.replace("{{TITLE}}", &html_escape::encode_text(title))
.replace("{{INITIAL_HTML}}", initial_html)
.replace("{{BOOT_JSON}}", &escape_json_for_script(&boot.to_string()));
(
[
(
header::CONTENT_SECURITY_POLICY,
HeaderValue::from_static(CSP),
),
(
header::X_CONTENT_TYPE_OPTIONS,
HeaderValue::from_static("nosniff"),
),
(
header::REFERRER_POLICY,
HeaderValue::from_static("no-referrer"),
),
],
Html(page),
)
.into_response()
}
pub fn fmt_time(ts: i64) -> String {
use time::{format_description::FormatItem, macros::format_description, OffsetDateTime};
const F: &[FormatItem] =
format_description!("[month repr:short] [day padding:none], [hour]:[minute]");
let local = OffsetDateTime::from_unix_timestamp(ts)
.map(|t| {
t.to_offset(time::UtcOffset::current_local_offset().unwrap_or(time::UtcOffset::UTC))
})
.unwrap_or(OffsetDateTime::UNIX_EPOCH);
local.format(F).unwrap_or_default()
}
fn doc_html(doc: &Doc, body: &str) -> String {
let e = html_escape::encode_text;
let mut sub = format!("{} · {}", e(&doc.project), e(&doc.workflow_title));
if let Some(b) = &doc.branch {
sub.push_str(&format!(" · <span class=\"branch\">{}</span>", e(b)));
}
sub.push_str(&format!(" · {}", fmt_time(doc.received_at)));
format!(
"<header class=\"doc-head\"><h1 class=\"doc-title\">{}</h1><p class=\"doc-sub\">{}</p></header><article class=\"prose kind-{}\">{}</article>",
e(&doc.title),
sub,
doc.kind.as_str(),
body
)
}
async fn shell_home(State(app): S) -> Response {
let tree = app.store.projects().unwrap_or_default();
let inbox = app.store.inbox(50).unwrap_or_default();
let sub = match tree.as_slice() {
[only] => subtree(&app, only.id, None),
_ => serde_json::Value::Object(Default::default()),
};
let mut boot = json!({ "view": "inbox", "tree": tree, "sub": sub, "inbox": inbox, "browse": app.browse.list(), "version": VERSION });
if inbox.is_empty() {
boot["agents"] = agents_json(&app);
}
shell(&app, boot, "", "snyvi")
}
async fn shell_connect(State(app): S) -> Response {
let tree = app.store.projects().unwrap_or_default();
let boot = json!({ "view": "connect", "tree": tree, "sub": {}, "browse": app.browse.list(), "version": VERSION, "agents": agents_json(&app) });
shell(&app, boot, "", "Connect an agent · snyvi")
}
async fn shell_doc(State(app): S, Path(id): Path<String>) -> Response {
let Ok(Some(doc)) = app.store.get(&id) else {
return (StatusCode::NOT_FOUND, Html("<h1>Not found</h1>")).into_response();
};
let body = app.store.html(&id).unwrap_or_default();
let tree = app.store.projects().unwrap_or_default();
let previous = app.store.previous(&doc).ok().flatten().map(|p| p.id);
let title = doc.title.clone();
let folder = doc_folder(&app, &doc);
let sub = subtree(&app, doc.project_id, Some(doc.workflow_id));
let boot = json!({ "view": "doc", "tree": tree, "sub": sub, "doc": doc, "previous": previous, "folder": folder, "browse": app.browse.list(), "version": VERSION });
shell(&app, boot, &doc_html(&doc, &body), &title)
}
fn immutable(content_type: &'static str, body: impl Into<Body>) -> Response {
(
[
(header::CONTENT_TYPE, HeaderValue::from_static(content_type)),
(
header::CACHE_CONTROL,
HeaderValue::from_static("public, max-age=31536000, immutable"),
),
],
body.into(),
)
.into_response()
}
async fn asset_css() -> Response {
immutable("text/css; charset=utf-8", APP_CSS)
}
async fn asset_js() -> Response {
immutable("application/javascript; charset=utf-8", APP_JS)
}
async fn asset_boot() -> Response {
immutable("application/javascript; charset=utf-8", BOOT_JS)
}
async fn asset_mermaid() -> Response {
(
[
(
header::CONTENT_TYPE,
HeaderValue::from_static("application/javascript; charset=utf-8"),
),
(header::CONTENT_ENCODING, HeaderValue::from_static("gzip")),
(
header::CACHE_CONTROL,
HeaderValue::from_static("public, max-age=31536000, immutable"),
),
],
MERMAID_JS_GZ,
)
.into_response()
}
async fn doc_file(State(app): S, Path((id, rel)): Path<(String, String)>) -> Response {
let Ok(Some(doc)) = app.store.get(&id) else {
return StatusCode::NOT_FOUND.into_response();
};
let Some(src) = doc.source_path.as_deref() else {
return StatusCode::NOT_FOUND.into_response();
};
let Some(dir) = std::path::Path::new(src).parent() else {
return StatusCode::NOT_FOUND.into_response();
};
let target = dir.join(&rel);
let ext = target
.extension()
.map(|e| e.to_string_lossy().to_ascii_lowercase())
.unwrap_or_default();
if !render::is_image_ext(&ext) {
return StatusCode::FORBIDDEN.into_response();
}
let (Ok(canon), Ok(root)) = (
target.canonicalize(),
crate::project::resolve(dir).root.canonicalize(),
) else {
return StatusCode::NOT_FOUND.into_response();
};
if !canon.starts_with(&root) {
return StatusCode::FORBIDDEN.into_response();
}
match tokio::fs::read(&canon).await {
Ok(bytes) => {
let mime = mime_guess::from_path(&canon)
.first_or_octet_stream()
.to_string();
(
[
(header::CONTENT_TYPE, mime),
(header::CACHE_CONTROL, "private, max-age=300".to_string()),
],
bytes,
)
.into_response()
}
Err(_) => StatusCode::NOT_FOUND.into_response(),
}
}
async fn asset_font(Path(name): Path<String>) -> Response {
match FONTS.iter().find(|(n, _)| *n == name) {
Some((_, bytes)) => immutable("font/woff2", *bytes),
None => StatusCode::NOT_FOUND.into_response(),
}
}
async fn health(State(app): S) -> Json<serde_json::Value> {
Json(json!({
"ok": true,
"version": VERSION,
"commit": BUILD_SHA,
"pid": std::process::id(),
"docs": app.store.count().unwrap_or(0),
"window": app.has_window(),
"streams": app.streams.load(Ordering::Relaxed),
"agents": app.online(),
"v": app.asset_v,
"languages": app.renderer.languages().len(),
"uptime_s": app.started.elapsed().as_secs(),
}))
}
async fn about(State(app): S) -> Json<serde_json::Value> {
let exe = std::env::current_exe().ok();
Json(json!({
"name": "snyvi",
"description": env!("CARGO_PKG_DESCRIPTION"),
"version": VERSION,
"commit": BUILD_SHA,
"target": BUILD_TARGET,
"binary": exe.as_deref().map(|p| p.display().to_string()),
"data_dir": app.paths.data_dir.display().to_string(),
"config_dir": app.paths.config_dir.display().to_string(),
"agents": std::iter::once(crate::setup::claude_code_status())
.chain(crate::agents::status_lines())
.collect::<Vec<_>>()
.join("\n"),
"license": env!("CARGO_PKG_LICENSE"),
"repository": env!("CARGO_PKG_REPOSITORY"),
"docs": app.store.count().unwrap_or(0),
"uptime_s": app.started.elapsed().as_secs(),
}))
}
async fn agents(State(app): S) -> Response {
Json(agents_json(&app)).into_response()
}
fn agents_json(app: &App) -> serde_json::Value {
let senders = app.store.senders().unwrap_or_default();
let online = app.online.lock().unwrap_or_else(|e| e.into_inner()).clone();
json!({
"program": crate::setup::program().0,
"rows": crate::agents::rows(&senders, &online),
"online": online,
"now": crate::store::now(),
})
}
async fn tree(State(app): S) -> Response {
match app.store.projects() {
Ok(t) => Json(t).into_response(),
Err(e) => err(e),
}
}
#[derive(Deserialize)]
struct TreeQ {
workflows: Option<usize>,
docs: Option<usize>,
whole: Option<i64>,
}
async fn project_tree(State(app): S, Path(id): Path<i64>, Query(q): Query<TreeQ>) -> Response {
let workflows = q.workflows.unwrap_or(TREE_WORKFLOWS);
let docs = q.docs.unwrap_or(TREE_DOCS);
Json(project_rows(&app, id, workflows, docs, q.whole)).into_response()
}
async fn workflow_tree(State(app): S, Path(id): Path<i64>) -> Response {
match app.store.workflow_tree(id) {
Ok(Some(w)) => Json(w).into_response(),
Ok(None) => StatusCode::NOT_FOUND.into_response(),
Err(e) => err(e),
}
}
#[derive(Deserialize)]
struct Limit {
limit: Option<usize>,
}
async fn inbox(State(app): S, Query(q): Query<Limit>) -> Response {
match app.store.inbox(q.limit.unwrap_or(50).min(500)) {
Ok(t) => Json(t).into_response(),
Err(e) => err(e),
}
}
#[derive(Deserialize)]
struct SearchQ {
q: String,
limit: Option<usize>,
}
async fn search(State(app): S, Query(q): Query<SearchQ>) -> Response {
match app.store.search(&q.q, q.limit.unwrap_or(30).min(200)) {
Ok(t) => Json(t).into_response(),
Err(e) => err(e),
}
}
async fn doc_json(State(app): S, Path(id): Path<String>) -> Response {
match app.store.get(&id) {
Ok(Some(doc)) => {
let body = app.store.html(&id).unwrap_or_default();
let previous = app.store.previous(&doc).ok().flatten().map(|p| p.id);
let preview = doc
.source_path
.as_deref()
.map(render::ext_of)
.and_then(|e| render::preview_kind(&e));
Json(json!({
"doc": doc,
"html": doc_html(&doc, &body),
"previous": previous,
"preview": preview,
"preview_url": preview.map(|_| format!("/api/docs/{id}/blob")),
"folder": doc_folder(&app, &doc),
}))
.into_response()
}
Ok(None) => StatusCode::NOT_FOUND.into_response(),
Err(e) => err(e),
}
}
async fn doc_raw(State(app): S, Path(id): Path<String>) -> Response {
match app.store.source(&id) {
Ok(src) => ([(header::CONTENT_TYPE, "text/plain; charset=utf-8")], src).into_response(),
Err(_) => StatusCode::NOT_FOUND.into_response(),
}
}
async fn doc_blob(State(app): S, Path(id): Path<String>) -> Response {
let Ok(Some(doc)) = app.store.get(&id) else {
return StatusCode::NOT_FOUND.into_response();
};
let Ok(bytes) = app.store.source_bytes(&id) else {
return StatusCode::NOT_FOUND.into_response();
};
let mime = doc
.source_path
.as_deref()
.map(|p| mime_guess::from_path(p).first_or_octet_stream().to_string())
.unwrap_or_else(|| "application/octet-stream".to_string());
let mut headers = HeaderMap::new();
if let Ok(v) = HeaderValue::from_str(&mime) {
headers.insert(header::CONTENT_TYPE, v);
}
headers.insert(
header::CACHE_CONTROL,
HeaderValue::from_static("private, max-age=31536000"),
);
protect(
&mut headers,
&doc.source_path
.as_deref()
.map(render::ext_of)
.unwrap_or_default(),
);
(headers, bytes).into_response()
}
#[derive(Deserialize)]
struct ViewQ {
view: Option<String>,
}
async fn doc_split(State(app): S, Path(id): Path<String>) -> Response {
match (app.store.get(&id), app.store.source(&id)) {
(Ok(Some(doc)), Ok(src)) if doc.kind == crate::render::Kind::Diff => {
Json(json!({ "html": render::diff_split(&src) })).into_response()
}
(Ok(Some(_)), _) => StatusCode::BAD_REQUEST.into_response(),
_ => StatusCode::NOT_FOUND.into_response(),
}
}
async fn doc_outline(State(app): S, Path(id): Path<String>) -> Response {
let Ok(Some(doc)) = app.store.get(&id) else {
return StatusCode::NOT_FOUND.into_response();
};
if doc.kind != crate::render::Kind::Code {
return Json(Vec::<crate::render::Outline>::new()).into_response();
}
let Ok(src) = app.store.source(&id) else {
return StatusCode::NOT_FOUND.into_response();
};
let app2 = app.clone();
match tokio::task::spawn_blocking(move || app2.renderer.outline(doc.lang.as_deref(), &src))
.await
{
Ok(items) => Json(items).into_response(),
Err(e) => err(anyhow::anyhow!(e)),
}
}
async fn history(State(app): S, Path(id): Path<String>) -> Response {
let Ok(Some(doc)) = app.store.get(&id) else {
return StatusCode::NOT_FOUND.into_response();
};
let Some(path) = doc.source_path.as_deref() else {
return Json(Vec::<Doc>::new()).into_response();
};
match app.store.history(doc.project_id, path) {
Ok(h) => Json(h).into_response(),
Err(e) => err(e),
}
}
async fn delete_doc(State(app): S, Path(id): Path<String>) -> Response {
match app.store.delete(&id) {
Ok(true) => {
emit(
&app,
"deleted",
json!({ "id": id, "waiting": waiting(&app) }),
);
Json(json!({ "ok": true })).into_response()
}
Ok(false) => StatusCode::NOT_FOUND.into_response(),
Err(e) => err(e),
}
}
async fn undelete_doc(State(app): S, Path(id): Path<String>) -> Response {
match app.store.undelete(&id) {
Ok(true) => {
let doc = app.store.get(&id).ok().flatten();
emit(
&app,
"restored",
json!({ "id": id, "doc": doc, "waiting": waiting(&app) }),
);
Json(json!({ "ok": true, "doc": doc })).into_response()
}
Ok(false) => (
StatusCode::GONE,
Json(json!({ "error": "that document has been pruned" })),
)
.into_response(),
Err(e) => err(e),
}
}
async fn shutdown(State(app): S, headers: HeaderMap) -> Response {
if !authorized(&app, &headers) {
return (
StatusCode::UNAUTHORIZED,
Json(json!({ "error": "missing or invalid token" })),
)
.into_response();
}
let _ = app.shutdown.send(());
Json(json!({ "ok": true, "version": VERSION })).into_response()
}
async fn reset_census(State(app): S) -> Response {
match app.store.census() {
Ok(c) => Json(c).into_response(),
Err(e) => err(e),
}
}
#[derive(Deserialize)]
struct ResetBody {
documents: i64,
#[serde(default)]
pinned: bool,
}
async fn reset(State(app): S, headers: HeaderMap, Json(b): Json<ResetBody>) -> Response {
if !from_this_page(&headers) && !authorized(&app, &headers) {
return (
StatusCode::FORBIDDEN,
Json(json!({ "error": "not from this page, and no token" })),
)
.into_response();
}
let census = match app.store.census() {
Ok(c) => c,
Err(e) => return err(e),
};
if b.documents != census.documents {
return (
StatusCode::CONFLICT,
Json(json!({
"error": format!("the library has changed: {} document(s) now, not {}; look again", census.documents, b.documents),
"census": census,
})),
)
.into_response();
}
if census.pinned > 0 && !b.pinned {
return (
StatusCode::CONFLICT,
Json(json!({
"error": format!("{} pinned document(s) would go with it; say so", census.pinned),
"census": census,
})),
)
.into_response();
}
let app2 = app.clone();
match tokio::task::spawn_blocking(move || app2.store.reset()).await {
Ok(Ok(())) => {}
Ok(Err(e)) => return err(e),
Err(e) => return err(anyhow::anyhow!("reset task: {e}")),
}
for root in app.browse.list() {
app.browse.close(&root.id);
}
let _ = std::fs::remove_file(app.paths.config_dir.join("sessions.json"));
match config::rotate_token(&app.paths) {
Ok(t) => *app.token.write().unwrap() = t,
Err(e) => return err(e),
}
emit(&app, "reset", json!({}));
Json(json!({ "ok": true, "removed": census })).into_response()
}
async fn focus(State(app): S) -> StatusCode {
*app.last_focus.lock().unwrap() = Instant::now();
StatusCode::NO_CONTENT
}
fn notify_desktop(app: &App, doc: &Doc) {
if std::env::var("SNYVI_NOTIFY")
.map(|v| v == "0")
.unwrap_or(false)
{
return;
}
let focused_recently =
app.last_focus.lock().unwrap().elapsed() < std::time::Duration::from_secs(4);
if focused_recently {
return;
}
let url = format!("{}/d/{}", config::base_url(), doc.id);
let has_window = app.has_window();
crate::platform::notify_open(
&doc.title,
&format!("{} · {}", doc.project, doc.workflow_title),
move || {
if has_window && crate::desktop::hand_to_window(&url) {
return;
}
platform::open_url(&url);
},
);
}
async fn compare(
State(app): S,
Path((a, b)): Path<(String, String)>,
Query(v): Query<ViewQ>,
) -> Response {
let (Ok(Some(da)), Ok(Some(db))) = (app.store.get(&a), app.store.get(&b)) else {
return StatusCode::NOT_FOUND.into_response();
};
let (Ok(sa), Ok(sb)) = (app.store.source(&a), app.store.source(&b)) else {
return StatusCode::NOT_FOUND.into_response();
};
let a_name = format!("{} ({})", da.title, fmt_time(da.received_at));
let b_name = format!("{} ({})", db.title, fmt_time(db.received_at));
let unified = render::unified(&a_name, &sa, &b_name, &sb);
let html = if unified.trim().is_empty() {
"<p class=\"empty\">No changes between these two versions.</p>".to_string()
} else if v.view.as_deref() == Some("split") {
render::diff_split(&unified)
} else {
render::diff(&unified)
};
Json(json!({ "a": da, "b": db, "html": html })).into_response()
}
#[derive(Deserialize)]
struct EventsQ {
#[serde(default)]
window: Option<String>,
#[serde(default)]
agent: Option<String>,
}
impl EventsQ {
fn is_window(&self) -> bool {
self.window
.as_deref()
.is_some_and(|v| !matches!(v, "" | "0" | "false" | "False"))
}
fn agent(&self) -> Option<String> {
let name = self.agent.as_deref()?.trim();
if name.is_empty() {
return None;
}
Some(name.chars().take(64).collect())
}
}
struct StreamMark {
app: Arc<App>,
window: bool,
agent: Option<String>,
}
impl StreamMark {
fn new(app: Arc<App>, window: bool, agent: Option<String>) -> StreamMark {
app.streams.fetch_add(1, Ordering::Relaxed);
if window {
app.windows.fetch_add(1, Ordering::Relaxed);
}
if let Some(name) = &agent {
let changed = {
let mut m = app.online.lock().unwrap_or_else(|e| e.into_inner());
*m.entry(name.clone()).or_insert(0) += 1;
json!(*m)
};
emit(&app, "agents", json!({ "online": changed }));
}
StreamMark { app, window, agent }
}
}
impl Drop for StreamMark {
fn drop(&mut self) {
self.app.streams.fetch_sub(1, Ordering::Relaxed);
if self.window {
self.app.windows.fetch_sub(1, Ordering::Relaxed);
}
if let Some(name) = &self.agent {
let changed = {
let mut m = self.app.online.lock().unwrap_or_else(|e| e.into_inner());
if let Some(n) = m.get_mut(name) {
*n -= 1;
if *n == 0 {
m.remove(name);
}
}
json!(*m)
};
emit(&self.app, "agents", json!({ "online": changed }));
}
}
}
async fn events(
State(app): S,
Query(q): Query<EventsQ>,
) -> Sse<impl tokio_stream::Stream<Item = Result<Event, Infallible>>> {
let rx = app.events.subscribe();
let stop = BroadcastStream::new(app.shutdown.subscribe()).map(|_| None);
let mark = StreamMark::new(app.clone(), q.is_window(), q.agent());
let stream = BroadcastStream::new(rx)
.filter_map(move |m| {
let _keep = &mark;
m.ok().map(|msg| {
let (name, data) = msg.split_once('\n').unwrap_or(("doc", msg.as_str()));
Some(Ok(Event::default().event(name).data(data)))
})
})
.merge(stop)
.take_while(Option::is_some)
.map(Option::unwrap);
Sse::new(stream).keep_alive(KeepAlive::default())
}
pub(crate) fn emit(app: &App, name: &str, data: serde_json::Value) {
let _ = app.events.send(format!("{name}\n{data}"));
}
#[derive(Deserialize)]
struct PinBody {
pinned: bool,
}
#[derive(Deserialize)]
struct RenameBody {
name: String,
}
fn clean_name(raw: &str) -> Option<String> {
let name: String = raw
.chars()
.map(|c| if c.is_control() { ' ' } else { c })
.collect();
let name = name.split_whitespace().collect::<Vec<_>>().join(" ");
if name.is_empty() {
return None;
}
Some(name.chars().take(120).collect())
}
async fn rename_project(State(app): S, Path(id): Path<i64>, Json(b): Json<RenameBody>) -> Response {
let Some(name) = clean_name(&b.name) else {
return (
StatusCode::BAD_REQUEST,
Json(json!({ "error": "a name cannot be empty" })),
)
.into_response();
};
match app.store.rename_project(id, &name) {
Ok(true) => {
emit(&app, "renamed", json!({ "project": id, "name": name }));
Json(json!({ "ok": true, "name": name })).into_response()
}
Ok(false) => StatusCode::NOT_FOUND.into_response(),
Err(e) => err(e),
}
}
async fn rename_workflow(
State(app): S,
Path(id): Path<i64>,
Json(b): Json<RenameBody>,
) -> Response {
let Some(name) = clean_name(&b.name) else {
return (
StatusCode::BAD_REQUEST,
Json(json!({ "error": "a name cannot be empty" })),
)
.into_response();
};
match app.store.rename_workflow(id, &name) {
Ok(true) => {
emit(&app, "renamed", json!({ "workflow": id, "name": name }));
Json(json!({ "ok": true, "name": name })).into_response()
}
Ok(false) => StatusCode::NOT_FOUND.into_response(),
Err(e) => err(e),
}
}
async fn queue(State(app): S, Query(q): Query<Limit>) -> Response {
match app.store.queue(q.limit.unwrap_or(QUEUE_MAX).min(QUEUE_MAX)) {
Ok(q) => Json(q).into_response(),
Err(e) => err(e),
}
}
async fn mark_read(State(app): S, Path(id): Path<String>) -> Response {
match app.store.mark_read(&id) {
Ok(true) => {
emit(
&app,
"read",
json!({ "ids": [id], "waiting": waiting(&app) }),
);
Json(json!({ "ok": true })).into_response()
}
Ok(false) => Json(json!({ "ok": true })).into_response(),
Err(e) => err(e),
}
}
async fn clear_queue(State(app): S) -> Response {
match app.store.mark_all_read() {
Ok(ids) => {
if !ids.is_empty() {
emit(&app, "read", json!({ "ids": ids, "waiting": 0 }));
}
Json(json!({ "ok": true, "n": ids.len() })).into_response()
}
Err(e) => err(e),
}
}
async fn pin(State(app): S, Path(id): Path<String>, Json(b): Json<PinBody>) -> Response {
match app.store.set_pinned(&id, b.pinned) {
Ok(true) => {
emit(&app, "pinned", json!({ "id": id, "pinned": b.pinned }));
Json(json!({ "ok": true })).into_response()
}
Ok(false) => StatusCode::NOT_FOUND.into_response(),
Err(e) => err(e),
}
}
async fn receive_doc(State(app): S, headers: HeaderMap, Json(payload): Json<Payload>) -> Response {
if !authorized(&app, &headers) {
return (
StatusCode::UNAUTHORIZED,
Json(json!({ "error": "missing or invalid token" })),
)
.into_response();
}
let app2 = app.clone();
let result =
tokio::task::spawn_blocking(move || receive::receive(&app2.store, &app2.renderer, payload))
.await;
match result {
Ok(Ok(received)) => {
let doc = received.doc;
let url = format!("{}/d/{}", config::base_url(), doc.id);
emit(
&app,
"doc",
json!({ "doc": doc, "url": url, "existing": received.existing, "waiting": waiting(&app) }),
);
if !received.existing {
notify_desktop(&app, &doc);
}
if received.needs_full_highlight {
spawn_full_highlight(app.clone(), doc.id.clone(), doc.lang.clone());
}
let status = if received.existing {
StatusCode::OK
} else {
StatusCode::CREATED
};
(
status,
Json(json!({
"id": doc.id,
"url": url,
"app_url": crate::desktop::window_installed().then(|| crate::desktop::app_url(&doc.id)),
"doc": doc,
"existing": received.existing,
"window": app.has_window(),
})),
)
.into_response()
}
Ok(Err(e)) => (
StatusCode::BAD_REQUEST,
Json(json!({ "error": e.to_string() })),
)
.into_response(),
Err(e) => err(anyhow::anyhow!(e)),
}
}
fn spawn_full_highlight(app: Arc<App>, id: String, lang: Option<String>) {
tokio::task::spawn_blocking(move || {
let Ok(src) = app.store.source(&id) else {
return;
};
let html = app.renderer.render_code_uncapped(lang.as_deref(), &src);
if app.store.replace_html(&id, &html).is_ok() {
emit(&app, "rendered", json!({ "id": id }));
}
});
}
#[derive(Deserialize)]
struct OpenBody {
path: String,
}
#[derive(Deserialize)]
struct PathQ {
path: Option<String>,
}
#[derive(Deserialize)]
struct FindQ {
q: Option<String>,
limit: Option<usize>,
}
#[derive(Deserialize)]
struct TerminalBody {
doc: Option<String>,
root: Option<String>,
path: Option<String>,
}
fn doc_folder(app: &App, doc: &Doc) -> Option<std::path::PathBuf> {
doc.source_path
.as_deref()
.and_then(|p| std::path::Path::new(p).parent().map(|d| d.to_path_buf()))
.into_iter()
.chain(app.store.project_root(doc.project_id).map(Into::into))
.find(|d: &std::path::PathBuf| d.is_dir())
}
fn from_this_page(headers: &HeaderMap) -> bool {
if let Some(site) = headers.get("sec-fetch-site").and_then(|v| v.to_str().ok()) {
if site != "same-origin" {
return false;
}
}
let Some(origin) = headers.get(header::ORIGIN).and_then(|v| v.to_str().ok()) else {
return false;
};
let port = config::port();
["127.0.0.1", "localhost", "[::1]"]
.iter()
.any(|h| origin == format!("http://{h}:{port}"))
}
async fn terminal(State(app): S, headers: HeaderMap, Json(b): Json<TerminalBody>) -> Response {
if !from_this_page(&headers) && !authorized(&app, &headers) {
return (
StatusCode::FORBIDDEN,
Json(json!({ "error": "not from this page, and no token" })),
)
.into_response();
}
let dir = if let Some(id) = b.root.as_deref() {
app.browse
.resolve(id, b.path.as_deref().unwrap_or(""))
.ok()
.and_then(|p| {
if p.is_dir() {
Some(p)
} else {
p.parent().map(|d| d.to_path_buf())
}
})
} else if let Some(id) = b.doc.as_deref() {
app.store
.get(id)
.ok()
.flatten()
.and_then(|d| doc_folder(&app, &d))
} else {
None
};
let Some(dir) = dir.filter(|d| d.is_dir()) else {
return (
StatusCode::BAD_REQUEST,
Json(json!({ "error": "no folder to open" })),
)
.into_response();
};
if !platform::has_display() {
return (
StatusCode::SERVICE_UNAVAILABLE,
Json(json!({ "error": "no desktop session to open a terminal in" })),
)
.into_response();
}
if platform::open_terminal(&dir) {
Json(json!({ "dir": dir })).into_response()
} else {
(
StatusCode::SERVICE_UNAVAILABLE,
Json(json!({ "error": "no terminal found on this machine" })),
)
.into_response()
}
}
async fn browse_open(State(app): S, headers: HeaderMap, Json(b): Json<OpenBody>) -> Response {
if !authorized(&app, &headers) {
return (
StatusCode::UNAUTHORIZED,
Json(json!({ "error": "missing or invalid token" })),
)
.into_response();
}
match app.browse.open(std::path::Path::new(&b.path)) {
Ok(root) => {
let url = format!("{}/b/{}", config::base_url(), root.id);
emit(&app, "browse", json!({ "roots": app.browse.list() }));
(
StatusCode::CREATED,
Json(json!({ "root": root, "url": url })),
)
.into_response()
}
Err(e) => (
StatusCode::BAD_REQUEST,
Json(json!({ "error": e.to_string() })),
)
.into_response(),
}
}
async fn browse_list(State(app): S) -> Response {
Json(app.browse.list()).into_response()
}
async fn browse_close(State(app): S, Path(id): Path<String>) -> Response {
if app.browse.close(&id) {
emit(&app, "browse", json!({ "roots": app.browse.list() }));
Json(json!({ "ok": true })).into_response()
} else {
StatusCode::NOT_FOUND.into_response()
}
}
async fn browse_tree(State(app): S, Path(id): Path<String>, Query(q): Query<PathQ>) -> Response {
match app.browse.entries(&id, q.path.as_deref().unwrap_or("")) {
Ok(entries) => Json(entries).into_response(),
Err(e) => (
StatusCode::NOT_FOUND,
Json(json!({ "error": e.to_string() })),
)
.into_response(),
}
}
async fn browse_file(State(app): S, Path(id): Path<String>, Query(q): Query<PathQ>) -> Response {
let rel = q.path.unwrap_or_default();
let app2 = app.clone();
let rel2 = rel.clone();
let id2 = id.clone();
let res =
tokio::task::spawn_blocking(move || app2.browse.file(&id2, &rel2, &app2.renderer)).await;
match res {
Ok(Ok(view)) => {
let root = app.browse.get(&id);
Json(json!({ "file": view, "root": root })).into_response()
}
Ok(Err(e)) => (
StatusCode::NOT_FOUND,
Json(json!({ "error": e.to_string() })),
)
.into_response(),
Err(e) => err(anyhow::anyhow!(e)),
}
}
async fn browse_raw(State(app): S, Path(id): Path<String>, Query(q): Query<PathQ>) -> Response {
serve_browsed(&app, &id, q.path.as_deref().unwrap_or("")).await
}
async fn browse_raw_path(State(app): S, Path((id, rel)): Path<(String, String)>) -> Response {
serve_browsed(&app, &id, &rel).await
}
fn protect(headers: &mut HeaderMap, ext: &str) {
let policy = match render::preview_kind(ext) {
Some("html") => "connect-src 'none'; form-action 'none'; frame-ancestors 'self'",
Some("pdf") => "frame-ancestors 'self'",
_ => "sandbox; default-src 'none'",
};
if let Ok(v) = HeaderValue::from_str(policy) {
headers.insert(header::CONTENT_SECURITY_POLICY, v);
}
headers.insert(
header::X_CONTENT_TYPE_OPTIONS,
HeaderValue::from_static("nosniff"),
);
}
async fn serve_browsed(app: &Arc<App>, id: &str, rel: &str) -> Response {
let Ok(path) = app.browse.resolve(id, rel) else {
return StatusCode::NOT_FOUND.into_response();
};
let Ok(bytes) = tokio::fs::read(&path).await else {
return StatusCode::NOT_FOUND.into_response();
};
let mime = mime_guess::from_path(&path)
.first_or_octet_stream()
.to_string();
let mut headers = HeaderMap::new();
let mut set = |k: header::HeaderName, v: &str| {
if let Ok(v) = HeaderValue::from_str(v) {
headers.insert(k, v);
}
};
set(header::CONTENT_TYPE, &mime);
set(header::CACHE_CONTROL, "private, max-age=60");
protect(&mut headers, &render::ext_of(&path.to_string_lossy()));
(headers, bytes).into_response()
}
async fn browse_outline(State(app): S, Path(id): Path<String>, Query(q): Query<PathQ>) -> Response {
let rel = q.path.unwrap_or_default();
let Ok(path) = app.browse.resolve(&id, &rel) else {
return StatusCode::NOT_FOUND.into_response();
};
let app2 = app.clone();
let res = tokio::task::spawn_blocking(move || {
let bytes = std::fs::read(&path).ok()?;
if crate::render::looks_binary(&bytes) {
return None;
}
let text = String::from_utf8_lossy(&bytes).into_owned();
let (kind, lang) = app2
.renderer
.detect(Some(&path.to_string_lossy()), None, &text);
if kind != crate::render::Kind::Code {
return None;
}
Some(app2.renderer.outline(lang.as_deref(), &text))
})
.await;
match res {
Ok(items) => Json(items.unwrap_or_default()).into_response(),
Err(e) => err(anyhow::anyhow!(e)),
}
}
async fn browse_find(State(app): S, Path(id): Path<String>, Query(q): Query<FindQ>) -> Response {
let app2 = app.clone();
let query = q.q.unwrap_or_default();
let limit = q.limit.unwrap_or(40).min(200);
match tokio::task::spawn_blocking(move || app2.browse.find(&id, &query, limit)).await {
Ok(Ok(hits)) => Json(hits).into_response(),
Ok(Err(e)) => (
StatusCode::NOT_FOUND,
Json(json!({ "error": e.to_string() })),
)
.into_response(),
Err(e) => err(anyhow::anyhow!(e)),
}
}
async fn shell_browse(State(app): S, Path(id): Path<String>) -> Response {
browse_shell(app, id, String::new()).await
}
async fn shell_browse_file(State(app): S, Path((id, path)): Path<(String, String)>) -> Response {
browse_shell(app, id, path).await
}
async fn browse_shell(app: Arc<App>, id: String, path: String) -> Response {
let Some(root) = app.browse.get(&id) else {
return (
StatusCode::NOT_FOUND,
Html("<h1>That folder is no longer open</h1>"),
)
.into_response();
};
let path = if path.is_empty() {
app.browse.landing(&id).unwrap_or_default()
} else {
path
};
let title = if path.is_empty() {
root.name.clone()
} else {
path.clone()
};
let boot = json!({
"view": "browse",
"tree": app.store.projects().unwrap_or_default(),
"browse": app.browse.list(),
"browseRoot": root,
"browsePath": path,
"version": VERSION,
});
shell(&app, boot, "", &title)
}
fn authorized(app: &App, headers: &HeaderMap) -> bool {
let bearer = headers
.get(header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
.map(str::trim);
let alt = headers
.get("x-snyvi-token")
.and_then(|v| v.to_str().ok())
.map(str::trim);
bearer
.or(alt)
.map(|t| constant_eq(t, &app.token.read().unwrap()))
.unwrap_or(false)
}
fn constant_eq(a: &str, b: &str) -> bool {
a.len() == b.len()
&& a.bytes()
.zip(b.bytes())
.fold(0u8, |acc, (x, y)| acc | (x ^ y))
== 0
}
fn err(e: anyhow::Error) -> Response {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": e.to_string() })),
)
.into_response()
}
#[cfg(test)]
mod tests {
use super::{APP_JS, BOOT_JS, INDEX_HTML};
#[test]
fn every_id_the_script_uses_unguarded_is_in_the_page() {
let mut missing = Vec::new();
for (i, _) in APP_JS.match_indices("$(\"#") {
let rest = &APP_JS[i + 4..];
let end = rest.find('"').expect("unterminated selector");
let id = &rest[..end];
let used_at_once = rest[end..].starts_with("\").");
if used_at_once && !INDEX_HTML.contains(&format!("id=\"{id}\"")) {
missing.push(id);
}
}
assert!(missing.is_empty(), "not in index.html: {missing:?}");
}
#[test]
fn a_name_is_cleaned_before_it_is_stored() {
use super::clean_name;
assert_eq!(clean_name(" Auth work ").unwrap(), "Auth work");
assert_eq!(clean_name("Auth\n\twork").unwrap(), "Auth work");
assert_eq!(clean_name("Auth work").unwrap(), "Auth work");
assert!(clean_name("").is_none());
assert!(clean_name(" \n ").is_none(), "whitespace is not a name");
let long = "é".repeat(400);
assert_eq!(clean_name(&long).unwrap().chars().count(), 120);
}
#[test]
fn settings_written_by_the_app_are_applied_before_first_paint() {
for key in ["theme", "font", "side", "wide", "wrap"] {
let k = format!("snyvi.{key}");
assert!(APP_JS.contains(&k), "{k} is not used by app.js");
assert!(BOOT_JS.contains(&k), "{k} is not applied by boot.js");
}
}
}