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::{
ws::{Message, WebSocket, WebSocketUpgrade},
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::borrow::Cow;
use std::convert::Infallible;
use std::path::PathBuf;
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!(concat!(env!("OUT_DIR"), "/app.css"));
const APP_JS: &str = include_str!(concat!(env!("OUT_DIR"), "/app.js"));
const BOOT_JS: &str = include_str!(concat!(env!("OUT_DIR"), "/boot.js"));
const MMD_JS: &str = include_str!(concat!(env!("OUT_DIR"), "/mmd.js"));
const DESK_JS: &str = include_str!(concat!(env!("OUT_DIR"), "/desk.js"));
const FRAME_JS: &str = include_str!(concat!(env!("OUT_DIR"), "/frame.js"));
const GAME_JS: &str = include_str!(concat!(env!("OUT_DIR"), "/game.js"));
const ABOUT_JS: &str = include_str!(concat!(env!("OUT_DIR"), "/about.js"));
const FIND_JS: &str = include_str!(concat!(env!("OUT_DIR"), "/find.js"));
const MENU_JS: &str = include_str!(concat!(env!("OUT_DIR"), "/menu.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"),
),
(
"literata.woff2",
include_bytes!("../ui/fonts/literata.woff2"),
),
(
"literata-italic.woff2",
include_bytes!("../ui/fonts/literata-italic.woff2"),
),
(
"atkinson.woff2",
include_bytes!("../ui/fonts/atkinson.woff2"),
),
(
"atkinson-italic.woff2",
include_bytes!("../ui/fonts/atkinson-italic.woff2"),
),
(
"symbols-nerd.woff2",
include_bytes!("../ui/fonts/symbols-nerd.woff2"),
),
];
pub struct Ui {
dir: Option<PathBuf>,
}
impl Ui {
fn from_env() -> Ui {
let dir = std::env::var_os("SNYVI_UI_DIR")
.map(PathBuf::from)
.filter(|d| d.join("app.css").is_file());
Ui { dir }
}
pub fn live(&self) -> bool {
self.dir.is_some()
}
fn text(&self, name: &str, built_in: &'static str) -> Cow<'static, str> {
self.dir
.as_ref()
.and_then(|d| std::fs::read_to_string(d.join(name)).ok())
.map_or(Cow::Borrowed(built_in), Cow::Owned)
}
fn version(&self, built_in: &str) -> String {
let Some(_) = self.dir.as_ref() else {
return built_in.to_string();
};
let mut h = blake3::Hasher::new();
for (name, fallback) in [
("index.html", INDEX_HTML),
("app.css", APP_CSS),
("app.js", APP_JS),
("boot.js", BOOT_JS),
("mmd.js", MMD_JS),
("desk.js", DESK_JS),
("frame.js", FRAME_JS),
("game.js", GAME_JS),
("about.js", ABOUT_JS),
("find.js", FIND_JS),
("menu.js", MENU_JS),
] {
h.update(self.text(name, fallback).as_bytes());
}
h.finalize().to_hex()[..8].to_string()
}
}
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,
built_v: String,
pub ui: Ui,
pub last_focus: std::sync::Mutex<Instant>,
pub windows: AtomicUsize,
pub streams: AtomicUsize,
pub online: std::sync::Mutex<std::collections::BTreeMap<String, usize>>,
pub capabilities: crate::capability::Capabilities,
pub panes: Arc<crate::pane::Panes>,
pub notes: crate::note::Notes,
}
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
}
pub fn asset_v(&self) -> String {
self.ui.version(&self.built_v)
}
}
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(DESK_JS.as_bytes());
h.update(FRAME_JS.as_bytes());
h.update(GAME_JS.as_bytes());
h.update(ABOUT_JS.as_bytes());
h.update(FIND_JS.as_bytes());
h.update(MENU_JS.as_bytes());
h.update(VERSION.as_bytes());
h.update(MERMAID_JS_GZ);
h.finalize().to_hex()[..8].to_string()
};
let panes = crate::pane::Panes::new(&paths.data_dir, tx.clone());
let app = Arc::new(App {
store,
renderer,
browse: Browser::load(paths.config_dir.join("folders.json")),
paths: paths.clone(),
token: std::sync::RwLock::new(token),
events: tx,
shutdown: stop_tx,
started: Instant::now(),
built_v: asset_v,
ui: Ui::from_env(),
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()),
capabilities: crate::capability::Capabilities::load(paths.config_dir.join("capabilities")),
panes,
notes: Default::default(),
});
let leaving = app.clone();
let told = app.shutdown.clone();
crate::watch::spawn_browse_watcher(app.clone());
crate::watch::spawn_ui_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/mmd.js", get(asset_mmd))
.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/notes", get(notes).post(receive_note))
.route("/api/notes/seen", post(see_notes))
.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/pick", post(browse_pick))
.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))
.route("/api/capability", post(mint_capability))
.route("/api/desk", get(desk_socket))
.route("/api/desks", get(desks).post(create_desk))
.route("/api/desks/{id}/rename", post(rename_desk))
.route("/api/desks/{id}/layout", post(desk_layout))
.route("/api/desks/{id}/delete", post(delete_desk))
.route("/api/desks/{id}/panes", post(open_pane))
.route("/api/desks/{id}/docs", get(desk_docs))
.route("/api/desks/{id}/notes", get(desk_notes).post(add_desk_note))
.route("/api/desks/{id}/notes/{note}", post(set_desk_note))
.route(
"/api/desks/{id}/notes/{note}/remove",
post(remove_desk_note),
)
.route(
"/api/desks/{id}/notes/{note}/restore",
post(restore_desk_note),
)
.route("/api/panes/{id}/delete", post(close_pane))
.route("/api/panes/{id}/start", post(start_pane))
.route("/api/panes/{id}/stop", post(stop_pane))
.route(
"/api/panes/{id}/paste",
post(paste_image).layer(axum::extract::DefaultBodyLimit::max(receive::MAX_BYTES)),
)
.route("/desks", get(shell_desk_list))
.route("/desk/{id}", get(shell_desk))
.route("/assets/desk.js", get(asset_desk))
.route("/assets/frame.js", get(asset_frame))
.route("/assets/game.js", get(asset_game))
.route("/assets/about.js", get(asset_about))
.route("/assets/find.js", get(asset_find))
.route("/assets/menu.js", get(asset_menu))
.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() => {},
}
let _ = told.send(());
})
.await?;
leaving.panes.shutdown();
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()));
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());
o.insert("notes".into(), json!(app.notes.list()));
}
let page = app
.ui
.text("index.html", 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(),
render::chunk_code(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)
}
async fn shell_desk(State(app): S, Path(id): Path<i64>) -> Response {
let tree = app.store.projects().unwrap_or_default();
let boot = json!({ "view": "desk", "desk": id, "tree": tree, "sub": {}, "browse": app.browse.list(), "version": VERSION });
shell(&app, boot, "", "Desk · snyvi")
}
async fn shell_desk_list(State(app): S) -> Response {
let tree = app.store.projects().unwrap_or_default();
let boot = json!({ "view": "desk", "desk": null, "tree": tree, "sub": {}, "browse": app.browse.list(), "version": VERSION });
shell(&app, boot, "", "Desks · snyvi")
}
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()
}
fn asset(app: &App, content_type: &'static str, name: &str, built_in: &'static str) -> Response {
let body = app.ui.text(name, built_in).into_owned();
if !app.ui.live() {
return immutable(content_type, body);
}
(
[
(header::CONTENT_TYPE, HeaderValue::from_static(content_type)),
(header::CACHE_CONTROL, HeaderValue::from_static("no-store")),
],
body,
)
.into_response()
}
async fn asset_css(State(app): S) -> Response {
asset(&app, "text/css; charset=utf-8", "app.css", APP_CSS)
}
async fn asset_js(State(app): S) -> Response {
asset(
&app,
"application/javascript; charset=utf-8",
"app.js",
APP_JS,
)
}
async fn asset_boot(State(app): S) -> Response {
asset(
&app,
"application/javascript; charset=utf-8",
"boot.js",
BOOT_JS,
)
}
async fn asset_mmd(State(app): S) -> Response {
asset(
&app,
"application/javascript; charset=utf-8",
"mmd.js",
MMD_JS,
)
}
async fn asset_desk(State(app): S) -> Response {
asset(
&app,
"application/javascript; charset=utf-8",
"desk.js",
DESK_JS,
)
}
async fn asset_frame(State(app): S) -> Response {
asset(
&app,
"application/javascript; charset=utf-8",
"frame.js",
FRAME_JS,
)
}
async fn asset_game(State(app): S) -> Response {
asset(
&app,
"application/javascript; charset=utf-8",
"game.js",
GAME_JS,
)
}
async fn asset_about(State(app): S) -> Response {
asset(
&app,
"application/javascript; charset=utf-8",
"about.js",
ABOUT_JS,
)
}
async fn asset_find(State(app): S) -> Response {
asset(
&app,
"application/javascript; charset=utf-8",
"find.js",
FIND_JS,
)
}
async fn asset_menu(State(app): S) -> Response {
asset(
&app,
"application/javascript; charset=utf-8",
"menu.js",
MENU_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),
"panes": app.panes.running(),
"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();
}
if let Some(json) = app.store.outline(&id) {
return ([(header::CONTENT_TYPE, "application/json")], json).into_response();
}
let app2 = app.clone();
match tokio::task::spawn_blocking(move || outline_of(&app2, &id, doc.lang.as_deref())).await {
Ok(Some(json)) => ([(header::CONTENT_TYPE, "application/json")], json).into_response(),
Ok(None) => StatusCode::NOT_FOUND.into_response(),
Err(e) => err(anyhow::anyhow!(e)),
}
}
fn outline_of(app: &App, id: &str, lang: Option<&str>) -> Option<String> {
let src = app.store.source(id).ok()?;
let json = serde_json::to_string(&app.renderer.outline(lang, &src)).ok()?;
let _ = app.store.set_outline(id, &json);
Some(json)
}
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,
#[serde(default)]
desks: i64,
}
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 b.desks != census.desks {
return (
StatusCode::CONFLICT,
Json(json!({
"error": format!("the desks have changed: {} now, not {}; look again", census.desks, b.desks),
"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}")),
}
app.panes.clear();
for root in app.browse.list() {
app.browse.close(&root.id);
}
let _ = std::fs::remove_file(app.paths.config_dir.join("sessions.json"));
let _ = std::fs::remove_file(app.paths.config_dir.join("folders.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,
}
#[derive(Deserialize)]
struct NoteTextBody {
text: String,
}
#[derive(Debug, Default, Deserialize)]
struct NoteEditBody {
#[serde(default)]
text: Option<String>,
#[serde(default)]
done: Option<bool>,
}
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, "supersedes": received.supersedes, "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());
}
if doc.kind == crate::render::Kind::Code {
let (app2, id, lang) = (app.clone(), doc.id.clone(), doc.lang.clone());
tokio::task::spawn_blocking(move || outline_of(&app2, &id, lang.as_deref()));
}
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)),
}
}
async fn receive_note(
State(app): S,
headers: HeaderMap,
Json(n): Json<crate::note::NewNote>,
) -> Response {
if !authorized(&app, &headers) {
return (
StatusCode::UNAUTHORIZED,
Json(json!({ "error": "missing or invalid token" })),
)
.into_response();
}
match app.notes.add(n, crate::store::now()) {
Ok(note) => {
emit(&app, "notes", json!({ "notes": app.notes.list() }));
(
StatusCode::CREATED,
Json(json!({ "note": note, "window": app.has_window() })),
)
.into_response()
}
Err(e) => (
StatusCode::BAD_REQUEST,
Json(json!({ "error": e.to_string() })),
)
.into_response(),
}
}
async fn notes(State(app): S) -> Json<serde_json::Value> {
Json(json!({ "notes": app.notes.list() }))
}
async fn see_notes(State(app): S) -> Json<serde_json::Value> {
if app.notes.see() {
emit(&app, "notes", json!({ "notes": app.notes.list() }));
}
Json(json!({ "ok": true }))
}
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())
}
async fn mint_capability(State(app): S, headers: HeaderMap) -> Response {
if !authorized(&app, &headers) {
return (StatusCode::FORBIDDEN, Json(json!({ "error": "no token" }))).into_response();
}
match app.capabilities.mint() {
Ok(capability) => Json(json!({ "capability": capability })).into_response(),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": e.to_string() })),
)
.into_response(),
}
}
const CAPABILITY_DEADLINE: std::time::Duration = std::time::Duration::from_secs(2);
#[derive(Deserialize)]
struct Hello {
capability: String,
}
#[derive(Deserialize)]
#[serde(tag = "t")]
enum Said {
#[serde(rename = "watch")]
Watch { panes: Vec<String> },
#[serde(rename = "in")]
In { p: String, d: String },
#[serde(rename = "size")]
Size { p: String, c: u16, r: u16 },
}
async fn forward(
live: Arc<crate::pane::Live>,
first: Vec<String>,
mut rx: broadcast::Receiver<Arc<str>>,
out: tokio::sync::mpsc::Sender<Arc<str>>,
) {
for f in first {
if out.send(f.into()).await.is_err() {
return;
}
}
loop {
match rx.recv().await {
Ok(m) => {
if out.send(m).await.is_err() {
return;
}
}
Err(broadcast::error::RecvError::Lagged(_)) => {
let (first, fresh) = live.attach();
rx = fresh;
for f in first {
if out.send(f.into()).await.is_err() {
return;
}
}
}
Err(broadcast::error::RecvError::Closed) => return,
}
}
}
async fn desk_socket(
State(app): S,
headers: HeaderMap,
Query(q): Query<std::collections::HashMap<String, String>>,
ws: WebSocketUpgrade,
) -> Response {
if q.contains_key(crate::desktop::CAPABILITY_KEY) || q.contains_key("capability") {
return (
StatusCode::FORBIDDEN,
Json(json!({ "error": "the capability is not a query parameter" })),
)
.into_response();
}
if !from_this_page(&headers) {
return (
StatusCode::FORBIDDEN,
Json(json!({ "error": "not from this page" })),
)
.into_response();
}
ws.on_upgrade(move |socket| desk_session(app, socket))
}
fn hello_allows(caps: &crate::capability::Capabilities, frame: Option<&str>) -> bool {
frame
.and_then(|f| serde_json::from_str::<Hello>(f).ok())
.is_some_and(|h| caps.verify(&h.capability))
}
async fn desk_session(app: Arc<App>, mut socket: WebSocket) {
let first = tokio::time::timeout(CAPABILITY_DEADLINE, socket.recv()).await;
let frame = match &first {
Ok(Some(Ok(Message::Text(t)))) => Some(t.as_str()),
_ => None,
};
if !hello_allows(&app.capabilities, frame) {
let _ = socket
.send(Message::Text(
json!({ "error": "no capability" }).to_string().into(),
))
.await;
let _ = socket.send(Message::Close(None)).await;
return;
}
let _ = socket
.send(Message::Text(json!({ "ok": true }).to_string().into()))
.await;
let (out, mut frames) = tokio::sync::mpsc::channel::<Arc<str>>(64);
let mut going = app.shutdown.subscribe();
let mut watching: std::collections::HashMap<
String,
(Arc<crate::pane::Live>, tokio::task::JoinHandle<()>),
> = Default::default();
loop {
tokio::select! {
_ = going.recv() => break,
f = frames.recv() => {
let Some(f) = f else { break };
if socket.send(Message::Text(f.to_string().into())).await.is_err() {
break;
}
}
msg = socket.recv() => {
let text = match msg {
Some(Ok(Message::Text(t))) => t,
Some(Ok(Message::Close(_))) | None | Some(Err(_)) => break,
Some(Ok(_)) => continue,
};
let Ok(said) = serde_json::from_str::<Said>(text.as_str()) else { continue };
match said {
Said::Watch { panes } => {
let wanted: std::collections::HashSet<String> = panes
.into_iter()
.filter(|id| crate::pane::valid_id(id))
.filter(|id| matches!(app.store.pane(id), Ok(Some(_))))
.take(crate::desk::EVERYWHERE as usize)
.collect();
watching.retain(|id, (_, task)| {
let keep = wanted.contains(id);
if !keep {
task.abort();
}
keep
});
for id in wanted {
if watching.contains_key(&id) {
continue;
}
let live = app.panes.get(&id);
let (first, rx) = live.attach();
let task = tokio::spawn(forward(live.clone(), first, rx, out.clone()));
watching.insert(id, (live, task));
}
}
Said::In { p, d } => {
if let Some((live, _)) = watching.get(&p) {
live.input(d.as_bytes(), &app.panes);
}
}
Said::Size { p, c, r } => {
if let Some((live, _)) = watching.get(&p) {
live.resize(c, r);
}
}
}
}
}
}
for (_, (_, task)) in watching {
task.abort();
}
}
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}"))
}
fn same_origin_read(headers: &HeaderMap) -> bool {
if headers.contains_key(header::ORIGIN) {
return false;
}
if let Some(site) = headers.get("sec-fetch-site").and_then(|v| v.to_str().ok()) {
if site != "same-origin" {
return false;
}
}
let Some(host) = headers.get(header::HOST).and_then(|v| v.to_str().ok()) else {
return false;
};
let port = config::port();
["127.0.0.1", "localhost", "[::1]"]
.iter()
.any(|h| host == format!("{h}:{port}"))
}
const CAPABILITY_HEADER: &str = "x-snyvi-capability";
fn desk_refusal(
caps: &crate::capability::Capabilities,
headers: &HeaderMap,
q: &std::collections::HashMap<String, String>,
) -> Option<&'static str> {
if q.contains_key(crate::desktop::CAPABILITY_KEY) || q.contains_key("capability") {
return Some("the capability is not a query parameter");
}
if !from_this_page(headers) && !same_origin_read(headers) {
return Some("not from this page");
}
let given = headers
.get(CAPABILITY_HEADER)
.and_then(|v| v.to_str().ok())
.unwrap_or_default();
(!caps.verify(given)).then_some("no capability")
}
fn refuse_desk(
app: &App,
headers: &HeaderMap,
q: &std::collections::HashMap<String, String>,
) -> Option<Response> {
desk_refusal(&app.capabilities, headers, q)
.map(|why| (StatusCode::FORBIDDEN, Json(json!({ "error": why }))).into_response())
}
#[derive(Deserialize)]
struct NewDeskBody {
#[serde(default)]
root: Option<String>,
#[serde(default)]
path: String,
#[serde(default)]
name: Option<String>,
}
#[derive(Deserialize)]
struct LayoutBody {
col: f64,
row: f64,
}
#[derive(Deserialize)]
struct NewPaneBody {
#[serde(default)]
cmd: Option<String>,
}
async fn desks(
State(app): S,
headers: HeaderMap,
Query(q): Query<std::collections::HashMap<String, String>>,
) -> Response {
if let Some(no) = refuse_desk(&app, &headers, &q) {
return no;
}
match (app.store.desks(), app.store.panes_open()) {
(Ok(desks), Ok(panes)) => Json(json!({
"desks": with_status(&app, &desks),
"home": dirs::home_dir(),
"panes": panes,
"cap": crate::desk::EVERYWHERE,
"per_desk": crate::desk::PER_DESK,
}))
.into_response(),
(Err(e), _) | (_, Err(e)) => err(e),
}
}
fn with_status(app: &App, desks: &[crate::desk::Desk]) -> serde_json::Value {
let mut v = serde_json::to_value(desks).unwrap_or_default();
for d in v.as_array_mut().into_iter().flatten() {
for p in d["panes"].as_array_mut().into_iter().flatten() {
let id = p["id"].as_str().unwrap_or_default().to_string();
p["status"] = serde_json::to_value(app.panes.status(&id)).unwrap_or_default();
}
}
v
}
async fn create_desk(
State(app): S,
headers: HeaderMap,
Query(q): Query<std::collections::HashMap<String, String>>,
Json(b): Json<NewDeskBody>,
) -> Response {
if let Some(no) = refuse_desk(&app, &headers, &q) {
return no;
}
let (dir, fallback) = match &b.root {
Some(root) => match app.browse.resolve(root, &b.path) {
Ok(dir) => (dir, None),
Err(_) => {
return (
StatusCode::BAD_REQUEST,
Json(json!({ "error": "no such folder" })),
)
.into_response()
}
},
None => match dirs::home_dir() {
Some(home) => (home, Some("desk")),
None => {
return (
StatusCode::BAD_REQUEST,
Json(json!({ "error": "there is no home directory to start in" })),
)
.into_response()
}
},
};
if !dir.is_dir() {
return (
StatusCode::BAD_REQUEST,
Json(json!({ "error": "a desk is rooted at a folder" })),
)
.into_response();
}
let name = b
.name
.as_deref()
.and_then(clean_name)
.or(fallback.map(String::from));
match app
.store
.create_desk(&dir.to_string_lossy(), name.as_deref())
{
Ok(desk) => {
desks_moved(&app);
(StatusCode::CREATED, Json(json!({ "desk": desk }))).into_response()
}
Err(e) => err(e),
}
}
async fn desk_docs(
State(app): S,
headers: HeaderMap,
Path(id): Path<i64>,
Query(q): Query<std::collections::HashMap<String, String>>,
) -> Response {
if let Some(no) = refuse_desk(&app, &headers, &q) {
return no;
}
match app.store.desk_docs(id, 40) {
Ok(docs) => Json(json!({ "docs": docs })).into_response(),
Err(e) => err(e),
}
}
async fn desk_notes(
State(app): S,
headers: HeaderMap,
Path(id): Path<i64>,
Query(q): Query<std::collections::HashMap<String, String>>,
) -> Response {
if let Some(no) = refuse_desk(&app, &headers, &q) {
return no;
}
match app.store.desk_notes(id) {
Ok(notes) => Json(json!({ "notes": notes })).into_response(),
Err(e) => err(e),
}
}
async fn add_desk_note(
State(app): S,
headers: HeaderMap,
Path(id): Path<i64>,
Query(q): Query<std::collections::HashMap<String, String>>,
Json(b): Json<NoteTextBody>,
) -> Response {
if let Some(no) = refuse_desk(&app, &headers, &q) {
return no;
}
match app.store.add_desk_note(id, &b.text) {
Ok(Some(note)) => (StatusCode::CREATED, Json(json!({ "note": note }))).into_response(),
Ok(None) => (
StatusCode::CONFLICT,
Json(json!({ "error": format!("a desk keeps {} notes", crate::desk::NOTES_PER_DESK) })),
)
.into_response(),
Err(e) => err(e),
}
}
async fn set_desk_note(
State(app): S,
headers: HeaderMap,
Path((id, note)): Path<(i64, i64)>,
Query(q): Query<std::collections::HashMap<String, String>>,
Json(b): Json<NoteEditBody>,
) -> Response {
if let Some(no) = refuse_desk(&app, &headers, &q) {
return no;
}
match app.store.set_desk_note(id, note, b.text.as_deref(), b.done) {
Ok(true) => Json(json!({ "ok": true })).into_response(),
Ok(false) => StatusCode::NOT_FOUND.into_response(),
Err(e) => err(e),
}
}
async fn remove_desk_note(
State(app): S,
headers: HeaderMap,
Path((id, note)): Path<(i64, i64)>,
Query(q): Query<std::collections::HashMap<String, String>>,
) -> Response {
if let Some(no) = refuse_desk(&app, &headers, &q) {
return no;
}
match app.store.remove_desk_note(id, note) {
Ok(true) => Json(json!({ "ok": true })).into_response(),
Ok(false) => StatusCode::NOT_FOUND.into_response(),
Err(e) => err(e),
}
}
async fn restore_desk_note(
State(app): S,
headers: HeaderMap,
Path((id, note)): Path<(i64, i64)>,
Query(q): Query<std::collections::HashMap<String, String>>,
) -> Response {
if let Some(no) = refuse_desk(&app, &headers, &q) {
return no;
}
match app.store.restore_desk_note(id, note) {
Ok(true) => Json(json!({ "ok": true })).into_response(),
Ok(false) => StatusCode::NOT_FOUND.into_response(),
Err(e) => err(e),
}
}
async fn rename_desk(
State(app): S,
headers: HeaderMap,
Path(id): Path<i64>,
Query(q): Query<std::collections::HashMap<String, String>>,
Json(b): Json<RenameBody>,
) -> Response {
if let Some(no) = refuse_desk(&app, &headers, &q) {
return no;
}
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_desk(id, &name) {
Ok(true) => {
desks_moved(&app);
Json(json!({ "ok": true, "name": name })).into_response()
}
Ok(false) => StatusCode::NOT_FOUND.into_response(),
Err(e) => err(e),
}
}
async fn desk_layout(
State(app): S,
headers: HeaderMap,
Path(id): Path<i64>,
Query(q): Query<std::collections::HashMap<String, String>>,
Json(b): Json<LayoutBody>,
) -> Response {
if let Some(no) = refuse_desk(&app, &headers, &q) {
return no;
}
match app.store.set_desk_layout(id, b.col, b.row) {
Ok(true) => Json(json!({ "ok": true })).into_response(),
Ok(false) => StatusCode::NOT_FOUND.into_response(),
Err(e) => err(e),
}
}
async fn delete_desk(
State(app): S,
headers: HeaderMap,
Path(id): Path<i64>,
Query(q): Query<std::collections::HashMap<String, String>>,
) -> Response {
if let Some(no) = refuse_desk(&app, &headers, &q) {
return no;
}
let panes: Vec<String> = app
.store
.desk(id)
.ok()
.flatten()
.map(|d| d.panes.into_iter().map(|p| p.id).collect())
.unwrap_or_default();
match app.store.delete_desk(id) {
Ok(true) => {
for p in &panes {
app.panes.close(p);
}
desks_moved(&app);
Json(json!({ "ok": true })).into_response()
}
Ok(false) => StatusCode::NOT_FOUND.into_response(),
Err(e) => err(e),
}
}
async fn open_pane(
State(app): S,
headers: HeaderMap,
Path(id): Path<i64>,
Query(q): Query<std::collections::HashMap<String, String>>,
Json(b): Json<NewPaneBody>,
) -> Response {
if let Some(no) = refuse_desk(&app, &headers, &q) {
return no;
}
let Ok(Some(desk)) = app.store.desk(id) else {
return StatusCode::NOT_FOUND.into_response();
};
let cmd = b.cmd.unwrap_or_default();
match app.store.open_pane(desk.id, &desk.root, cmd.trim()) {
Ok(crate::desk::Opened::Pane(pane)) => {
desks_moved(&app);
(StatusCode::CREATED, Json(json!({ "pane": pane }))).into_response()
}
Ok(crate::desk::Opened::DeskFull) => (
StatusCode::CONFLICT,
Json(json!({ "error": format!("this desk holds {}", crate::desk::PER_DESK), "full": "desk" })),
)
.into_response(),
Ok(crate::desk::Opened::NoRoomLeft) => (
StatusCode::CONFLICT,
Json(json!({ "error": format!("{} panels is the whole of it", crate::desk::EVERYWHERE), "full": "everywhere" })),
)
.into_response(),
Ok(crate::desk::Opened::NoSuchDesk) => StatusCode::NOT_FOUND.into_response(),
Err(e) => err(e),
}
}
async fn close_pane(
State(app): S,
headers: HeaderMap,
Path(id): Path<String>,
Query(q): Query<std::collections::HashMap<String, String>>,
) -> Response {
if let Some(no) = refuse_desk(&app, &headers, &q) {
return no;
}
match app.store.close_pane(&id) {
Ok(true) => {
app.panes.close(&id);
desks_moved(&app);
Json(json!({ "ok": true })).into_response()
}
Ok(false) => StatusCode::NOT_FOUND.into_response(),
Err(e) => err(e),
}
}
fn desks_moved(app: &App) {
emit(app, "desks", json!({}));
}
#[derive(Deserialize)]
struct StartBody {
#[serde(default)]
cmd: Option<String>,
#[serde(default = "default_cols")]
cols: u16,
#[serde(default = "default_rows")]
rows: u16,
#[serde(default)]
accent: String,
}
fn default_cols() -> u16 {
80
}
fn default_rows() -> u16 {
24
}
async fn start_pane(
State(app): S,
headers: HeaderMap,
Path(id): Path<String>,
Query(q): Query<std::collections::HashMap<String, String>>,
Json(b): Json<StartBody>,
) -> Response {
if let Some(no) = refuse_desk(&app, &headers, &q) {
return no;
}
let Ok(Some(placed)) = app.store.pane(&id) else {
return StatusCode::NOT_FOUND.into_response();
};
let cmd = b.cmd.unwrap_or_else(|| placed.pane.cmd.clone());
if cmd.trim() != placed.pane.cmd {
let _ = app.store.set_pane_cmd(&id, &cmd);
}
let live = app.panes.get(&id);
let start = crate::pane::Start {
cwd: &placed.pane.cwd,
cmd: &cmd,
desk: &placed.desk_name,
slot: placed.pane.slot,
cols: b.cols,
rows: b.rows,
accent: &b.accent,
};
match live.start(start, &app.panes) {
Ok(status) => Json(json!({ "status": status })).into_response(),
Err(e) => (
StatusCode::CONFLICT,
Json(json!({ "error": e.to_string() })),
)
.into_response(),
}
}
async fn stop_pane(
State(app): S,
headers: HeaderMap,
Path(id): Path<String>,
Query(q): Query<std::collections::HashMap<String, String>>,
) -> Response {
if let Some(no) = refuse_desk(&app, &headers, &q) {
return no;
}
let Ok(Some(_)) = app.store.pane(&id) else {
return StatusCode::NOT_FOUND.into_response();
};
app.panes.get(&id).stop();
Json(json!({ "ok": true })).into_response()
}
async fn paste_image(
State(app): S,
headers: HeaderMap,
Path(id): Path<String>,
Query(q): Query<std::collections::HashMap<String, String>>,
body: axum::body::Bytes,
) -> Response {
if let Some(no) = refuse_desk(&app, &headers, &q) {
return no;
}
let Ok(Some(placed)) = app.store.pane(&id) else {
return StatusCode::NOT_FOUND.into_response();
};
let ext = match headers
.get(header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or_default()
{
"image/png" => "png",
"image/jpeg" => "jpg",
"image/gif" => "gif",
"image/webp" => "webp",
_ => {
return (
StatusCode::UNSUPPORTED_MEDIA_TYPE,
Json(json!({ "error": "an image, as png, jpeg, gif or webp" })),
)
.into_response()
}
};
if body.is_empty() {
return (
StatusCode::BAD_REQUEST,
Json(json!({ "error": "an empty paste" })),
)
.into_response();
}
let dir = app.paths.data_dir.join("pastes");
let name = format!("{}.{ext}", &blake3::hash(&body).to_hex()[..16]);
let file = dir.join(&name);
if let Err(e) = std::fs::create_dir_all(&dir).and_then(|_| std::fs::write(&file, &body)) {
return err(anyhow::anyhow!("keeping the pasted image: {e}"));
}
let payload = Payload {
path: Some(file.to_string_lossy().to_string()),
title: Some(format!(
"Pasted into {} [{}]",
placed.desk_name, placed.pane.slot
)),
workflow: Some(format!("{} pastes", placed.desk_name)),
cwd: Some(placed.root.clone()),
origin: Some("paste".into()),
pane: Some(id.clone()),
..Default::default()
};
let app2 = app.clone();
match tokio::task::spawn_blocking(move || {
receive::receive(&app2.store, &app2.renderer, payload)
})
.await
{
Ok(Ok(received)) => {
let doc = received.doc;
emit(
&app,
"doc",
json!({ "doc": doc, "url": format!("{}/d/{}", config::base_url(), doc.id), "existing": received.existing, "supersedes": received.supersedes, "waiting": waiting(&app) }),
);
Json(json!({ "id": doc.id, "path": file })).into_response()
}
Ok(Err(e)) => (
StatusCode::BAD_REQUEST,
Json(json!({ "error": e.to_string() })),
)
.into_response(),
Err(e) => err(anyhow::anyhow!(e)),
}
}
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_pick(
State(app): S,
headers: HeaderMap,
Query(q): Query<std::collections::HashMap<String, String>>,
) -> Response {
if let Some(no) = refuse_desk(&app, &headers, &q) {
return no;
}
static PICKING: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
if PICKING.swap(true, std::sync::atomic::Ordering::SeqCst) {
return (
StatusCode::CONFLICT,
Json(json!({ "error": "a folder dialog is already open" })),
)
.into_response();
}
struct Done;
impl Drop for Done {
fn drop(&mut self) {
PICKING.store(false, std::sync::atomic::Ordering::SeqCst);
}
}
let _done = Done;
let picked = tokio::task::spawn_blocking(crate::platform::pick_folder).await;
match picked {
Ok(Ok(Some(dir))) => match app.browse.open(&dir) {
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(),
},
Ok(Ok(None)) => StatusCode::NO_CONTENT.into_response(),
Ok(Err(why)) => {
(StatusCode::NOT_IMPLEMENTED, Json(json!({ "error": why }))).into_response()
}
Err(e) => err(anyhow::anyhow!(e)),
}
}
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();
};
drop(path);
let app2 = app.clone();
let res =
tokio::task::spawn_blocking(move || app2.browse.outline(&id, &rel, &app2.renderer)).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::{
desk_refusal, hello_allows, Ui, ABOUT_JS, APP_CSS, APP_JS, BOOT_JS, DESK_JS, FIND_JS,
FRAME_JS, GAME_JS, INDEX_HTML, MENU_JS, MMD_JS,
};
use crate::capability::Capabilities;
use axum::http::{header, HeaderMap, HeaderValue};
#[test]
fn only_a_frame_carrying_a_live_capability_opens_a_desk() {
let caps = Capabilities::default();
let cap = caps.mint().unwrap();
assert!(hello_allows(
&caps,
Some(&format!(r#"{{"capability":"{cap}"}}"#))
));
assert!(!hello_allows(&caps, None));
assert!(!hello_allows(
&caps,
Some(&format!(r#"{{"capability":"{}"}}"#, "b".repeat(64)))
));
assert!(!hello_allows(&caps, Some(r#"{"capability":""}"#)));
assert!(!hello_allows(
&caps,
Some(&format!(r#"{{"token":"{cap}"}}"#))
));
assert!(!hello_allows(&caps, Some(&format!(r#""{cap}""#))));
assert!(!hello_allows(&caps, Some("")));
assert!(!hello_allows(&caps, Some("not json at all")));
}
#[test]
fn the_window_count_is_never_consulted_on_the_desk_path() {
let src = include_str!("server.rs");
let from = src
.find("async fn desk_socket")
.expect("the desk socket should be in this file");
let to = src[from..]
.find("\nfn hello_allows")
.expect("hello_allows follows the socket")
+ from;
let path = &src[from..to];
for forgeable in ["has_window", "windows", "is_window", "EventsQ"] {
assert!(
!path.contains(forgeable),
"the desk path reads `{forgeable}`, which a browser tab can forge"
);
}
assert!(
src.contains(
"fn hello_allows(caps: &crate::capability::Capabilities, frame: Option<&str>)"
),
"the desk gate should see a capability and a frame, and nothing else"
);
}
#[test]
fn a_desk_route_takes_its_capability_from_a_header_and_nowhere_else() {
let caps = Capabilities::default();
let cap = caps.mint().unwrap();
let none = std::collections::HashMap::new();
let ours = |cap: &str| {
let mut h = HeaderMap::new();
h.insert(
header::ORIGIN,
HeaderValue::from_str(&crate::config::base_url()).unwrap(),
);
h.insert("sec-fetch-site", HeaderValue::from_static("same-origin"));
if !cap.is_empty() {
h.insert(
super::CAPABILITY_HEADER,
HeaderValue::from_str(cap).unwrap(),
);
}
h
};
assert_eq!(desk_refusal(&caps, &ours(&cap), &none), None);
assert_eq!(desk_refusal(&caps, &ours(""), &none), Some("no capability"));
assert_eq!(
desk_refusal(&caps, &ours(&"c".repeat(64)), &none),
Some("no capability")
);
let query = std::collections::HashMap::from([("cap".to_string(), cap.clone())]);
assert_eq!(
desk_refusal(&caps, &ours(&cap), &query),
Some("the capability is not a query parameter")
);
let query = std::collections::HashMap::from([("capability".to_string(), cap.clone())]);
assert_eq!(
desk_refusal(&caps, &ours(&cap), &query),
Some("the capability is not a query parameter")
);
let mut elsewhere = ours(&cap);
elsewhere.insert(
header::ORIGIN,
HeaderValue::from_static("http://evil.example"),
);
assert_eq!(
desk_refusal(&caps, &elsewhere, &none),
Some("not from this page")
);
let mut bare = HeaderMap::new();
bare.insert(
super::CAPABILITY_HEADER,
HeaderValue::from_str(&cap).unwrap(),
);
assert_eq!(
desk_refusal(&caps, &bare, &none),
Some("not from this page")
);
let read = |host: &str, site: Option<&'static str>| {
let mut h = HeaderMap::new();
h.insert(header::HOST, HeaderValue::from_str(host).unwrap());
if let Some(s) = site {
h.insert("sec-fetch-site", HeaderValue::from_static(s));
}
h.insert(
super::CAPABILITY_HEADER,
HeaderValue::from_str(&cap).unwrap(),
);
h
};
let here = format!("127.0.0.1:{}", crate::config::port());
assert_eq!(
desk_refusal(&caps, &read(&here, Some("same-origin")), &none),
None
);
assert_eq!(desk_refusal(&caps, &read(&here, None), &none), None);
let rebound = format!("evil.example:{}", crate::config::port());
assert_eq!(
desk_refusal(&caps, &read(&rebound, Some("same-origin")), &none),
Some("not from this page")
);
assert_eq!(
desk_refusal(&caps, &read(&here, Some("cross-site")), &none),
Some("not from this page")
);
}
#[test]
fn every_desk_route_is_behind_the_gate() {
let src = include_str!("server.rs").replace("\r\n", "\n");
let src = src.as_str();
for handler in [
"async fn desks(",
"async fn create_desk(",
"async fn browse_pick(",
"async fn rename_desk(",
"async fn desk_layout(",
"async fn delete_desk(",
"async fn desk_docs(",
"async fn desk_notes(",
"async fn add_desk_note(",
"async fn set_desk_note(",
"async fn remove_desk_note(",
"async fn restore_desk_note(",
"async fn open_pane(",
"async fn close_pane(",
"async fn start_pane(",
"async fn stop_pane(",
"async fn paste_image(",
] {
let from = src
.find(handler)
.unwrap_or_else(|| panic!("{handler} is a route in this file"));
let body = &src[from..];
let end = body.find("\n}\n").expect("a handler ends");
let body = &body[..end];
let gate = body
.find("refuse_desk")
.expect("a desk handler goes through the gate");
let store = body.find("app.store").unwrap_or(usize::MAX);
assert!(gate < store, "{handler} reaches the store before the gate");
}
let pick = &src[src.find("async fn browse_pick(").unwrap()..];
assert!(
pick.find("refuse_desk").unwrap() < pick.find("pick_folder").unwrap(),
"browse_pick shows a dialog before the gate"
);
for route in [
r#".route("/api/desks", get(desks).post(create_desk))"#,
r#".route("/api/browse/pick", post(browse_pick))"#,
r#".route("/api/desks/{id}/rename", post(rename_desk))"#,
r#".route("/api/desks/{id}/layout", post(desk_layout))"#,
r#".route("/api/desks/{id}/delete", post(delete_desk))"#,
r#".route("/api/desks/{id}/panes", post(open_pane))"#,
r#".route("/api/desks/{id}/docs", get(desk_docs))"#,
r#".route("/api/desks/{id}/notes", get(desk_notes).post(add_desk_note))"#,
r#".route("/api/desks/{id}/notes/{note}", post(set_desk_note))"#,
r#".route("/api/desks/{id}/notes/{note}/remove", post(remove_desk_note))"#,
r#".route("/api/desks/{id}/notes/{note}/restore", post(restore_desk_note))"#,
r#".route("/api/panes/{id}/delete", post(close_pane))"#,
r#".route("/api/panes/{id}/start", post(start_pane))"#,
r#".route("/api/panes/{id}/stop", post(stop_pane))"#,
r#""/api/panes/{id}/paste""#,
"post(paste_image)",
] {
assert!(src.contains(route), "the route table should hold {route}");
}
}
#[test]
fn the_page_never_puts_the_capability_in_a_url() {
assert!(
APP_JS.contains("/api/desk"),
"the desk socket should be opened from here"
);
for (name, src) in [("app.js", APP_JS), ("desk.js", DESK_JS)] {
for bad in ["cap=${", "capability=${", "?cap=", "&cap=", "?capability="] {
assert!(
!src.contains(bad),
"the capability is in a URL in {name}: {bad}"
);
}
}
}
#[test]
fn the_page_asks_for_the_desk_view_only_in_a_window_opening_a_desk() {
assert_eq!(APP_JS.matches("import(`/assets/desk.js").count(), 1);
let import = APP_JS.find("import(`/assets/desk.js").unwrap();
let sentence = APP_JS
.find("This is a browser tab, so it has no capability")
.expect("a tab is told why there is no desk");
let refusal = APP_JS[..sentence]
.rfind("if (!capability)")
.expect("the sentence is what a page without the capability gets");
assert!(refusal < import, "the import sits past the tab's refusal");
assert!(sentence < import);
for seam in [
"export function open(",
"export function update(",
"export function close(",
] {
assert!(DESK_JS.contains(seam), "desk.js should export `{seam}`");
}
}
#[test]
fn the_page_asks_for_the_game_only_when_the_rocket_is_pressed() {
assert_eq!(APP_JS.matches("import(`/assets/game.js").count(), 1);
let import = APP_JS.find("import(`/assets/game.js").unwrap();
let press = APP_JS
.find(r##"$("#btn-game")"##)
.expect("the rocket is the button the game is behind");
assert!(press < import, "the import sits inside the rocket's press");
for seam in [
"export function open(",
"export function close(",
"export function isOpen(",
] {
assert!(GAME_JS.contains(seam), "game.js should export `{seam}`");
}
}
#[test]
fn the_page_asks_for_the_panels_only_when_one_is_opened() {
assert_eq!(APP_JS.matches("import(`/assets/about.js").count(), 1);
let import = APP_JS.find("import(`/assets/about.js").unwrap();
for button in [r##"$("#btn-about")"##, r##"$("#btn-reset")"##] {
let press = APP_JS
.find(button)
.unwrap_or_else(|| panic!("{button} is a button a panel is behind"));
assert!(
import < press,
"the press reaches the import, not the other way"
);
}
assert!(
ABOUT_JS.contains("export function open("),
"about.js should export `open`"
);
for gone in ["/api/about", "#about-facts", "#reset-go"] {
assert!(
!APP_JS.contains(gone),
"`{gone}` belongs to the chunk now, not to app.js"
);
}
}
#[test]
fn the_page_asks_for_the_diagram_driver_only_when_a_document_holds_one() {
assert_eq!(
APP_JS.matches("import(`/assets/mmd.js").count(),
1,
"one import, so there is one place the laziness can be lost"
);
assert!(
APP_JS.contains(r#"if (docEl.querySelector("pre.mermaid")) mmdLoad()"#),
"the import should sit behind the check for a diagram in this document"
);
for gone in [
"mermaid.run",
"mermaidLib",
"mmdRender",
"mmdReserve",
"mmdDrain",
"mmdQueue",
] {
assert!(!APP_JS.contains(gone), "`{gone}` is back in app.js");
}
for kept in [
"export function prepare(",
"export function retheme(",
"export function escape(",
"export function key(",
] {
assert!(MMD_JS.contains(kept), "mmd.js should export `{kept}`");
}
}
#[test]
fn a_live_ui_serves_the_file_on_disk_and_a_shipped_one_cannot() {
let dir = std::env::temp_dir().join(format!("snyvi-ui-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let css = dir.join("app.css");
std::fs::write(&css, "body { --probe: 1 }").unwrap();
let live = Ui {
dir: Some(dir.clone()),
};
assert_eq!(live.text("app.css", APP_CSS), "body { --probe: 1 }");
assert!(live.live());
let before = live.version("shipped");
std::fs::write(&css, "body { --probe: 2 }").unwrap();
assert_ne!(live.version("shipped"), before);
std::fs::remove_file(&css).unwrap();
assert_eq!(live.text("app.css", APP_CSS), APP_CSS);
let shipped = Ui { dir: None };
assert!(!shipped.live());
assert_eq!(shipped.text("app.css", APP_CSS), APP_CSS);
assert_eq!(shipped.version("shipped"), "shipped");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn every_id_the_script_uses_unguarded_is_in_the_page() {
let mut missing = Vec::new();
for (file, src) in [
("app.js", APP_JS),
("desk.js", DESK_JS),
("frame.js", FRAME_JS),
("game.js", GAME_JS),
("about.js", ABOUT_JS),
("find.js", FIND_JS),
("menu.js", MENU_JS),
] {
for (i, _) in src.match_indices("$(\"#") {
let rest = &src[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(format!("{file}: {id}"));
}
}
}
assert!(missing.is_empty(), "not in index.html: {missing:?}");
}
#[test]
fn the_hash_behind_the_version_covers_every_chunk_the_page_can_fetch() {
let src = include_str!("server.rs");
let from = src.find("let asset_v = {").expect("the startup hash");
let to = from + src[from..].find("\n };").expect("the end of it");
let block = &src[from..to];
for chunk in [
"INDEX_HTML",
"APP_CSS",
"APP_JS",
"DESK_JS",
"FRAME_JS",
"GAME_JS",
"ABOUT_JS",
"FIND_JS",
"MENU_JS",
] {
assert!(
block.contains(chunk),
"{chunk} is served immutable under ?v= but is not in the hash that makes it"
);
}
}
#[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");
}
}
}