use axum::{
extract::{
ws::{Message, WebSocket},
Extension, Path as AxumPath, Query, State, WebSocketUpgrade,
},
http::{header, StatusCode},
response::{Html, IntoResponse, Redirect, Response},
routing::{delete, get, post},
Json, Router,
};
use futures_util::{stream::StreamExt, SinkExt};
use qrcode::render::unicode::Dense1x2;
use qrcode::{EcLevel, QrCode};
use rayon::prelude::*;
use rusqlite::Connection;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use similar::{ChangeTag, TextDiff};
use std::collections::{HashMap, HashSet, VecDeque};
use std::fs;
use std::path::{Path as FsPath, PathBuf};
use std::sync::{Arc, Mutex};
use tera::Tera;
use tokio::net::TcpListener;
use tokio::sync::{broadcast, mpsc};
use crate::admin_auth::{self, AdminBootstrapStore};
use crate::assets::{CssAssets, IconAssets, JsAssets, Templates};
use crate::git;
use crate::i18n;
use crate::markdown::{
default_markdown_engine, MarkdownEngine, MarkdownHtmlRenderer, MarkdownRenderer,
};
use crate::markdown_ast;
use crate::search::{SearchQuery, SearchResult};
use crate::workspace::{
ct_eq, expand_and_canonicalize, generate_token, ServerLock, WorkspaceConfig, WorkspaceEntry,
WorkspaceEvent, WorkspaceFlags, WorkspaceRegistry,
};
use crate::workspace_fs::WorkspaceFs;
const WORKSPACE_WS_ROUTE: &str = "/_/{workspace_id}/ws";
pub mod api {
pub use crate::workspace::WorkspaceInfo;
}
pub struct WorkspaceInit {
pub path: std::path::PathBuf,
pub flags: WorkspaceFlags,
pub initial_path: Option<String>,
pub single_file: Option<String>,
pub collaborator_access_code_hash: String,
pub alias: String,
}
pub struct ServerConfig {
pub host: String,
pub advertised_host: String,
pub trusted_hosts: Vec<String>,
pub port: u16,
pub theme: String,
pub qr: Option<String>,
pub open_browser: Option<String>,
pub shared_annotation: bool,
pub db_path: Option<String>,
pub salt: Option<String>,
pub initial_workspaces: Vec<WorkspaceInit>,
pub bound_listener: Option<std::net::TcpListener>,
pub registry: Option<Arc<WorkspaceRegistry>>,
pub management_token: Option<String>,
pub admin_bootstraps: Option<Arc<AdminBootstrapStore>>,
pub language: Option<String>,
pub shortcuts_json: Option<String>,
pub styles_css: Option<String>,
pub default_chat_mode: String,
pub editor_theme: String,
pub collaborator_access_code_hash: String,
pub print_collapsed_content: bool,
}
#[derive(Default)]
pub(crate) struct AccessAttempts {
pub fails: u32,
pub locked_until: Option<std::time::Instant>,
pub last_attempt: Option<std::time::Instant>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum AccessRole {
Collaborator,
Admin,
}
#[derive(Clone, Debug)]
struct AccessRequirement {
role: AccessRole,
hash: String,
scope: String,
}
#[derive(Clone, Debug, Default)]
pub(crate) struct AllowedHosts {
names: HashSet<String>,
secure_names: HashSet<String>,
}
impl AllowedHosts {
fn insert(&mut self, value: &str) {
if let Some((name, secure)) = normalized_configured_host(value) {
self.names.insert(name.clone());
if secure {
self.secure_names.insert(name);
}
}
}
fn allows_header(&self, host: Option<&str>) -> bool {
host.and_then(normalized_authority_host)
.is_some_and(|host| self.names.contains(&host))
}
fn is_secure_header(&self, host: Option<&str>) -> bool {
host.and_then(normalized_authority_host)
.is_some_and(|host| self.secure_names.contains(&host))
}
}
#[derive(Clone)]
pub(crate) struct AppState {
pub theme: Arc<String>,
pub tera: Arc<Tera>,
pub db: Option<Arc<Mutex<Connection>>>,
pub workspace_registry: Arc<WorkspaceRegistry>,
pub management_token: Arc<String>,
pub admin_bootstraps: Arc<AdminBootstrapStore>,
pub(crate) allowed_hosts: Arc<AllowedHosts>,
pub save_token: Arc<String>,
pub i18n_json: Arc<String>,
pub i18n_lang: Arc<String>,
pub shortcuts_json: Arc<String>,
pub styles_css: Arc<String>,
pub default_chat_mode: Arc<String>,
pub editor_theme: Arc<String>,
pub collaborator_access_code_hash: Arc<String>,
pub access_secret: Arc<String>,
pub access_attempts:
Arc<std::sync::Mutex<std::collections::HashMap<std::net::IpAddr, AccessAttempts>>>,
pub(crate) markdown_diff_cache: Arc<Mutex<MarkdownDiffCache>>,
pub print_collapsed_content: bool,
pub shutdown_tx: mpsc::Sender<()>,
#[cfg(debug_assertions)]
pub dev_reload_tx: Arc<broadcast::Sender<()>>,
}
async fn shutdown_handler(State(state): State<AppState>) -> impl IntoResponse {
let _ = state.shutdown_tx.send(()).await;
StatusCode::OK
}
fn detect_lang(override_lang: &Option<String>) -> String {
match override_lang {
Some(lang) => i18n::resolve_lang(lang).to_string(),
None => i18n::resolve_lang("auto").to_string(),
}
}
fn js_json_safe(json: String) -> String {
json.replace('<', "\\u003c")
.replace('>', "\\u003e")
.replace('&', "\\u0026")
}
pub fn workspace_url_path(workspace_id: &str, initial_path: Option<&str>) -> String {
match initial_path {
Some(path) => workspace_file_url(workspace_id, path),
None => workspace_root_url(workspace_id),
}
}
fn workspace_root_url(workspace_id: &str) -> String {
format!("/{workspace_id}/")
}
fn workspace_file_url(workspace_id: &str, path: &str) -> String {
let rel = path.trim_start_matches('/');
if rel.is_empty() {
workspace_root_url(workspace_id)
} else {
format!("/{workspace_id}/{}", encode_route_path(rel))
}
}
fn workspace_internal_url(workspace_id: &str, path: &str) -> String {
let rel = path.trim_start_matches('/');
format!("/_/{workspace_id}/{rel}")
}
fn workspace_git_history_url(workspace_id: &str) -> String {
workspace_internal_url(workspace_id, "git/history")
}
fn normalize_host_name(value: &str) -> Option<String> {
let trimmed = value.trim().trim_matches(['[', ']']).trim_end_matches('.');
if trimmed.is_empty()
|| trimmed.contains('/')
|| trimmed.contains('\\')
|| trimmed.chars().any(char::is_whitespace)
{
return None;
}
Some(trimmed.to_ascii_lowercase())
}
fn normalized_authority_host(authority: &str) -> Option<String> {
authority
.parse::<axum::http::uri::Authority>()
.ok()
.and_then(|authority| normalize_host_name(authority.host()))
}
fn normalized_configured_host(value: &str) -> Option<(String, bool)> {
let trimmed = value.trim();
if trimmed.is_empty() || trimmed == "local" || trimmed == "missing" {
return None;
}
if trimmed.contains("://") {
let uri = trimmed.parse::<axum::http::Uri>().ok()?;
let authority = uri.authority()?;
let name = normalize_host_name(authority.host())?;
let secure = uri
.scheme_str()
.is_some_and(|scheme| scheme.eq_ignore_ascii_case("https"));
return Some((name, secure));
}
if let Some(name) = normalized_authority_host(trimmed) {
return Some((name, false));
}
trimmed
.parse::<std::net::IpAddr>()
.ok()
.and_then(|ip| normalize_host_name(&ip.to_string()))
.map(|name| (name, false))
}
fn build_allowed_hosts(
bind_host: &str,
advertised_host: &str,
port: u16,
trusted_hosts: &[String],
external_bases: &[Option<String>],
) -> AllowedHosts {
let mut allowed = AllowedHosts::default();
for host in ["localhost", "127.0.0.1", "::1", bind_host, advertised_host] {
allowed.insert(host);
}
for reachable in reachable_urls(bind_host, advertised_host, port).all {
allowed.insert(&reachable.url);
}
for host in trusted_hosts {
allowed.insert(host);
}
for base in external_bases.iter().flatten() {
allowed.insert(base);
}
allowed
}
fn workspace_git_branches_url(workspace_id: &str) -> String {
workspace_internal_url(workspace_id, "git/branches")
}
fn workspace_git_tags_url(workspace_id: &str) -> String {
workspace_internal_url(workspace_id, "git/tags")
}
fn workspace_git_checkout_url(workspace_id: &str) -> String {
workspace_internal_url(workspace_id, "git/checkout")
}
fn workspace_files_data_url(workspace_id: &str) -> String {
workspace_internal_url(workspace_id, "files/data")
}
fn workspace_files_dir_url(workspace_id: &str) -> String {
workspace_internal_url(workspace_id, "files/dir")
}
fn workspace_file_create_url(workspace_id: &str) -> String {
workspace_internal_url(workspace_id, "files/create")
}
fn workspace_folder_create_url(workspace_id: &str) -> String {
workspace_internal_url(workspace_id, "files/folder")
}
fn workspace_settings_features_url(workspace_id: &str) -> String {
workspace_internal_url(workspace_id, "settings/features")
}
fn workspace_compare_base_url(workspace_id: &str) -> String {
workspace_internal_url(workspace_id, "compare")
}
fn workspace_compare_options_url(workspace_id: &str) -> String {
workspace_internal_url(workspace_id, "compare/options")
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ReachableUrl {
pub label: String,
pub url: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ReachableUrls {
pub featured: String,
pub all: Vec<ReachableUrl>,
}
fn assemble_reachable_urls(
bind_host: &str,
advertised_host: &str,
port: u16,
hosts: &[crate::net::BindHostOption],
) -> ReachableUrls {
use crate::net::BindHostKind;
let trimmed = bind_host.trim();
let is_wildcard_v6 = crate::net::host_is_wildcard_v6(trimmed);
let is_wildcard_v4 = crate::net::host_is_wildcard_v4(trimmed);
let is_wildcard = is_wildcard_v4 || is_wildcard_v6;
let is_loopback = crate::net::host_is_loopback(trimmed);
let loopback_addr = if is_wildcard_v6 { "::1" } else { "127.0.0.1" };
let mut entries: Vec<(String, String)> = Vec::new();
if is_wildcard {
entries.push(("localhost".to_string(), loopback_addr.to_string()));
for h in hosts.iter().filter(|h| {
h.kind == BindHostKind::Interface
&& if is_wildcard_v6 {
crate::net::host_is_ipv6(&h.address)
} else {
crate::net::host_is_ipv4(&h.address)
}
}) {
entries.push((h.interface.clone().unwrap_or_default(), h.address.clone()));
}
} else {
let label = if is_loopback {
"localhost".to_string()
} else {
hosts
.iter()
.find(|h| crate::net::host_matches(&h.address, trimmed))
.and_then(|h| h.interface.clone())
.unwrap_or_default()
};
entries.push((label, trimmed.to_string()));
}
let all: Vec<ReachableUrl> = entries
.iter()
.map(|(label, addr)| ReachableUrl {
label: label.clone(),
url: format!("http://{}:{}", crate::net::url_host_literal(addr), port),
})
.collect();
let featured_addr: String = if is_wildcard {
let adv = advertised_host.trim();
let lan: Vec<&String> = entries
.iter()
.filter(|(label, _)| label != "localhost")
.map(|(_, addr)| addr)
.collect();
if !adv.is_empty() && lan.iter().any(|a| crate::net::host_matches(a, adv)) {
adv.to_string()
} else if let Some(first) = lan.first() {
(*first).clone()
} else {
loopback_addr.to_string()
}
} else {
trimmed.to_string()
};
let featured = format!(
"http://{}:{}",
crate::net::url_host_literal(&featured_addr),
port
);
ReachableUrls { featured, all }
}
pub fn reachable_urls(bind_host: &str, advertised_host: &str, port: u16) -> ReachableUrls {
assemble_reachable_urls(
bind_host,
advertised_host,
port,
&crate::net::available_bind_hosts(),
)
}
pub fn featured_base_url(bind_host: &str, advertised_host: &str, port: u16) -> String {
reachable_urls(bind_host, advertised_host, port).featured
}
pub fn build_workspace_url(base: &str, workspace_path: &str) -> String {
let suffix = if workspace_path.starts_with('/') {
workspace_path.to_string()
} else {
format!("/{workspace_path}")
};
format!("{}{}", base.trim_end_matches('/'), suffix)
}
fn canonicalize_route_path(path: &FsPath) -> std::io::Result<PathBuf> {
dunce::canonicalize(path)
}
fn canonical_workspace_root(ws: &WorkspaceEntry) -> PathBuf {
canonicalize_route_path(ws.fs.ambient_root())
.unwrap_or_else(|_| ws.fs.ambient_root().to_path_buf())
}
macro_rules! directory_root_or_not_found {
($ws:expr) => {
match $ws.fs.directory_root() {
Some(root) => root,
None => return StatusCode::NOT_FOUND.into_response(),
}
};
}
fn workspace_relative_path(path: &FsPath, root: &FsPath) -> Option<PathBuf> {
path.strip_prefix(root).ok().map(PathBuf::from)
}
fn is_inside_workspace(path: &FsPath, root: &FsPath) -> bool {
path.starts_with(root)
}
fn deepest_existing_ancestor_inside_workspace(target: &FsPath, root: &FsPath) -> bool {
let mut ancestor = target.parent();
while let Some(dir) = ancestor {
if fs::symlink_metadata(dir).is_ok() {
return matches!(
canonicalize_route_path(dir),
Ok(p) if is_inside_workspace(&p, root)
);
}
ancestor = dir.parent();
}
false
}
fn path_to_route(path: &FsPath) -> String {
path.to_string_lossy().replace('\\', "/")
}
fn workspace_display_name(ws: &WorkspaceEntry, root: &FsPath) -> String {
let alias = ws.alias();
if alias.is_empty() {
root.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_default()
} else {
alias
}
}
fn workspace_display_path(root: &FsPath) -> String {
if let Some(home) = dirs::home_dir() {
if let Ok(rel) = root.strip_prefix(&home) {
return if rel.as_os_str().is_empty() {
"~".to_string()
} else {
format!("~/{}", path_to_route(rel))
};
}
}
root.display().to_string()
}
fn insert_workspace_header_context(
context: &mut tera::Context,
ws: &WorkspaceEntry,
root: &FsPath,
) {
context.insert("workspace_display_name", &workspace_display_name(ws, root));
context.insert("workspace_display_path", &workspace_display_path(root));
}
fn encode_route_path(path: &str) -> String {
path.split('/')
.map(|segment| urlencoding::encode(segment).into_owned())
.collect::<Vec<_>>()
.join("/")
}
fn path_to_hash(path: &FsPath) -> String {
encode_route_path(&path_to_route(path))
}
fn workspace_file_back_link(workspace_id: &str, path: &FsPath, root: &FsPath) -> String {
workspace_relative_path(path, root)
.map(|rel| {
let hash_path = path_to_hash(&rel);
if hash_path.is_empty() {
workspace_root_url(workspace_id)
} else {
format!("/{workspace_id}/#{hash_path}")
}
})
.unwrap_or_else(|| workspace_root_url(workspace_id))
}
fn sanitize_new_file_path(path: &str) -> Option<PathBuf> {
let trimmed = path.trim().trim_matches('/');
if trimmed.is_empty() || trimmed.len() > 4096 || trimmed.contains('\0') {
return None;
}
let candidate = PathBuf::from(trimmed);
if candidate.is_absolute() {
return None;
}
let mut out = PathBuf::new();
for component in candidate.components() {
match component {
std::path::Component::Normal(part) => out.push(part),
std::path::Component::CurDir => {}
_ => return None,
}
}
(!out.as_os_str().is_empty()).then_some(out)
}
fn is_markdown_path(path: &FsPath) -> bool {
path.extension()
.is_some_and(|e| e.to_string_lossy().to_lowercase() == "md")
}
pub fn print_compact_qr(data: &str) -> Result<(), Box<dyn std::error::Error>> {
let code = QrCode::with_error_correction_level(data.as_bytes(), EcLevel::L)?;
let string = code
.render::<Dense1x2>()
.quiet_zone(false) .build();
for line in string.lines() {
println!(" {line}"); }
println!();
Ok(())
}
#[derive(Serialize, Deserialize, Debug)]
#[serde(tag = "type")]
enum WebSocketMessage {
#[serde(rename = "all_annotations")]
AllAnnotations { annotations: Vec<serde_json::Value> },
#[serde(rename = "new_annotation")]
NewAnnotation {
annotation: serde_json::Value,
#[serde(default, skip_serializing_if = "Option::is_none")]
op_id: Option<String>,
},
#[serde(rename = "delete_annotation")]
DeleteAnnotation {
id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
op_id: Option<String>,
},
#[serde(rename = "clear_annotations")]
ClearAnnotations {
#[serde(default, skip_serializing_if = "Option::is_none")]
op_id: Option<String>,
},
#[serde(rename = "viewed_state")]
ViewedState {
state: serde_json::Value,
#[serde(default, skip_serializing_if = "Option::is_none")]
op_id: Option<String>,
},
#[serde(rename = "update_viewed_state")]
UpdateViewedState {
state: serde_json::Value,
#[serde(default, skip_serializing_if = "Option::is_none")]
op_id: Option<String>,
},
#[serde(rename = "live_action")]
LiveAction { data: serde_json::Value },
#[serde(rename = "file_changed")]
FileChanged { workspace_id: String, path: String },
}
#[derive(Deserialize, Debug)]
#[serde(deny_unknown_fields)]
struct WsHello {
#[serde(rename = "type")]
_kind: WsHelloKind,
target: WsTarget,
}
#[derive(Deserialize, Debug)]
#[serde(rename_all = "snake_case")]
enum WsHelloKind {
Hello,
}
#[derive(Deserialize, Debug)]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
enum WsTarget {
Document { path: String },
Surface { key: String },
}
#[derive(Clone, Debug, PartialEq, Eq)]
enum WsSessionTarget {
Document { file_path: String },
Surface,
}
#[derive(Clone, Debug, PartialEq, Eq)]
struct WsSession {
channel: String,
target: WsSessionTarget,
}
pub async fn start(config: ServerConfig) -> Result<(), String> {
let ServerConfig {
host,
advertised_host,
trusted_hosts,
port,
theme,
qr,
open_browser,
shared_annotation: _,
db_path,
salt,
initial_workspaces,
bound_listener,
registry,
management_token,
admin_bootstraps,
language,
shortcuts_json,
styles_css,
default_chat_mode,
editor_theme,
collaborator_access_code_hash,
print_collapsed_content,
} = config;
let mut tera = Tera::default();
for file_name in Templates::iter() {
if let Some(file) = Templates::get(&file_name) {
match std::str::from_utf8(&file.data) {
Ok(content) => {
if let Err(e) = tera.add_raw_template(&file_name, content) {
return Err(format!("Failed to add template '{file_name}': {e}"));
}
}
Err(e) => {
return Err(format!("Failed to read template '{file_name}': {e}"));
}
}
}
}
let db_path = std::env::var("MARKON_SQLITE_PATH")
.ok()
.or(db_path)
.unwrap_or_else(|| {
let home = dirs::home_dir().expect("Cannot find home directory");
home.join(".markon/annotation.sqlite")
.to_string_lossy()
.to_string()
});
let parent_dir = std::path::Path::new(&db_path).parent().unwrap();
fs::create_dir_all(parent_dir).expect("Failed to create database directory");
let conn = Connection::open(&db_path).expect("Failed to open database");
let journal_mode: String = conn
.query_row("PRAGMA journal_mode=WAL", [], |row| row.get(0))
.unwrap_or_default();
if !journal_mode.eq_ignore_ascii_case("wal") {
tracing::warn!(
got = %journal_mode,
"could not enable WAL journal mode; concurrent multi-process access may hit 'database is locked'"
);
}
conn.pragma_update(None, "busy_timeout", 5000)
.expect("Failed to set busy_timeout");
conn.execute(
"CREATE TABLE IF NOT EXISTS annotations (
id TEXT PRIMARY KEY,
file_path TEXT NOT NULL,
data TEXT NOT NULL
)",
[],
)
.expect("Failed to create annotations table");
conn.execute(
"CREATE TABLE IF NOT EXISTS viewed_state (
file_path TEXT PRIMARY KEY,
state TEXT NOT NULL,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)",
[],
)
.expect("Failed to create viewed_state table");
crate::chat::storage::ChatStorage::init(&conn).expect("Failed to create chat tables");
let db = Some(Arc::new(Mutex::new(conn)));
let effective_salt = salt.unwrap_or_else(|| format!("markon:{port}"));
let access_cookie_secret = effective_salt.clone();
let registry = registry.unwrap_or_else(|| Arc::new(WorkspaceRegistry::new(effective_salt)));
let mut first_workspace_url_path: Option<String> = None;
for ws_init in initial_workspaces {
let path = expand_and_canonicalize(&ws_init.path.to_string_lossy())
.unwrap_or_else(|_| ws_init.path.clone());
let id = registry.add(WorkspaceConfig {
path,
flags: ws_init.flags,
single_file: ws_init.single_file,
collaborator_access_code_hash: ws_init.collaborator_access_code_hash,
alias: ws_init.alias,
});
if first_workspace_url_path.is_none() {
let url_path = workspace_url_path(&id, ws_init.initial_path.as_deref());
first_workspace_url_path = Some(url_path);
}
}
let token = Arc::new(management_token.unwrap_or_else(generate_token));
let admin_bootstraps = admin_bootstraps.unwrap_or_else(|| Arc::new(AdminBootstrapStore::new()));
let allowed_hosts = Arc::new(build_allowed_hosts(
&host,
&advertised_host,
port,
&trusted_hosts,
&[qr.clone(), open_browser.clone()],
));
let save_token = Arc::new(generate_token());
let (shutdown_tx, mut shutdown_rx) = mpsc::channel::<()>(1);
let state = AppState {
theme: Arc::new(theme),
tera: Arc::new(tera),
db,
workspace_registry: registry,
management_token: token.clone(),
admin_bootstraps: admin_bootstraps.clone(),
allowed_hosts,
save_token: save_token.clone(),
i18n_json: Arc::new(js_json_safe(i18n::load_i18n())),
i18n_lang: Arc::new(detect_lang(&language)),
shortcuts_json: Arc::new(js_json_safe(
shortcuts_json.unwrap_or_else(|| "null".to_string()),
)),
styles_css: Arc::new(styles_css.unwrap_or_default()),
default_chat_mode: Arc::new(default_chat_mode),
editor_theme: Arc::new(editor_theme),
collaborator_access_code_hash: Arc::new(collaborator_access_code_hash),
access_secret: Arc::new(access_cookie_secret),
access_attempts: Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())),
markdown_diff_cache: Arc::new(Mutex::new(MarkdownDiffCache::default())),
print_collapsed_content,
shutdown_tx,
#[cfg(debug_assertions)]
dev_reload_tx: Arc::new(broadcast::channel::<()>(16).0),
};
let mgmt = Router::new()
.route("/api/workspace", post(add_workspace_handler))
.route(
"/api/workspace/{id}",
delete(remove_workspace_handler).put(update_workspace_handler),
)
.route(
"/api/workspace/{id}/access",
axum::routing::put(update_workspace_access_handler),
)
.route("/api/workspaces", get(list_workspaces_handler))
.route("/api/admin/bootstrap", post(create_admin_bootstrap_handler))
.route("/api/shutdown", post(shutdown_handler))
.layer(axum::middleware::from_fn_with_state(
state.clone(),
require_local_and_token,
));
let save = Router::new()
.route("/api/save", post(save_file_handler))
.layer(axum::middleware::from_fn_with_state(
state.clone(),
require_local_save_origin,
));
let app = Router::new()
.route("/favicon.ico", get(serve_favicon))
.route("/_/favicon.ico", get(serve_favicon))
.route("/_/favicon.svg", get(serve_favicon_svg))
.route("/_/css/{filename}", get(serve_css))
.route("/_/js/{*path}", get(serve_js))
.route("/_/admin", get(admin_bootstrap_page))
.route("/_/admin/bootstrap", get(admin_bootstrap_page))
.route("/_/admin/session", post(admin_session_handler))
.route("/_/ws/{workspace_id}", get(config_ws_handler))
.route("/_/{workspace_id}/search", get(workspace_search_handler))
.route("/api/preview", post(preview_handler))
.route("/_/unlock", post(unlock_handler))
.route(
"/_/{workspace_id}/git/data/history",
get(handle_git_history_data),
)
.route(
"/_/{workspace_id}/git/data/diff/work",
get(handle_git_working_diff_data),
)
.route(
"/_/{workspace_id}/git/data/show/{commit}",
get(handle_git_commit_diff_data),
)
.route("/_/{workspace_id}/git/history", get(handle_git_history))
.route("/_/{workspace_id}/git/branches", get(handle_git_branches))
.route("/_/{workspace_id}/git/tags", get(handle_git_tags))
.route(
"/_/{workspace_id}/compare/options",
get(handle_git_compare_options_status),
)
.route(
"/_/{workspace_id}/git/diff/work",
get(handle_git_working_diff),
)
.route(
"/_/{workspace_id}/git/show/{commit}",
get(handle_git_commit_diff),
)
.route(
"/_/{workspace_id}/compare/{*range}",
get(handle_pretty_compare_diff),
)
.route(
"/_/{workspace_id}/git/commit",
post(handle_git_commit)
.route_layer(axum::middleware::from_fn(require_admin_role))
.route_layer(axum::middleware::from_fn(require_same_origin)),
)
.route(
"/_/{workspace_id}/git/checkout",
post(handle_git_checkout)
.route_layer(axum::middleware::from_fn(require_admin_role))
.route_layer(axum::middleware::from_fn(require_same_origin)),
)
.route(
"/_/{workspace_id}/files/data",
get(handle_workspace_files_data),
)
.route(
"/_/{workspace_id}/files/dir",
get(handle_workspace_dir_data),
)
.route(
"/_/{workspace_id}/files/create",
post(handle_workspace_create_file)
.route_layer(axum::middleware::from_fn(require_admin_role))
.route_layer(axum::middleware::from_fn(require_same_origin)),
)
.route(
"/_/{workspace_id}/files/folder",
post(handle_workspace_create_folder)
.route_layer(axum::middleware::from_fn(require_admin_role))
.route_layer(axum::middleware::from_fn(require_same_origin)),
)
.route(
"/_/{workspace_id}/files/delete",
post(handle_workspace_delete_file)
.route_layer(axum::middleware::from_fn(require_admin_role))
.route_layer(axum::middleware::from_fn(require_same_origin)),
)
.route(
"/_/{workspace_id}/settings/features",
post(handle_workspace_update_features)
.route_layer(axum::middleware::from_fn(require_admin_role))
.route_layer(axum::middleware::from_fn(require_same_origin)),
)
.route(
"/_/{workspace_id}/settings/alias",
post(handle_workspace_update_alias)
.route_layer(axum::middleware::from_fn(require_admin_role))
.route_layer(axum::middleware::from_fn(require_same_origin)),
)
.route("/_/{workspace_id}/chat", get(handle_chat_popout))
.route(WORKSPACE_WS_ROUTE, get(ws_handler))
.route("/{workspace_id}/", get(handle_workspace_root))
.route("/{workspace_id}/{*path}", get(handle_workspace_path))
.fallback(|| async { StatusCode::NOT_FOUND })
.merge(mgmt)
.merge(save);
#[cfg(debug_assertions)]
let app = app
.route("/_/dev/reload-stream", get(dev_reload_stream))
.route("/_/dev/reload-trigger", post(dev_reload_trigger));
let app = app.merge(
crate::chat::routes::router().route_layer(axum::middleware::from_fn(require_same_origin)),
);
let app = app.layer(axum::middleware::from_fn_with_state(
state.clone(),
require_access_code,
));
let app = app.layer(axum::middleware::from_fn_with_state(
state.clone(),
require_allowed_host,
));
let app = app.layer(axum::middleware::from_fn(security_headers));
let app = app.with_state(state);
let listener = if let Some(std_listener) = bound_listener {
std_listener
.set_nonblocking(true)
.map_err(|e| format!("Failed to set non-blocking: {e}"))?;
TcpListener::from_std(std_listener)
.map_err(|e| format!("Failed to convert listener: {e}"))?
} else {
let addr = crate::net::bind_socket_addr(&host, port)?;
TcpListener::bind(&addr)
.await
.map_err(|e| format!("Failed to bind to {addr}: {e}"))?
};
let addr = listener
.local_addr()
.map_err(|e| format!("Failed to get local address: {e}"))?;
let local_base = featured_base_url(&host, &advertised_host, addr.port());
println!("listening on http://{addr}");
if let Some(ref p) = first_workspace_url_path {
println!("workspace: {}", build_workspace_url(&local_base, p));
}
let _lock_guard = {
if let Err(e) = (ServerLock {
port: addr.port(),
token: token.as_ref().clone(),
host: host.clone(),
})
.write()
{
tracing::warn!("failed to write lock file: {e}");
}
struct LockGuard;
impl Drop for LockGuard {
fn drop(&mut self) {
ServerLock::remove();
}
}
LockGuard
};
let make_url = |base_option: &str, ws_path: &Option<String>| -> String {
let base = if base_option == "local" {
local_base.clone()
} else {
base_option.to_string()
};
match ws_path {
Some(p) => build_workspace_url(&base, p),
None => format!("{}/", base.trim_end_matches('/')),
}
};
let custom_base = qr
.as_ref()
.filter(|u| u.as_str() != "missing")
.or_else(|| open_browser.as_ref().filter(|u| u.as_str() != "local"));
if let Some(base) = custom_base {
println!(
"accessible at {}",
make_url(base, &first_workspace_url_path)
);
}
if let Some(ref qr_option) = qr {
println!();
let qr_url = if qr_option == "missing" {
make_url("local", &first_workspace_url_path)
} else {
make_url(qr_option, &first_workspace_url_path)
};
if let Err(e) = print_compact_qr(&qr_url) {
eprintln!("Failed to generate QR code: {e}");
}
}
if let Some(ref base_opt) = open_browser {
let base = if base_opt == "local" {
local_base.clone()
} else {
base_opt.to_string()
};
let redirect = first_workspace_url_path.as_deref().unwrap_or("/");
let nonce = admin_bootstraps.issue_url(redirect);
let bootstrap = build_workspace_url(&base, "/_/admin/bootstrap");
let url = format!("{bootstrap}#nonce={nonce}");
if let Err(e) = open::that(&url) {
tracing::warn!("best-effort browser open failed: {e}");
}
}
axum::serve(
listener,
app.into_make_service_with_connect_info::<std::net::SocketAddr>(),
)
.with_graceful_shutdown(async move {
shutdown_rx.recv().await;
println!("Shutting down...");
})
.await
.map_err(|e| format!("Server error: {e}"))?;
Ok(())
}
async fn config_ws_handler(
ws: WebSocketUpgrade,
State(state): State<AppState>,
AxumPath(workspace_id): AxumPath<String>,
axum::extract::ConnectInfo(addr): axum::extract::ConnectInfo<std::net::SocketAddr>,
headers: axum::http::HeaderMap,
) -> impl IntoResponse {
if !check_ws_origin(&headers, &addr) {
return StatusCode::FORBIDDEN.into_response();
}
let Some(ws_entry) = state.workspace_registry.get(&workspace_id) else {
return StatusCode::NOT_FOUND.into_response();
};
let mut rx = ws_entry.config_tx.subscribe();
ws.on_upgrade(move |mut socket| async move {
loop {
match rx.recv().await {
Ok(()) => {
if socket
.send(axum::extract::ws::Message::Text("reload".into()))
.await
.is_err()
{
break;
}
}
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
tracing::warn!(skipped = n, "config ws broadcast lagged; continuing");
continue;
}
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
}
}
})
}
fn check_ws_origin(headers: &axum::http::HeaderMap, peer: &std::net::SocketAddr) -> bool {
same_origin_or_loopback_no_origin(headers, peer)
}
fn same_origin_or_loopback_no_origin(
headers: &axum::http::HeaderMap,
peer: &std::net::SocketAddr,
) -> bool {
let origin = headers
.get(axum::http::header::ORIGIN)
.and_then(|v| v.to_str().ok());
match origin {
None => peer.ip().is_loopback(),
Some(o) if o.trim().eq_ignore_ascii_case("null") => false,
Some(o) => {
let host = headers
.get(axum::http::header::HOST)
.and_then(|v| v.to_str().ok());
origin_matches_host(o, host)
}
}
}
fn authorize_ws_target(entry: &WorkspaceEntry, target: WsTarget) -> Option<WsSession> {
match target {
WsTarget::Document { path } => {
let requested = FsPath::new(&path);
if path.is_empty()
|| path.len() > 4096
|| path.contains('\0')
|| !requested.is_absolute()
{
return None;
}
let authorized = entry.fs.resolve_content_input(requested).ok()?;
if !authorized.is_file() {
return None;
}
let file_path = authorized.to_string_lossy().into_owned();
Some(WsSession {
channel: format!("document:{file_path}"),
target: WsSessionTarget::Document { file_path },
})
}
WsTarget::Surface { key } => {
if !entry.enable_live.load(std::sync::atomic::Ordering::Relaxed) {
return None;
}
let key = canonical_ws_surface_key(&entry.id, &key)?;
Some(WsSession {
channel: format!("surface:{key}"),
target: WsSessionTarget::Surface,
})
}
}
}
fn canonical_ws_surface_key(workspace_id: &str, key: &str) -> Option<String> {
if key.is_empty() || key.len() > 2048 || key.contains('\0') {
return None;
}
let without_fragment = key.split('#').next().unwrap_or(key);
let path = without_fragment
.split('?')
.next()
.unwrap_or(without_fragment);
let public_root = format!("/{workspace_id}");
let internal_root = format!("/_/{workspace_id}");
let in_workspace = path == public_root
|| path.starts_with(&format!("{public_root}/"))
|| path == internal_root
|| path.starts_with(&format!("{internal_root}/"));
in_workspace.then(|| without_fragment.to_string())
}
fn origin_matches_host(origin: &str, host: Option<&str>) -> bool {
let Some(host) = host else { return false };
let Some(rest) = origin.split_once("://").map(|(_, r)| r) else {
return false;
};
let authority = rest.split(['/', '?', '#']).next().unwrap_or(rest);
authority.eq_ignore_ascii_case(host)
}
const ACCESS_COOKIE: &str = "markon_access";
const ACCESS_TTL_SECS: u64 = 30 * 24 * 60 * 60; const ACCESS_MAX_FAILS: u32 = 5;
const ACCESS_BASE_COOLDOWN_SECS: u64 = 30;
const ACCESS_MAX_COOLDOWN_SECS: u64 = 60 * 60;
fn access_now_unix() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
fn access_hex(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{b:02x}")).collect()
}
fn access_sig(secret: &str, payload_hex: &str) -> String {
admin_auth::auth_tag(secret, b"markon-access-cookie\0", payload_hex)
}
fn workspace_save_token(secret: &str, workspace_id: &str) -> String {
admin_auth::auth_tag(secret, b"markon-save-workspace\0", workspace_id)
}
fn make_access_cookie(secret: &str, scopes: &[(String, String)], exp: u64, secure: bool) -> String {
let mut payload = exp.to_string();
for (scope, hash) in scopes {
payload.push('|');
payload.push_str(scope);
payload.push('=');
payload.push_str(hash);
}
let payload_hex = access_hex(payload.as_bytes());
let sig = access_sig(secret, &payload_hex);
let secure_attr = if secure { "; Secure" } else { "" };
format!(
"{ACCESS_COOKIE}={payload_hex}.{sig}; Path=/; Max-Age={ACCESS_TTL_SECS}; HttpOnly; SameSite=Lax{secure_attr}"
)
}
fn access_cookie_scopes(secret: &str, cookie_header: Option<&str>) -> Vec<(String, String)> {
let Some(header) = cookie_header else {
return Vec::new();
};
let Some(token) = header
.split(';')
.filter_map(|kv| kv.trim().split_once('='))
.find(|(k, _)| *k == ACCESS_COOKIE)
.map(|(_, v)| v)
else {
return Vec::new();
};
let Some((payload_hex, sig)) = token.split_once('.') else {
return Vec::new();
};
if payload_hex.len() % 2 != 0
|| !ct_eq(access_sig(secret, payload_hex).as_bytes(), sig.as_bytes())
{
return Vec::new();
}
let Ok(payload_bytes) = (0..payload_hex.len())
.step_by(2)
.map(|i| u8::from_str_radix(&payload_hex[i..i + 2], 16))
.collect::<Result<Vec<u8>, _>>()
else {
return Vec::new();
};
let Ok(payload) = String::from_utf8(payload_bytes) else {
return Vec::new();
};
let mut fields = payload.split('|');
let Some(exp) = fields.next().and_then(|s| s.parse::<u64>().ok()) else {
return Vec::new();
};
if access_now_unix() >= exp {
return Vec::new();
}
fields
.filter_map(|kv| kv.split_once('='))
.map(|(s, h)| (s.to_string(), h.to_string()))
.collect()
}
fn decoded_workspace_id(segment: &str) -> Option<String> {
let decoded = urlencoding::decode(segment).ok()?;
(decoded.len() == 8 && decoded.bytes().all(|byte| byte.is_ascii_hexdigit()))
.then(|| decoded.into_owned())
}
fn access_gated_workspace(path: &str) -> Option<String> {
let segs: Vec<&str> = path.trim_start_matches('/').split('/').collect();
if segs.len() >= 3 && segs[0] == "api" && segs[1] == "chat" {
return decoded_workspace_id(segs[2]);
}
if segs.len() >= 3 && segs[0] == "_" && segs[1] == "ws" {
return decoded_workspace_id(segs[2]);
}
if segs.len() >= 2 && segs[0] == "_" {
if let Some(workspace_id) = decoded_workspace_id(segs[1]) {
return Some(workspace_id);
}
}
segs.first()
.and_then(|segment| decoded_workspace_id(segment))
}
fn access_safe_redirect(redirect: &str, ws_id: &str) -> String {
if redirect.starts_with('/') && !redirect.starts_with("//") {
redirect.to_string()
} else {
format!("/{ws_id}/")
}
}
fn prune_access_attempts(
map: &mut std::collections::HashMap<std::net::IpAddr, AccessAttempts>,
now: std::time::Instant,
) {
let max = std::time::Duration::from_secs(ACCESS_MAX_COOLDOWN_SECS);
map.retain(|_, st| match st.last_attempt {
Some(ts) => now.saturating_duration_since(ts) < max,
None => true,
});
}
fn access_cooldown_remaining(state: &AppState, ip: std::net::IpAddr) -> Option<u64> {
let mut map = state.access_attempts.lock().unwrap();
let now = std::time::Instant::now();
prune_access_attempts(&mut map, now);
let until = map.get(&ip)?.locked_until?;
(until > now).then(|| (until - now).as_secs() + 1)
}
fn access_record_failure(state: &AppState, ip: std::net::IpAddr) -> Option<u64> {
let mut map = state.access_attempts.lock().unwrap();
let now = std::time::Instant::now();
prune_access_attempts(&mut map, now);
let st = map.entry(ip).or_default();
st.fails += 1;
st.last_attempt = Some(now);
if st.fails >= ACCESS_MAX_FAILS {
let over = (st.fails - ACCESS_MAX_FAILS).min(7);
let secs = (ACCESS_BASE_COOLDOWN_SECS << over).min(ACCESS_MAX_COOLDOWN_SECS);
st.locked_until = Some(now + std::time::Duration::from_secs(secs));
Some(secs)
} else {
None
}
}
fn access_record_success(state: &AppState, ip: std::net::IpAddr) {
state.access_attempts.lock().unwrap().remove(&ip);
}
fn access_requirements_for(state: &AppState, ws_id: &str) -> Vec<AccessRequirement> {
let entry = state.workspace_registry.get(ws_id);
let workspace_collaborator = entry.as_ref().map(|e| e.collaborator_access_code_hash());
let (collaborator_hash, collaborator_scope) =
if let Some(hash) = workspace_collaborator.filter(|hash| !hash.is_empty()) {
(hash, format!("w:{ws_id}:collaborator"))
} else if !state.collaborator_access_code_hash.is_empty() {
(
state.collaborator_access_code_hash.as_str().to_string(),
"s:collaborator".to_string(),
)
} else {
(String::new(), String::new())
};
let mut out = Vec::new();
if !collaborator_hash.is_empty() {
out.push(AccessRequirement {
role: AccessRole::Collaborator,
hash: collaborator_hash,
scope: collaborator_scope,
});
}
out
}
fn access_role_from_cookie(
state: &AppState,
ws_id: &str,
cookie_header: Option<&str>,
) -> Option<AccessRole> {
let requirements = access_requirements_for(state, ws_id);
if requirements.is_empty() {
return Some(AccessRole::Collaborator);
}
let scopes = access_cookie_scopes(&state.access_secret, cookie_header);
for req in requirements.iter() {
if scopes
.iter()
.any(|(s, h)| s == &req.scope && h == &req.hash)
{
return Some(req.role);
}
}
None
}
fn render_access_gate(
state: &AppState,
ws_id: &str,
redirect: &str,
err: Option<(&str, u64)>,
) -> Response {
let mut ctx = tera::Context::new();
ctx.insert("workspace_id", ws_id);
ctx.insert("redirect", &access_safe_redirect(redirect, ws_id));
ctx.insert("theme", state.theme.as_str());
ctx.insert("i18n_json", state.i18n_json.as_str());
ctx.insert("i18n_lang", state.i18n_lang.as_str());
ctx.insert("error", "");
ctx.insert("cooldown", &0u64);
if let Some((kind, cooldown)) = err {
ctx.insert("error", kind);
ctx.insert("cooldown", &cooldown);
}
match state.tera.render("access-gate.html", &ctx) {
Ok(html) => (StatusCode::OK, Html(html)).into_response(),
Err(e) => {
tracing::error!("access gate render failed: {e}");
(StatusCode::INTERNAL_SERVER_ERROR, "gate error").into_response()
}
}
}
async fn require_access_code(
State(state): State<AppState>,
mut req: axum::extract::Request,
next: axum::middleware::Next,
) -> Response {
let path = req.uri().path().to_string();
let ws_id = access_gated_workspace(&path);
let Some(ws_id) = ws_id else {
return next.run(req).await;
};
let cookie = req
.headers()
.get(axum::http::header::COOKIE)
.and_then(|value| value.to_str().ok());
if admin_auth::admin_cookie_valid(&state.management_token, cookie, access_now_unix()) {
req.extensions_mut().insert(AccessRole::Admin);
return next.run(req).await;
}
let requirements = access_requirements_for(&state, &ws_id);
if requirements.is_empty() {
req.extensions_mut().insert(AccessRole::Collaborator);
return next.run(req).await;
};
if let Some(role) = access_role_from_cookie(&state, &ws_id, cookie) {
req.extensions_mut().insert(role);
return next.run(req).await;
}
let websocket_upgrade = req
.headers()
.get(axum::http::header::UPGRADE)
.and_then(|value| value.to_str().ok())
.is_some_and(|value| value.eq_ignore_ascii_case("websocket"));
if websocket_upgrade {
return (StatusCode::UNAUTHORIZED, "Access code required").into_response();
}
if req.method() == axum::http::Method::GET && !path.starts_with("/api/") {
render_access_gate(&state, &ws_id, &path, None)
} else {
(StatusCode::UNAUTHORIZED, "Access code required").into_response()
}
}
async fn require_admin_role(req: axum::extract::Request, next: axum::middleware::Next) -> Response {
if req.extensions().get::<AccessRole>() == Some(&AccessRole::Admin) {
next.run(req).await
} else {
StatusCode::FORBIDDEN.into_response()
}
}
async fn require_allowed_host(
State(state): State<AppState>,
req: axum::extract::Request,
next: axum::middleware::Next,
) -> Response {
let host = req
.headers()
.get(axum::http::header::HOST)
.and_then(|value| value.to_str().ok());
if !state.allowed_hosts.allows_header(host) {
tracing::warn!(
host = host.unwrap_or("<missing>"),
"request rejected: untrusted Host"
);
return StatusCode::MISDIRECTED_REQUEST.into_response();
}
next.run(req).await
}
#[derive(Deserialize)]
#[serde(rename_all = "snake_case")]
enum AdminBootstrapMode {
Url,
Code,
}
#[derive(Deserialize)]
struct CreateAdminBootstrapRequest {
mode: AdminBootstrapMode,
#[serde(default = "admin_default_redirect")]
redirect: String,
}
fn admin_default_redirect() -> String {
"/".to_string()
}
#[derive(Serialize)]
struct CreateAdminBootstrapResponse {
path: &'static str,
nonce: Option<String>,
code: Option<String>,
expires_in: u64,
}
async fn create_admin_bootstrap_handler(
State(state): State<AppState>,
Json(request): Json<CreateAdminBootstrapRequest>,
) -> Json<CreateAdminBootstrapResponse> {
let response = match request.mode {
AdminBootstrapMode::Url => CreateAdminBootstrapResponse {
path: "/_/admin/bootstrap",
nonce: Some(state.admin_bootstraps.issue_url(&request.redirect)),
code: None,
expires_in: 60,
},
AdminBootstrapMode::Code => CreateAdminBootstrapResponse {
path: "/_/admin",
nonce: None,
code: Some(state.admin_bootstraps.issue_code(&request.redirect)),
expires_in: 5 * 60,
},
};
Json(response)
}
async fn admin_bootstrap_page(State(state): State<AppState>) -> Response {
let context = base_context(&state);
let mut response = render_template(&state, "admin-bootstrap.html", &context);
response.headers_mut().insert(
axum::http::header::CACHE_CONTROL,
axum::http::HeaderValue::from_static("no-store"),
);
response
}
#[derive(Deserialize)]
struct AdminSessionRequest {
nonce: Option<String>,
code: Option<String>,
}
#[derive(Serialize)]
struct AdminSessionResponse {
redirect: String,
}
async fn admin_session_handler(
State(state): State<AppState>,
axum::extract::ConnectInfo(addr): axum::extract::ConnectInfo<std::net::SocketAddr>,
headers: axum::http::HeaderMap,
Json(request): Json<AdminSessionRequest>,
) -> Response {
if !same_origin_or_loopback_no_origin(&headers, &addr) {
return StatusCode::FORBIDDEN.into_response();
}
let redirect = request
.nonce
.as_deref()
.and_then(|nonce| state.admin_bootstraps.consume_url(nonce))
.or_else(|| {
request
.code
.as_deref()
.and_then(|code| state.admin_bootstraps.consume_code(code))
});
let Some(redirect) = redirect else {
return StatusCode::UNAUTHORIZED.into_response();
};
let host = headers
.get(axum::http::header::HOST)
.and_then(|value| value.to_str().ok());
let cookie = admin_auth::make_admin_cookie(
&state.management_token,
access_now_unix(),
state.allowed_hosts.is_secure_header(host),
);
(
[
(axum::http::header::SET_COOKIE, cookie),
(axum::http::header::CACHE_CONTROL, "no-store".to_string()),
],
Json(AdminSessionResponse { redirect }),
)
.into_response()
}
#[derive(serde::Deserialize)]
struct UnlockForm {
code: String,
workspace_id: String,
redirect: String,
}
async fn unlock_handler(
State(state): State<AppState>,
axum::extract::ConnectInfo(addr): axum::extract::ConnectInfo<std::net::SocketAddr>,
headers: axum::http::HeaderMap,
axum::extract::Form(form): axum::extract::Form<UnlockForm>,
) -> Response {
let ip = addr.ip();
let redirect = access_safe_redirect(&form.redirect, &form.workspace_id);
if let Some(remaining) = access_cooldown_remaining(&state, ip) {
tracing::warn!(%ip, ws = %form.workspace_id, "access unlock blocked: cooldown {remaining}s");
return render_access_gate(
&state,
&form.workspace_id,
&redirect,
Some(("cooldown", remaining)),
);
}
let requirements = access_requirements_for(&state, &form.workspace_id);
if requirements.is_empty() {
return Redirect::to(&redirect).into_response();
};
if let Some(req) = requirements.iter().find(|req| {
crate::workspace::access_code_matches(&state.access_secret, &form.code, &req.hash)
}) {
access_record_success(&state, ip);
tracing::info!(%ip, ws = %form.workspace_id, role = ?req.role, "access unlocked");
let cookie_hdr = headers
.get(axum::http::header::COOKIE)
.and_then(|v| v.to_str().ok());
let mut scopes = access_cookie_scopes(&state.access_secret, cookie_hdr);
if let Some(entry) = scopes.iter_mut().find(|(s, _)| s == &req.scope) {
entry.1 = req.hash.clone();
} else {
scopes.push((req.scope.clone(), req.hash.clone()));
}
let cookie = make_access_cookie(
&state.access_secret,
&scopes,
access_now_unix() + ACCESS_TTL_SECS,
state.allowed_hosts.is_secure_header(
headers
.get(axum::http::header::HOST)
.and_then(|value| value.to_str().ok()),
),
);
return (
[(axum::http::header::SET_COOKIE, cookie)],
Redirect::to(&redirect),
)
.into_response();
}
let cooldown = access_record_failure(&state, ip);
tracing::warn!(%ip, ws = %form.workspace_id, "access unlock failed");
let err = cooldown.map_or(("wrong", 0), |s| ("cooldown", s));
render_access_gate(&state, &form.workspace_id, &redirect, Some(err))
}
const MAX_WS_MSG_BYTES: usize = 256 * 1024;
const SECURITY_CSP: &str = "default-src 'self'; \
script-src 'self' 'unsafe-inline'; \
style-src 'self' 'unsafe-inline'; \
img-src * data: blob:; media-src * data: blob:; font-src 'self' data:; \
connect-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; \
frame-ancestors 'self'";
async fn security_headers(req: axum::extract::Request, next: axum::middleware::Next) -> Response {
let mut resp = next.run(req).await;
let h = resp.headers_mut();
h.insert(
axum::http::header::X_CONTENT_TYPE_OPTIONS,
axum::http::HeaderValue::from_static("nosniff"),
);
h.insert(
axum::http::header::X_FRAME_OPTIONS,
axum::http::HeaderValue::from_static("SAMEORIGIN"),
);
h.insert(
axum::http::header::CONTENT_SECURITY_POLICY,
axum::http::HeaderValue::from_static(SECURITY_CSP),
);
resp
}
async fn require_same_origin(
req: axum::extract::Request,
next: axum::middleware::Next,
) -> Response {
let headers = req.headers();
if let Some(origin) = headers
.get(axum::http::header::ORIGIN)
.and_then(|v| v.to_str().ok())
{
let host = headers
.get(axum::http::header::HOST)
.and_then(|v| v.to_str().ok());
if !origin_matches_host(origin, host) {
return StatusCode::FORBIDDEN.into_response();
}
}
next.run(req).await
}
async fn require_local_and_token(
State(state): State<AppState>,
axum::extract::ConnectInfo(addr): axum::extract::ConnectInfo<std::net::SocketAddr>,
req: axum::extract::Request,
next: axum::middleware::Next,
) -> Response {
if !addr.ip().is_loopback() {
return StatusCode::FORBIDDEN.into_response();
}
let ok = req
.headers()
.get("X-Markon-Token")
.and_then(|v| v.to_str().ok())
.map(|t| ct_eq(t.as_bytes(), state.management_token.as_bytes()))
.unwrap_or(false);
if !ok {
return StatusCode::UNAUTHORIZED.into_response();
}
next.run(req).await
}
async fn require_local_save_origin(
State(_state): State<AppState>,
axum::extract::ConnectInfo(addr): axum::extract::ConnectInfo<std::net::SocketAddr>,
req: axum::extract::Request,
next: axum::middleware::Next,
) -> Response {
if !same_origin_or_loopback_no_origin(req.headers(), &addr) {
return StatusCode::FORBIDDEN.into_response();
}
next.run(req).await
}
async fn ws_handler(
ws: WebSocketUpgrade,
State(state): State<AppState>,
AxumPath(workspace_id): AxumPath<String>,
axum::extract::ConnectInfo(addr): axum::extract::ConnectInfo<std::net::SocketAddr>,
headers: axum::http::HeaderMap,
) -> impl IntoResponse {
if !check_ws_origin(&headers, &addr) {
return StatusCode::FORBIDDEN.into_response();
}
let Some(entry) = state.workspace_registry.get(&workspace_id) else {
return StatusCode::NOT_FOUND.into_response();
};
let flags = entry.flags();
if !flags.shared_annotation && !flags.enable_live {
return StatusCode::FORBIDDEN.into_response();
}
ws.max_message_size(MAX_WS_MSG_BYTES)
.max_frame_size(MAX_WS_MSG_BYTES)
.on_upgrade(move |socket| handle_socket(socket, state, entry))
.into_response()
}
#[cfg(debug_assertions)]
async fn dev_reload_stream(State(state): State<AppState>) -> impl IntoResponse {
use axum::response::sse::{Event, KeepAlive, Sse};
use std::convert::Infallible;
let rx = state.dev_reload_tx.subscribe();
let stream = tokio_stream::wrappers::BroadcastStream::new(rx).filter_map(|item| async move {
item.ok()
.map(|()| Ok::<Event, Infallible>(Event::default().event("reload")))
});
Sse::new(stream).keep_alive(KeepAlive::default())
}
#[cfg(debug_assertions)]
async fn dev_reload_trigger(State(state): State<AppState>) -> impl IntoResponse {
let _ = state.dev_reload_tx.send(());
StatusCode::NO_CONTENT
}
async fn load_annotations(db: Arc<Mutex<Connection>>, file_path: String) -> Vec<serde_json::Value> {
tokio::task::spawn_blocking(move || {
let db = db.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
let mut stmt = match db.prepare("SELECT data FROM annotations WHERE file_path = ?1") {
Ok(s) => s,
Err(e) => {
tracing::error!(file_path = %file_path, "load_annotations: prepare failed: {e}");
return Vec::new();
}
};
let rows = match stmt.query_map([file_path.as_str()], |row| row.get::<_, String>(0)) {
Ok(r) => r,
Err(e) => {
tracing::error!(file_path = %file_path, "load_annotations: query_map failed: {e}");
return Vec::new();
}
};
rows.filter_map(Result::ok)
.filter_map(|s| serde_json::from_str(&s).ok())
.collect()
})
.await
.unwrap_or_else(|e| {
tracing::error!("load_annotations join error: {e}");
Vec::new()
})
}
async fn load_viewed_state(db: Arc<Mutex<Connection>>, file_path: String) -> serde_json::Value {
tokio::task::spawn_blocking(move || {
let db = db.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
let state_json = db
.query_row(
"SELECT state FROM viewed_state WHERE file_path = ?1",
[file_path.as_str()],
|row| row.get::<_, String>(0),
)
.unwrap_or_else(|_| "{}".to_string());
serde_json::from_str(&state_json).unwrap_or_else(|_| serde_json::json!({}))
})
.await
.unwrap_or_else(|e| {
tracing::error!("load_viewed_state join error: {e}");
serde_json::json!({})
})
}
async fn send_json(
sender: &mut futures_util::stream::SplitSink<WebSocket, Message>,
msg: &WebSocketMessage,
) -> Result<(), ()> {
let Ok(encoded) = serde_json::to_string(msg) else {
return Err(());
};
sender
.send(Message::Text(encoded.into()))
.await
.map_err(|_| ())
}
async fn send_initial_document_state(
sender: &mut futures_util::stream::SplitSink<WebSocket, Message>,
db: Arc<Mutex<Connection>>,
file_path: String,
) -> Result<(), ()> {
let annotations = load_annotations(db.clone(), file_path.clone()).await;
tracing::debug!(
file_path = %file_path,
count = annotations.len(),
"sending initial annotations to client",
);
send_json(sender, &WebSocketMessage::AllAnnotations { annotations }).await?;
let viewed = load_viewed_state(db, file_path).await;
send_json(
sender,
&WebSocketMessage::ViewedState {
state: viewed,
op_id: None,
},
)
.await
}
fn broadcast_msg(tx: &broadcast::Sender<WorkspaceEvent>, channel: &str, msg: &WebSocketMessage) {
if let Ok(encoded) = serde_json::to_string(msg) {
let _ = tx.send(WorkspaceEvent::Channel {
channel: channel.to_string(),
payload: encoded,
});
}
}
fn workspace_event_payload(event: WorkspaceEvent, channel: &str) -> Option<String> {
match event {
WorkspaceEvent::Workspace { payload } => Some(payload),
WorkspaceEvent::Channel {
channel: event_channel,
payload,
} if event_channel == channel => Some(payload),
WorkspaceEvent::Channel { .. } => None,
}
}
enum DbResult {
Broadcast(WebSocketMessage),
BroadcastClear {
op_id: Option<String>,
},
None,
}
fn upsert_annotation_for_file(
conn: &Connection,
id: &str,
file_path: &str,
data: &str,
) -> rusqlite::Result<bool> {
conn.execute(
"INSERT INTO annotations (id, file_path, data)
VALUES (?1, ?2, ?3)
ON CONFLICT(id) DO UPDATE SET data = excluded.data
WHERE annotations.file_path = excluded.file_path",
rusqlite::params![id, file_path, data],
)
.map(|changed| changed > 0)
}
async fn handle_client_msg(
db: Option<Arc<Mutex<Connection>>>,
entry: Arc<WorkspaceEntry>,
session: Arc<WsSession>,
msg: WebSocketMessage,
) {
if let WebSocketMessage::LiveAction { data } = msg {
if entry.enable_live.load(std::sync::atomic::Ordering::Relaxed) {
broadcast_msg(
&entry.events_tx,
&session.channel,
&WebSocketMessage::LiveAction { data },
);
}
return;
}
if !entry
.shared_annotation
.load(std::sync::atomic::Ordering::Relaxed)
{
return;
}
let WsSessionTarget::Document { file_path } = &session.target else {
return;
};
let file_path = file_path.clone();
let Some(db) = db else { return };
let result = tokio::task::spawn_blocking(move || {
let conn = db.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
match msg {
WebSocketMessage::NewAnnotation { annotation, op_id } => {
let Some(id) = annotation["id"].as_str().map(str::to_owned) else {
return DbResult::None;
};
let Ok(data) = serde_json::to_string(&annotation) else {
return DbResult::None;
};
match upsert_annotation_for_file(
&conn,
id.as_str(),
file_path.as_str(),
data.as_str(),
) {
Ok(true) => {}
Ok(false) => {
tracing::warn!(
annotation_id = %id,
file_path = %file_path,
"rejected annotation id already owned by another document"
);
return DbResult::None;
}
Err(e) => {
tracing::error!(file_path = %file_path, "insert annotation failed: {e}");
return DbResult::None;
}
}
DbResult::Broadcast(WebSocketMessage::NewAnnotation { annotation, op_id })
}
WebSocketMessage::DeleteAnnotation { id, op_id } => {
if let Err(e) = conn.execute(
"DELETE FROM annotations WHERE id = ?1 AND file_path = ?2",
[id.as_str(), file_path.as_str()],
) {
tracing::error!(file_path = %file_path, "delete annotation failed: {e}");
return DbResult::None;
}
DbResult::Broadcast(WebSocketMessage::DeleteAnnotation { id, op_id })
}
WebSocketMessage::ClearAnnotations { op_id } => {
tracing::info!(file_path = %file_path, "clearing annotations");
if let Err(e) = conn.execute(
"DELETE FROM annotations WHERE file_path = ?1",
[file_path.as_str()],
) {
tracing::error!(file_path = %file_path, "clear annotations failed: {e}");
}
if let Err(e) = conn.execute(
"DELETE FROM viewed_state WHERE file_path = ?1",
[file_path.as_str()],
) {
tracing::error!(file_path = %file_path, "clear viewed_state failed: {e}");
}
DbResult::BroadcastClear { op_id }
}
WebSocketMessage::UpdateViewedState {
state: viewed,
op_id,
} => {
let Ok(state_json) = serde_json::to_string(&viewed) else {
return DbResult::None;
};
if let Err(e) = conn.execute(
"INSERT OR REPLACE INTO viewed_state (file_path, state, updated_at) VALUES (?1, ?2, CURRENT_TIMESTAMP)",
[file_path.as_str(), state_json.as_str()],
) {
tracing::error!(file_path = %file_path, "update viewed_state failed: {e}");
return DbResult::None;
}
DbResult::Broadcast(WebSocketMessage::ViewedState {
state: viewed,
op_id,
})
}
_ => DbResult::None,
}
})
.await;
let result = match result {
Ok(r) => r,
Err(e) => {
tracing::error!("handle_client_msg join error: {e}");
return;
}
};
match result {
DbResult::Broadcast(out) => broadcast_msg(&entry.events_tx, &session.channel, &out),
DbResult::BroadcastClear { op_id } => {
broadcast_msg(
&entry.events_tx,
&session.channel,
&WebSocketMessage::ClearAnnotations {
op_id: op_id.clone(),
},
);
broadcast_msg(
&entry.events_tx,
&session.channel,
&WebSocketMessage::ViewedState {
state: serde_json::Value::Object(serde_json::Map::new()),
op_id,
},
);
}
DbResult::None => {}
}
}
async fn handle_socket(socket: WebSocket, state: AppState, entry: Arc<WorkspaceEntry>) {
let (mut sender, mut receiver) = socket.split();
let db = state.db.clone();
let mut rx = entry.events_tx.subscribe();
let mut config_rx = entry.config_tx.subscribe();
let hello = match tokio::time::timeout(std::time::Duration::from_secs(5), receiver.next()).await
{
Ok(Some(Ok(Message::Text(text)))) => serde_json::from_str::<WsHello>(&text).ok(),
Err(_) => {
tracing::warn!(workspace_id = %entry.id, "timed out waiting for websocket hello");
return;
}
_ => {
tracing::warn!(workspace_id = %entry.id, "missing or invalid websocket hello");
return;
}
};
let Some(session) = hello.and_then(|hello| authorize_ws_target(&entry, hello.target)) else {
tracing::warn!(workspace_id = %entry.id, "rejecting unauthorized websocket target");
return;
};
let session = Arc::new(session);
if entry
.shared_annotation
.load(std::sync::atomic::Ordering::Relaxed)
{
if let WsSessionTarget::Document { file_path } = &session.target {
let Some(db) = db.as_ref() else { return };
tokio::select! {
biased;
_ = config_rx.recv() => return,
result = send_initial_document_state(&mut sender, db.clone(), file_path.clone()) => {
if result.is_err() {
return;
}
}
}
}
}
let send_channel = session.channel.clone();
let mut send_task = tokio::spawn(async move {
loop {
match rx.recv().await {
Ok(event) => {
let Some(payload) = workspace_event_payload(event, &send_channel) else {
continue;
};
if sender.send(Message::Text(payload.into())).await.is_err() {
break;
}
}
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
tracing::warn!(skipped = n, "ws broadcast lagged; continuing");
continue;
}
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
}
}
});
let recv_entry = entry.clone();
let recv_session = session.clone();
let mut recv_task = tokio::spawn(async move {
while let Some(Ok(Message::Text(text))) = receiver.next().await {
if text.len() > MAX_WS_MSG_BYTES {
tracing::warn!("dropping oversized ws message ({} bytes)", text.len());
continue;
}
let Ok(msg) = serde_json::from_str::<WebSocketMessage>(&text) else {
continue;
};
handle_client_msg(db.clone(), recv_entry.clone(), recv_session.clone(), msg).await;
}
});
tokio::select! {
_ = (&mut send_task) => recv_task.abort(),
_ = (&mut recv_task) => send_task.abort(),
_ = config_rx.recv() => {
send_task.abort();
recv_task.abort();
}
};
}
async fn handle_chat_popout(
State(state): State<AppState>,
AxumPath(workspace_id): AxumPath<String>,
) -> impl IntoResponse {
let Some(ws) = state.workspace_registry.get(&workspace_id) else {
return StatusCode::NOT_FOUND.into_response();
};
if !ws.flags().enable_chat {
return StatusCode::NOT_FOUND.into_response();
}
let mut context = base_context(&state);
context.insert("workspace_id", &workspace_id);
context.insert("title", &"Markon Chat".to_string());
render_template(&state, "chat.html", &context)
}
async fn handle_workspace_root(
State(state): State<AppState>,
AxumPath(workspace_id): AxumPath<String>,
role: Option<Extension<AccessRole>>,
) -> impl IntoResponse {
let Some(ws) = state.workspace_registry.get(&workspace_id) else {
return StatusCode::NOT_FOUND.into_response();
};
if let Some(only) = &ws.single_file {
return Redirect::to(&workspace_file_url(&workspace_id, only)).into_response();
}
let root = canonical_workspace_root(&ws);
let can_manage = role.is_some_and(|Extension(role)| role == AccessRole::Admin);
render_directory_listing(&workspace_id, &ws, &root, None, &state, can_manage)
}
async fn handle_workspace_path(
State(state): State<AppState>,
AxumPath((workspace_id, path)): AxumPath<(String, String)>,
role: Option<Extension<AccessRole>>,
headers: axum::http::HeaderMap,
) -> impl IntoResponse {
let Some(ws) = state.workspace_registry.get(&workspace_id) else {
return StatusCode::NOT_FOUND.into_response();
};
let decoded = urlencoding::decode(&path).unwrap_or_else(|_| path.clone().into());
let rel = decoded.trim_start_matches('/');
let canonical = match ws.fs.resolve_served(rel) {
Ok(path) => path,
Err(
crate::workspace_fs::WorkspaceFsError::InvalidPath
| crate::workspace_fs::WorkspaceFsError::Denied,
) if !ws.is_ephemeral() => {
return (StatusCode::FORBIDDEN, "Access denied").into_response();
}
Err(_) => {
return (StatusCode::NOT_FOUND, format!("Path not found: {decoded}")).into_response();
}
};
let root = canonical_workspace_root(&ws);
let can_manage = role.is_some_and(|Extension(role)| role == AccessRole::Admin);
if !is_inside_workspace(&canonical, &root) {
return (StatusCode::FORBIDDEN, "Access denied").into_response();
}
if canonical.is_file() {
if is_markdown_path(&canonical) {
render_markdown_file_async(
canonical.to_string_lossy().into_owned(),
workspace_id.clone(),
ws.clone(),
root.clone(),
state.clone(),
can_manage,
)
.await
} else {
match render_preview_or_none(
canonical.clone(),
workspace_id.clone(),
ws.clone(),
root.clone(),
state.clone(),
)
.await
{
Some(resp) => resp,
None => serve_file(&canonical, &headers).await,
}
}
} else if canonical.is_dir() {
if ws.is_ephemeral() {
return (StatusCode::NOT_FOUND, "Path not found").into_response();
}
match workspace_relative_path(&canonical, &root).map(|rel| path_to_route(&rel)) {
Some(rel_str) if !rel_str.is_empty() => Redirect::to(&format!(
"{}#{}/",
workspace_root_url(&workspace_id),
rel_str
))
.into_response(),
_ => render_directory_listing(&workspace_id, &ws, &root, None, &state, can_manage),
}
} else {
(StatusCode::NOT_FOUND, "Path not found").into_response()
}
}
#[derive(Deserialize)]
struct GitHistoryQuery {
branch: Option<String>,
author: Option<String>,
range: Option<String>,
}
fn git_history_since(range: Option<&str>) -> Option<String> {
match range.map(str::trim).unwrap_or("") {
"day" => Some("1 day ago".to_string()),
"week" => Some("1 week ago".to_string()),
"month" => Some("1 month ago".to_string()),
"year" => Some("1 year ago".to_string()),
_ => None,
}
}
async fn handle_git_history(
State(state): State<AppState>,
AxumPath(workspace_id): AxumPath<String>,
Query(q): Query<GitHistoryQuery>,
) -> impl IntoResponse {
let Some(ws) = state.workspace_registry.get(&workspace_id) else {
return StatusCode::NOT_FOUND.into_response();
};
let branch = q
.branch
.as_deref()
.map(str::trim)
.filter(|b| !b.is_empty())
.map(str::to_string);
let author = q
.author
.as_deref()
.map(str::trim)
.filter(|a| !a.is_empty())
.map(str::to_string);
let range_key = q
.range
.as_deref()
.map(str::trim)
.filter(|r| !r.is_empty() && *r != "all")
.unwrap_or("")
.to_string();
let filter = git::HistoryFilter {
branch: branch.clone(),
author: author.clone(),
since: git_history_since(Some(&range_key)),
};
let root = directory_root_or_not_found!(ws).to_path_buf();
let git_root = root.clone();
let history =
tokio::task::spawn_blocking(move || git::history_filtered(&git_root, 80, &filter))
.await
.unwrap_or_else(|e| {
tracing::error!("git history blocking task join error: {e}");
Err(git::GitError::Command("internal task error".into()))
});
match history {
Ok(commits) => render_git_history_page(
&state,
&workspace_id,
&root,
&commits,
branch.as_deref(),
author.as_deref(),
&range_key,
),
Err(git::GitError::NotRepository) => git_not_repository_response(),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to list git history: {e}"),
)
.into_response(),
}
}
async fn handle_git_history_data(
State(state): State<AppState>,
AxumPath(workspace_id): AxumPath<String>,
) -> impl IntoResponse {
let Some(ws) = state.workspace_registry.get(&workspace_id) else {
return StatusCode::NOT_FOUND.into_response();
};
let root = directory_root_or_not_found!(ws).to_path_buf();
let history = tokio::task::spawn_blocking(move || git::history(&root, 80))
.await
.unwrap_or_else(|e| {
tracing::error!("git history blocking task join error: {e}");
Err(git::GitError::Command("internal task error".into()))
});
match history {
Ok(commits) => Json(commits).into_response(),
Err(git::GitError::NotRepository) => git_not_repository_response(),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to list git history: {e}"),
)
.into_response(),
}
}
async fn handle_git_branches(
State(state): State<AppState>,
AxumPath(workspace_id): AxumPath<String>,
) -> impl IntoResponse {
let Some(ws) = state.workspace_registry.get(&workspace_id) else {
return StatusCode::NOT_FOUND.into_response();
};
let root = directory_root_or_not_found!(ws).to_path_buf();
let branches = tokio::task::spawn_blocking(move || git::branches_detailed(&root))
.await
.unwrap_or_else(|e| {
tracing::error!("git branches blocking task join error: {e}");
Err(git::GitError::Command("internal task error".into()))
});
match branches {
Ok(branches) => render_git_branches_page(&state, &workspace_id, &branches),
Err(git::GitError::NotRepository) => git_not_repository_response(),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to list git branches: {e}"),
)
.into_response(),
}
}
async fn handle_git_tags(
State(state): State<AppState>,
AxumPath(workspace_id): AxumPath<String>,
) -> impl IntoResponse {
let Some(ws) = state.workspace_registry.get(&workspace_id) else {
return StatusCode::NOT_FOUND.into_response();
};
let root = directory_root_or_not_found!(ws).to_path_buf();
let tags = tokio::task::spawn_blocking(move || git::tags(&root, 200))
.await
.unwrap_or_else(|e| {
tracing::error!("git tags blocking task join error: {e}");
Err(git::GitError::Command("internal task error".into()))
});
match tags {
Ok(tags) => render_git_tags_page(&state, &workspace_id, &tags),
Err(git::GitError::NotRepository) => git_not_repository_response(),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to list git tags: {e}"),
)
.into_response(),
}
}
async fn handle_git_working_diff(
State(state): State<AppState>,
AxumPath(workspace_id): AxumPath<String>,
Query(query): Query<GitViewQuery>,
role: Option<Extension<AccessRole>>,
) -> impl IntoResponse {
let Some(ws) = state.workspace_registry.get(&workspace_id) else {
return StatusCode::NOT_FOUND.into_response();
};
let can_manage = role.is_some_and(|Extension(role)| role == AccessRole::Admin);
let initial_view = diff_view_from_query(query.view.as_deref());
directory_root_or_not_found!(ws);
let workspace_fs = ws.fs.clone();
let diff = tokio::task::spawn_blocking(move || git::working_diff(&workspace_fs))
.await
.unwrap_or_else(|e| {
tracing::error!("git working diff blocking task join error: {e}");
Err(git::GitError::Command("internal task error".into()))
});
match diff {
Ok(diff) => {
render_git_diff_page(&state, &workspace_id, &ws, can_manage, &diff, initial_view)
}
Err(git::GitError::NotRepository) => git_not_repository_response(),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to read git diff: {e}"),
)
.into_response(),
}
}
async fn handle_git_working_diff_data(
State(state): State<AppState>,
AxumPath(workspace_id): AxumPath<String>,
Query(query): Query<GitViewQuery>,
) -> impl IntoResponse {
let Some(ws) = state.workspace_registry.get(&workspace_id) else {
return StatusCode::NOT_FOUND.into_response();
};
if diff_view_from_query(query.view.as_deref()) == "rendered" {
directory_root_or_not_found!(ws);
return match markdown_compare_diff_data_async(
&state,
&workspace_id,
ws.fs.clone(),
"HEAD",
"worktree",
query.f.as_deref(),
)
.await
{
Ok(data) => Json(data).into_response(),
Err(git::GitError::NotRepository) => git_not_repository_response(),
Err(git::GitError::InvalidRevision) => {
(StatusCode::BAD_REQUEST, "Invalid git revision").into_response()
}
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to read markdown diff: {e}"),
)
.into_response(),
};
}
directory_root_or_not_found!(ws);
let workspace_fs = ws.fs.clone();
let diff = tokio::task::spawn_blocking(move || git::working_diff(&workspace_fs))
.await
.unwrap_or_else(|e| {
tracing::error!("git working diff blocking task join error: {e}");
Err(git::GitError::Command("internal task error".into()))
});
match diff {
Ok(diff) => git_diff_json_response(&diff, query.f.as_deref()),
Err(git::GitError::NotRepository) => git_not_repository_response(),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to read git diff: {e}"),
)
.into_response(),
}
}
async fn handle_git_commit_diff(
State(state): State<AppState>,
AxumPath((workspace_id, commit)): AxumPath<(String, String)>,
Query(query): Query<GitViewQuery>,
role: Option<Extension<AccessRole>>,
) -> impl IntoResponse {
let Some(ws) = state.workspace_registry.get(&workspace_id) else {
return StatusCode::NOT_FOUND.into_response();
};
let can_manage = role.is_some_and(|Extension(role)| role == AccessRole::Admin);
let initial_view = diff_view_from_query(query.view.as_deref());
let root = directory_root_or_not_found!(ws).to_path_buf();
let diff = tokio::task::spawn_blocking(move || git::commit_diff(&root, &commit))
.await
.unwrap_or_else(|e| {
tracing::error!("git commit diff blocking task join error: {e}");
Err(git::GitError::Command("internal task error".into()))
});
match diff {
Ok(diff) => {
render_git_diff_page(&state, &workspace_id, &ws, can_manage, &diff, initial_view)
}
Err(git::GitError::InvalidRevision) => {
(StatusCode::BAD_REQUEST, "Invalid git revision").into_response()
}
Err(git::GitError::NotRepository) => git_not_repository_response(),
Err(e) => (StatusCode::NOT_FOUND, format!("Git diff not found: {e}")).into_response(),
}
}
async fn handle_git_commit_diff_data(
State(state): State<AppState>,
AxumPath((workspace_id, commit)): AxumPath<(String, String)>,
Query(query): Query<GitViewQuery>,
) -> impl IntoResponse {
let Some(ws) = state.workspace_registry.get(&workspace_id) else {
return StatusCode::NOT_FOUND.into_response();
};
let root = directory_root_or_not_found!(ws).to_path_buf();
let diff = tokio::task::spawn_blocking(move || git::commit_diff(&root, &commit))
.await
.unwrap_or_else(|e| {
tracing::error!("git commit diff blocking task join error: {e}");
Err(git::GitError::Command("internal task error".into()))
});
match diff {
Ok(diff) => git_diff_json_response(&diff, query.f.as_deref()),
Err(git::GitError::InvalidRevision) => {
(StatusCode::BAD_REQUEST, "Invalid git revision").into_response()
}
Err(git::GitError::NotRepository) => git_not_repository_response(),
Err(e) => (StatusCode::NOT_FOUND, format!("Git diff not found: {e}")).into_response(),
}
}
#[derive(Deserialize)]
struct GitViewQuery {
view: Option<String>,
f: Option<String>,
}
#[derive(Deserialize)]
struct PrettyCompareQuery {
view: Option<String>,
format: Option<String>,
f: Option<String>,
}
#[derive(Deserialize)]
struct GitCompareOptionsStatusQuery {
base: String,
compare: String,
}
fn diff_view_from_query(view: Option<&str>) -> &'static str {
match view {
Some("raw") => "raw",
_ => "rendered",
}
}
fn git_not_repository_response() -> Response {
(StatusCode::CONFLICT, "Workspace is not a git repository").into_response()
}
async fn handle_pretty_compare_diff(
State(state): State<AppState>,
AxumPath((workspace_id, range)): AxumPath<(String, String)>,
Query(query): Query<PrettyCompareQuery>,
role: Option<Extension<AccessRole>>,
) -> impl IntoResponse {
let Some(ws) = state.workspace_registry.get(&workspace_id) else {
return StatusCode::NOT_FOUND.into_response();
};
let Some((base, compare)) = parse_pretty_compare_range(&range) else {
return (StatusCode::BAD_REQUEST, "Invalid compare range").into_response();
};
let can_manage = role.is_some_and(|Extension(role)| role == AccessRole::Admin);
let initial_view = diff_view_from_query(query.view.as_deref());
if query.format.as_deref() == Some("data") && initial_view == "rendered" {
directory_root_or_not_found!(ws);
return match markdown_compare_diff_data_async(
&state,
&workspace_id,
ws.fs.clone(),
&base,
&compare,
query.f.as_deref(),
)
.await
{
Ok(data) => Json(data).into_response(),
Err(git::GitError::NotRepository) => git_not_repository_response(),
Err(git::GitError::InvalidRevision) => {
(StatusCode::BAD_REQUEST, "Invalid git revision").into_response()
}
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to read markdown diff: {e}"),
)
.into_response(),
};
}
directory_root_or_not_found!(ws);
match git::compare_diff(&ws.fs, &base, &compare) {
Ok(diff) if query.format.as_deref() == Some("data") => {
git_diff_json_response(&diff, query.f.as_deref())
}
Ok(diff) => {
render_git_diff_page(&state, &workspace_id, &ws, can_manage, &diff, initial_view)
}
Err(git::GitError::InvalidRevision) => {
(StatusCode::BAD_REQUEST, "Invalid git revision").into_response()
}
Err(git::GitError::NotRepository) => git_not_repository_response(),
Err(e) => (StatusCode::NOT_FOUND, format!("Git diff not found: {e}")).into_response(),
}
}
async fn handle_git_compare_options_status(
State(state): State<AppState>,
AxumPath(workspace_id): AxumPath<String>,
Query(query): Query<GitCompareOptionsStatusQuery>,
) -> impl IntoResponse {
let Some(ws) = state.workspace_registry.get(&workspace_id) else {
return StatusCode::NOT_FOUND.into_response();
};
let root = directory_root_or_not_found!(ws);
if !git::status(root).available {
return git_not_repository_response();
}
let (base, compare) = rayon::join(
|| {
git_compare_option_statuses(git_compare_options(
root,
&query.base,
false,
&query.compare,
GitCompareOptionRole::Base,
GitCompareOptionStatusMode::Checked,
))
},
|| {
git_compare_option_statuses(git_compare_options(
root,
&query.compare,
true,
&query.base,
GitCompareOptionRole::Compare,
GitCompareOptionStatusMode::Checked,
))
},
);
Json(GitCompareOptionsStatus { base, compare }).into_response()
}
fn parse_pretty_compare_range(range: &str) -> Option<(String, String)> {
let (base, compare) = range.split_once("...")?;
if base.trim().is_empty() || compare.trim().is_empty() {
return None;
}
Some((base.to_string(), compare.to_string()))
}
#[derive(Deserialize)]
struct GitCommitRequest {
message: String,
}
#[derive(Deserialize)]
struct GitCheckoutRequest {
branch: String,
}
#[derive(Serialize)]
struct GitCommitResponse {
success: bool,
message: String,
commit: Option<git::GitCommitResult>,
}
#[derive(Serialize)]
struct GitCheckoutResponse {
success: bool,
message: String,
status: Option<git::GitStatus>,
}
async fn handle_git_commit(
State(state): State<AppState>,
AxumPath(workspace_id): AxumPath<String>,
Json(payload): Json<GitCommitRequest>,
) -> impl IntoResponse {
let Some(ws) = state.workspace_registry.get(&workspace_id) else {
return StatusCode::NOT_FOUND.into_response();
};
match git::commit_workspace(directory_root_or_not_found!(ws), &payload.message) {
Ok(commit) => Json(GitCommitResponse {
success: true,
message: "Committed workspace changes".to_string(),
commit: Some(commit),
})
.into_response(),
Err(git::GitError::NothingToCommit) => Json(GitCommitResponse {
success: false,
message: "Nothing to commit".to_string(),
commit: None,
})
.into_response(),
Err(git::GitError::NotRepository) => git_not_repository_response(),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to commit workspace changes: {e}"),
)
.into_response(),
}
}
async fn handle_git_checkout(
State(state): State<AppState>,
AxumPath(workspace_id): AxumPath<String>,
Json(payload): Json<GitCheckoutRequest>,
) -> impl IntoResponse {
let Some(ws) = state.workspace_registry.get(&workspace_id) else {
return StatusCode::NOT_FOUND.into_response();
};
match git::checkout_branch(directory_root_or_not_found!(ws), &payload.branch) {
Ok(status) => Json(GitCheckoutResponse {
success: true,
message: "Switched branch".to_string(),
status: Some(status),
})
.into_response(),
Err(git::GitError::InvalidRevision) => Json(GitCheckoutResponse {
success: false,
message: "Invalid branch".to_string(),
status: None,
})
.into_response(),
Err(git::GitError::NotRepository) => git_not_repository_response(),
Err(e) => Json(GitCheckoutResponse {
success: false,
message: format!("Failed to switch branch: {e}"),
status: None,
})
.into_response(),
}
}
#[derive(Serialize)]
struct WorkspaceFileListEntry {
path: String,
name: String,
is_markdown: bool,
url: String,
}
async fn handle_workspace_files_data(
State(state): State<AppState>,
AxumPath(workspace_id): AxumPath<String>,
) -> impl IntoResponse {
let Some(ws) = state.workspace_registry.get(&workspace_id) else {
return StatusCode::NOT_FOUND.into_response();
};
let mut files = Vec::new();
for (rel, path) in ws.fs.served_files(2000) {
let route = rel.as_route();
files.push(WorkspaceFileListEntry {
name: path
.file_name()
.map(|name| name.to_string_lossy().to_string())
.unwrap_or_else(|| route.clone()),
is_markdown: is_markdown_path(&path),
url: workspace_file_url(&workspace_id, &route),
path: route,
});
}
files.sort_by(|a, b| a.path.cmp(&b.path));
Json(files).into_response()
}
#[derive(Deserialize)]
struct CreateFileRequest {
path: String,
content: Option<String>,
}
#[derive(Serialize)]
struct CreateFileResponse {
success: bool,
message: String,
url: Option<String>,
}
async fn handle_workspace_create_file(
State(state): State<AppState>,
AxumPath(workspace_id): AxumPath<String>,
Json(payload): Json<CreateFileRequest>,
) -> impl IntoResponse {
let Some(ws) = state.workspace_registry.get(&workspace_id) else {
return StatusCode::NOT_FOUND.into_response();
};
let Some(root) = ws.fs.directory_root() else {
return StatusCode::NOT_FOUND.into_response();
};
if !ws.enable_edit.load(std::sync::atomic::Ordering::Relaxed) {
return Json(CreateFileResponse {
success: false,
message: "Edit feature is not enabled".to_string(),
url: None,
})
.into_response();
}
let Some(rel) = sanitize_new_file_path(&payload.path) else {
return Json(CreateFileResponse {
success: false,
message: "Invalid file path".to_string(),
url: None,
})
.into_response();
};
let full_path = root.join(&rel);
if fs::symlink_metadata(&full_path).is_ok() {
return Json(CreateFileResponse {
success: false,
message: "File already exists".to_string(),
url: None,
})
.into_response();
}
if !deepest_existing_ancestor_inside_workspace(&full_path, root) {
return Json(CreateFileResponse {
success: false,
message: "Access denied".to_string(),
url: None,
})
.into_response();
}
if let Some(parent) = full_path.parent() {
if let Err(e) = fs::create_dir_all(parent) {
return Json(CreateFileResponse {
success: false,
message: format!("Failed to create directory: {e}"),
url: None,
})
.into_response();
}
}
if let Some(parent) = full_path.parent() {
match canonicalize_route_path(parent) {
Ok(parent) if is_inside_workspace(&parent, root) => {}
_ => {
return Json(CreateFileResponse {
success: false,
message: "Access denied".to_string(),
url: None,
})
.into_response()
}
}
}
let content = payload.content.unwrap_or_default();
let write_result = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&full_path)
.and_then(|mut file| std::io::Write::write_all(&mut file, content.as_bytes()));
if let Err(e) = write_result {
return Json(CreateFileResponse {
success: false,
message: format!("Failed to create file: {e}"),
url: None,
})
.into_response();
}
let route = path_to_route(&rel);
Json(CreateFileResponse {
success: true,
message: "File created".to_string(),
url: Some(workspace_file_url(&workspace_id, &route)),
})
.into_response()
}
async fn handle_workspace_create_folder(
State(state): State<AppState>,
AxumPath(workspace_id): AxumPath<String>,
Json(payload): Json<CreateFileRequest>,
) -> impl IntoResponse {
let Some(ws) = state.workspace_registry.get(&workspace_id) else {
return StatusCode::NOT_FOUND.into_response();
};
let Some(root) = ws.fs.directory_root() else {
return StatusCode::NOT_FOUND.into_response();
};
if !ws.enable_edit.load(std::sync::atomic::Ordering::Relaxed) {
return Json(CreateFileResponse {
success: false,
message: "Edit feature is not enabled".to_string(),
url: None,
})
.into_response();
}
let Some(rel) = sanitize_new_file_path(&payload.path) else {
return Json(CreateFileResponse {
success: false,
message: "Invalid folder path".to_string(),
url: None,
})
.into_response();
};
let full_path = root.join(&rel);
if fs::symlink_metadata(&full_path).is_ok() {
return Json(CreateFileResponse {
success: false,
message: "Folder already exists".to_string(),
url: None,
})
.into_response();
}
if !deepest_existing_ancestor_inside_workspace(&full_path, root) {
return Json(CreateFileResponse {
success: false,
message: "Access denied".to_string(),
url: None,
})
.into_response();
}
if let Err(e) = fs::create_dir_all(&full_path) {
return Json(CreateFileResponse {
success: false,
message: format!("Failed to create folder: {e}"),
url: None,
})
.into_response();
}
match canonicalize_route_path(&full_path) {
Ok(p) if is_inside_workspace(&p, root) => {}
_ => {
return Json(CreateFileResponse {
success: false,
message: "Access denied".to_string(),
url: None,
})
.into_response()
}
}
Json(CreateFileResponse {
success: true,
message: "Folder created".to_string(),
url: None,
})
.into_response()
}
#[derive(Deserialize)]
struct DeleteFileRequest {
path: String,
}
#[derive(Serialize)]
struct DeleteFileResponse {
success: bool,
message: String,
}
async fn handle_workspace_delete_file(
State(state): State<AppState>,
AxumPath(workspace_id): AxumPath<String>,
Json(payload): Json<DeleteFileRequest>,
) -> impl IntoResponse {
let fail = |message: &str| {
Json(DeleteFileResponse {
success: false,
message: message.to_string(),
})
.into_response()
};
let Some(ws) = state.workspace_registry.get(&workspace_id) else {
return StatusCode::NOT_FOUND.into_response();
};
let Some(root) = ws.fs.directory_root() else {
return StatusCode::NOT_FOUND.into_response();
};
if !ws.enable_edit.load(std::sync::atomic::Ordering::Relaxed) {
return fail("Edit feature is not enabled");
}
let rel = payload.path.trim().trim_start_matches('/');
if rel.is_empty() || rel.contains('\0') {
return fail("Invalid file path");
}
let canon = match canonicalize_route_path(&root.join(rel)) {
Ok(p) if is_inside_workspace(&p, root) => p,
_ => return fail("Access denied"),
};
if !canon.is_file() {
return fail("Not a file");
}
match std::fs::remove_file(&canon) {
Ok(_) => Json(DeleteFileResponse {
success: true,
message: "File deleted".to_string(),
})
.into_response(),
Err(e) => fail(&format!("Failed to delete file: {e}")),
}
}
#[derive(Deserialize)]
struct UpdateWorkspaceFeaturesRequest {
#[serde(flatten)]
flags: WorkspaceFlags,
}
#[derive(Serialize)]
struct UpdateWorkspaceFeaturesResponse {
success: bool,
message: String,
}
async fn handle_workspace_update_features(
State(state): State<AppState>,
AxumPath(workspace_id): AxumPath<String>,
Json(payload): Json<UpdateWorkspaceFeaturesRequest>,
) -> Response {
if state
.workspace_registry
.update_flags(&workspace_id, payload.flags)
{
Json(UpdateWorkspaceFeaturesResponse {
success: true,
message: "Workspace features updated".to_string(),
})
.into_response()
} else {
(
StatusCode::NOT_FOUND,
Json(UpdateWorkspaceFeaturesResponse {
success: false,
message: "Workspace not found".to_string(),
}),
)
.into_response()
}
}
#[derive(Deserialize)]
struct UpdateWorkspaceAliasRequest {
#[serde(default)]
alias: String,
}
async fn handle_workspace_update_alias(
State(state): State<AppState>,
AxumPath(workspace_id): AxumPath<String>,
Json(payload): Json<UpdateWorkspaceAliasRequest>,
) -> Response {
if state
.workspace_registry
.set_alias(&workspace_id, payload.alias.trim())
{
Json(serde_json::json!({ "success": true })).into_response()
} else {
(
StatusCode::NOT_FOUND,
Json(serde_json::json!({ "success": false, "message": "Workspace not found" })),
)
.into_response()
}
}
#[derive(Deserialize)]
struct AddWorkspaceRequest {
path: String,
#[serde(flatten)]
flags: WorkspaceFlags,
#[serde(default)]
collaborator_access_code_hash: String,
}
#[derive(Deserialize)]
struct UpdateWorkspaceRequest {
#[serde(flatten)]
flags: WorkspaceFlags,
}
#[derive(Deserialize)]
struct UpdateWorkspaceAccessRequest {
collaborator_access_code_hash: Option<String>,
}
#[derive(Serialize)]
struct AddWorkspaceResponse {
id: String,
}
async fn add_workspace_handler(
State(state): State<AppState>,
Json(req): Json<AddWorkspaceRequest>,
) -> impl IntoResponse {
let path = match expand_and_canonicalize(&req.path) {
Ok(p) => p,
Err(e) => return (StatusCode::BAD_REQUEST, format!("Invalid path: {e}")).into_response(),
};
let id = state.workspace_registry.add(WorkspaceConfig {
path,
flags: req.flags,
single_file: None,
collaborator_access_code_hash: req.collaborator_access_code_hash,
alias: String::new(),
});
Json(AddWorkspaceResponse { id }).into_response()
}
async fn remove_workspace_handler(
State(state): State<AppState>,
AxumPath(id): AxumPath<String>,
) -> impl IntoResponse {
if state.workspace_registry.remove(&id) {
StatusCode::OK
} else {
StatusCode::NOT_FOUND
}
}
async fn update_workspace_handler(
State(state): State<AppState>,
AxumPath(id): AxumPath<String>,
Json(req): Json<UpdateWorkspaceRequest>,
) -> impl IntoResponse {
if state.workspace_registry.update_flags(&id, req.flags) {
StatusCode::OK
} else {
StatusCode::NOT_FOUND
}
}
async fn update_workspace_access_handler(
State(state): State<AppState>,
AxumPath(id): AxumPath<String>,
Json(req): Json<UpdateWorkspaceAccessRequest>,
) -> impl IntoResponse {
if let Some(hash) = req.collaborator_access_code_hash {
if !state
.workspace_registry
.set_collaborator_access_code(&id, &hash)
{
return StatusCode::NOT_FOUND.into_response();
}
}
StatusCode::OK.into_response()
}
async fn list_workspaces_handler(State(state): State<AppState>) -> impl IntoResponse {
Json(state.workspace_registry.info_list())
}
async fn workspace_search_handler(
State(state): State<AppState>,
AxumPath(workspace_id): AxumPath<String>,
axum::extract::Query(query): axum::extract::Query<SearchQuery>,
) -> impl IntoResponse {
workspace_search_results(&state, &workspace_id, &query.q).await
}
async fn workspace_search_results(
state: &AppState,
workspace_id: &str,
query: &str,
) -> Json<Vec<SearchResult>> {
if query.is_empty() {
return Json(Vec::<SearchResult>::new());
}
let Some(ws) = state.workspace_registry.get(workspace_id) else {
return Json(Vec::new());
};
if !ws.enable_search.load(std::sync::atomic::Ordering::Relaxed) {
return Json(Vec::new());
}
let Some(idx) = ws.search_index.load_full() else {
return Json(Vec::new()); };
let query_owned = query.to_string();
let results = tokio::task::spawn_blocking(move || idx.search(&query_owned, 20))
.await
.unwrap_or_else(|e| {
tracing::error!("search blocking task join error: {e}");
Ok(Vec::new())
})
.unwrap_or_else(|e| {
tracing::warn!("search error: {e}");
Vec::new()
});
Json(results)
}
fn base_context(state: &AppState) -> tera::Context {
let mut context = tera::Context::new();
context.insert("theme", state.theme.as_str());
context.insert("i18n_json", state.i18n_json.as_str());
context.insert("i18n_lang", state.i18n_lang.as_str());
context.insert("shortcuts_json", state.shortcuts_json.as_str());
context.insert("styles_css", state.styles_css.as_str());
context.insert("default_chat_mode", state.default_chat_mode.as_str());
context.insert("editor_theme", state.editor_theme.as_str());
context.insert("print_collapsed_content", &state.print_collapsed_content);
context
}
fn render_template(state: &AppState, name: &str, context: &tera::Context) -> Response {
match state.tera.render(name, context) {
Ok(html) => Html(html).into_response(),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Template error: {e}"),
)
.into_response(),
}
}
#[derive(Serialize)]
struct GitDiffTemplate<'a> {
range: &'a str,
title: &'a str,
subtitle: Option<&'a str>,
mode_label: String,
base_label: String,
compare_label: String,
base_value: String,
compare_value: String,
files: Vec<GitDiffFileTemplate<'a>>,
nav_entries: Vec<GitDiffNavEntry<'a>>,
total_additions: usize,
total_deletions: usize,
}
#[derive(Serialize)]
struct GitDiffFileTemplate<'a> {
path: &'a str,
old_path: Option<&'a str>,
status: &'a str,
additions: usize,
deletions: usize,
}
#[derive(Serialize)]
struct GitDiffNavEntry<'a> {
kind: &'static str,
name: String,
path: String,
depth: usize,
status: Option<&'a str>,
additions: usize,
deletions: usize,
}
#[derive(Serialize)]
struct GitCompareOption {
value: String,
label: String,
alias: String,
kind: String,
subject: String,
detail: String,
date: String,
selected: bool,
disabled: bool,
}
#[derive(Serialize)]
struct GitCompareOptionStatus {
value: String,
disabled: bool,
}
#[derive(Serialize)]
struct GitCompareOptionsStatus {
base: Vec<GitCompareOptionStatus>,
compare: Vec<GitCompareOptionStatus>,
}
#[derive(Serialize)]
struct GitDiffData<'a> {
range: &'a str,
title: &'a str,
subtitle: Option<&'a str>,
files: Vec<GitDiffDataFile<'a>>,
rows: Vec<GitDiffDataRow<'a>>,
total_additions: usize,
total_deletions: usize,
}
#[derive(Serialize)]
struct GitDiffDataFile<'a> {
path: &'a str,
old_path: Option<&'a str>,
status: &'a str,
additions: usize,
deletions: usize,
start_row: usize,
row_count: usize,
}
#[derive(Serialize)]
#[serde(tag = "kind")]
enum GitDiffDataRow<'a> {
#[serde(rename = "file")]
File {
file_index: usize,
path: &'a str,
old_path: Option<&'a str>,
status: &'a str,
additions: usize,
deletions: usize,
},
#[serde(rename = "line")]
Line {
file_index: usize,
old_line_no: Option<usize>,
new_line_no: Option<usize>,
old_class_name: &'static str,
new_class_name: &'static str,
old_segments: Vec<GitDiffDataSegment>,
new_segments: Vec<GitDiffDataSegment>,
},
}
#[derive(Serialize)]
struct GitDiffDataSegment {
text: String,
class_name: Option<&'static str>,
}
struct GitDiffDataSide {
line_no: Option<usize>,
class_name: &'static str,
segments: Vec<GitDiffDataSegment>,
}
#[derive(Serialize)]
struct MarkdownDiffData {
title: String,
subtitle: Option<String>,
engine: markdown_ast::MarkdownAstEngineInfo,
files: Vec<MarkdownDiffFile>,
}
#[derive(Clone, Serialize)]
struct MarkdownDiffFile {
path: String,
abs_path: String,
old_path: Option<String>,
status: String,
old: Option<MarkdownDocOutline>,
new: Option<MarkdownDocOutline>,
old_source: Option<String>,
new_source: Option<String>,
additions: usize,
deletions: usize,
blocks: Vec<MarkdownDiffBlock>,
diagnostics: Vec<MarkdownDiffDiagnostic>,
}
#[derive(Clone, Serialize)]
struct MarkdownDocOutline {
block_count: usize,
blocks: Vec<MarkdownBlockOutline>,
}
#[derive(Clone, Serialize)]
struct MarkdownBlockOutline {
index: usize,
kind: String,
label: String,
}
impl MarkdownDocOutline {
fn from_summary(summary: &markdown_ast::MarkdownDocumentSummary) -> Self {
MarkdownDocOutline {
block_count: summary.block_count,
blocks: summary
.blocks
.iter()
.map(|b| MarkdownBlockOutline {
index: b.index,
kind: b.kind.clone(),
label: b.label.clone(),
})
.collect(),
}
}
}
#[derive(Clone, Serialize)]
struct MarkdownDiffBlock {
kind: &'static str,
old: Option<markdown_ast::MarkdownBlockSummary>,
new: Option<markdown_ast::MarkdownBlockSummary>,
}
#[derive(Clone, Serialize)]
struct MarkdownDiffDiagnostic {
side: &'static str,
code: String,
severity: String,
message: String,
start_line: Option<u32>,
end_line: Option<u32>,
}
const MARKDOWN_DIFF_CACHE_VERSION: &str = "markdown-diff-cache-v2";
const MARKDOWN_DIFF_DOCUMENT_CACHE_LIMIT: usize = 256;
const MARKDOWN_DIFF_FILE_CACHE_LIMIT: usize = 512;
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
struct MarkdownDocumentCacheKey {
version: &'static str,
theme: String,
workspace_id: String,
file_path: String,
content_hash: String,
}
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
struct MarkdownDiffFileCacheKey {
version: &'static str,
theme: String,
workspace_id: String,
path: String,
old_path: Option<String>,
status: String,
old_id: Option<String>,
new_id: Option<String>,
}
#[derive(Default)]
pub(crate) struct MarkdownDiffCache {
documents: HashMap<MarkdownDocumentCacheKey, Arc<markdown_ast::MarkdownDocumentSummary>>,
document_lru: VecDeque<MarkdownDocumentCacheKey>,
files: HashMap<MarkdownDiffFileCacheKey, Arc<MarkdownDiffFile>>,
file_lru: VecDeque<MarkdownDiffFileCacheKey>,
document_hits: u64,
document_misses: u64,
file_hits: u64,
file_misses: u64,
}
#[cfg(test)]
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
struct MarkdownDiffCacheStats {
document_entries: usize,
file_entries: usize,
document_hits: u64,
document_misses: u64,
file_hits: u64,
file_misses: u64,
}
impl MarkdownDiffCache {
fn get_document(
&mut self,
key: &MarkdownDocumentCacheKey,
) -> Option<Arc<markdown_ast::MarkdownDocumentSummary>> {
if let Some(summary) = self.documents.get(key).cloned() {
self.document_hits += 1;
touch_lru_key(&mut self.document_lru, key);
Some(summary)
} else {
self.document_misses += 1;
None
}
}
fn insert_document(
&mut self,
key: MarkdownDocumentCacheKey,
summary: markdown_ast::MarkdownDocumentSummary,
) -> Arc<markdown_ast::MarkdownDocumentSummary> {
let summary = Arc::new(summary);
self.documents.insert(key.clone(), summary.clone());
touch_lru_key(&mut self.document_lru, &key);
trim_lru_cache(
&mut self.documents,
&mut self.document_lru,
MARKDOWN_DIFF_DOCUMENT_CACHE_LIMIT,
);
summary
}
fn get_file(&mut self, key: &MarkdownDiffFileCacheKey) -> Option<Arc<MarkdownDiffFile>> {
if let Some(file) = self.files.get(key).cloned() {
self.file_hits += 1;
touch_lru_key(&mut self.file_lru, key);
Some(file)
} else {
self.file_misses += 1;
None
}
}
fn insert_file(
&mut self,
key: MarkdownDiffFileCacheKey,
file: MarkdownDiffFile,
) -> Arc<MarkdownDiffFile> {
let file = Arc::new(file);
self.files.insert(key.clone(), file.clone());
touch_lru_key(&mut self.file_lru, &key);
trim_lru_cache(
&mut self.files,
&mut self.file_lru,
MARKDOWN_DIFF_FILE_CACHE_LIMIT,
);
file
}
#[cfg(test)]
fn stats(&self) -> MarkdownDiffCacheStats {
MarkdownDiffCacheStats {
document_entries: self.documents.len(),
file_entries: self.files.len(),
document_hits: self.document_hits,
document_misses: self.document_misses,
file_hits: self.file_hits,
file_misses: self.file_misses,
}
}
}
fn touch_lru_key<K>(lru: &mut VecDeque<K>, key: &K)
where
K: Clone + Eq,
{
if let Some(index) = lru.iter().position(|existing| existing == key) {
lru.remove(index);
}
lru.push_back(key.clone());
}
fn trim_lru_cache<K, V>(cache: &mut HashMap<K, V>, lru: &mut VecDeque<K>, limit: usize)
where
K: Clone + Eq + std::hash::Hash,
{
while cache.len() > limit {
let Some(key) = lru.pop_front() else {
break;
};
cache.remove(&key);
}
}
#[derive(Serialize)]
struct WorkspaceFeatureStatus {
key: &'static str,
label: &'static str,
label_key: &'static str,
enabled: bool,
}
fn git_diff_template<'a>(
root: &FsPath,
diff: &'a git::GitDiff,
base_value: String,
compare_value: String,
) -> GitDiffTemplate<'a> {
let files: Vec<GitDiffFileTemplate<'_>> = diff
.files
.iter()
.map(|file| GitDiffFileTemplate {
path: &file.path,
old_path: file.old_path.as_deref(),
status: &file.status,
additions: file.additions,
deletions: file.deletions,
})
.collect();
let total_additions = diff.files.iter().map(|file| file.additions).sum();
let total_deletions = diff.files.iter().map(|file| file.deletions).sum();
let (mode_label, base_label, compare_label) =
git_diff_range_labels(root, diff, &base_value, &compare_value);
GitDiffTemplate {
range: &diff.range,
title: &diff.title,
subtitle: diff.subtitle.as_deref(),
mode_label,
base_label,
compare_label,
base_value,
compare_value,
nav_entries: git_diff_nav_entries(&diff.files),
files,
total_additions,
total_deletions,
}
}
fn git_diff_range_labels(
root: &FsPath,
diff: &git::GitDiff,
base_value: &str,
compare_value: &str,
) -> (String, String, String) {
if diff.range == "HEAD..worktree" {
return (
"Working tree".to_string(),
"HEAD".to_string(),
"Worktree".to_string(),
);
}
if let Some((base, compare)) = diff.range.split_once("..") {
return (
"Git range".to_string(),
base.to_string(),
compare.to_string(),
);
}
if valid_hex_display_ref(&diff.range) {
let base = git::parent_commit(root, &diff.range)
.ok()
.flatten()
.map(|parent| short_git_ref(&parent))
.unwrap_or_else(|| "Parent".to_string());
return ("Commit".to_string(), base, short_git_ref(compare_value));
}
(
"Git range".to_string(),
short_git_ref(base_value),
short_git_ref(compare_value),
)
}
fn valid_hex_display_ref(value: &str) -> bool {
(4..=64).contains(&value.len()) && value.bytes().all(|b| b.is_ascii_hexdigit())
}
fn short_git_ref(value: &str) -> String {
if value.len() > 12 && value.bytes().all(|b| b.is_ascii_hexdigit()) {
value[..12].to_string()
} else {
value.to_string()
}
}
fn git_diff_nav_entries(files: &[git::GitDiffFile]) -> Vec<GitDiffNavEntry<'_>> {
let mut sorted: Vec<&git::GitDiffFile> = files.iter().collect();
sorted.sort_by(|a, b| a.path.cmp(&b.path));
let mut emitted_dirs = std::collections::BTreeSet::new();
let mut entries = Vec::new();
for file in sorted {
let segments: Vec<&str> = file
.path
.split('/')
.filter(|part| !part.is_empty())
.collect();
for depth in 0..segments.len().saturating_sub(1) {
let path = segments[..=depth].join("/");
if emitted_dirs.insert(path.clone()) {
entries.push(GitDiffNavEntry {
kind: "dir",
name: segments[depth].to_string(),
path,
depth,
status: None,
additions: 0,
deletions: 0,
});
}
}
entries.push(GitDiffNavEntry {
kind: "file",
name: segments
.last()
.copied()
.unwrap_or(file.path.as_str())
.to_string(),
path: file.path.clone(),
depth: segments.len().saturating_sub(1),
status: Some(file.status.as_str()),
additions: file.additions,
deletions: file.deletions,
});
}
entries
}
fn git_diff_json_response(diff: &git::GitDiff, file_filter: Option<&str>) -> Response {
let diff = markdown_only_git_diff(diff, file_filter);
match serde_json::to_value(git_diff_data(&diff)) {
Ok(value) => Json(value).into_response(),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to serialize git diff: {e}"),
)
.into_response(),
}
}
fn markdown_only_git_diff(diff: &git::GitDiff, file_filter: Option<&str>) -> git::GitDiff {
let files: Vec<git::GitDiffFile> = diff
.files
.iter()
.filter(|file| is_markdown_diff_file(file))
.filter(|file| diff_file_matches_filter(file, file_filter))
.cloned()
.collect();
let patch = files.iter().map(|file| file.patch.as_str()).collect();
git::GitDiff {
range: diff.range.clone(),
title: diff.title.clone(),
subtitle: diff.subtitle.clone(),
patch,
files,
}
}
fn diff_file_matches_filter(file: &git::GitDiffFile, file_filter: Option<&str>) -> bool {
let Some(filter) = file_filter
.map(str::trim)
.filter(|filter| !filter.is_empty())
else {
return true;
};
file.path == filter || file.old_path.as_deref() == Some(filter)
}
fn git_diff_data(diff: &git::GitDiff) -> GitDiffData<'_> {
let mut rows = Vec::new();
let mut files = Vec::new();
for (file_index, file) in diff.files.iter().enumerate() {
let start_row = rows.len();
rows.push(GitDiffDataRow::File {
file_index,
path: &file.path,
old_path: file.old_path.as_deref(),
status: &file.status,
additions: file.additions,
deletions: file.deletions,
});
push_diff_data_rows(file_index, &file.patch, &mut rows);
let row_count = rows.len() - start_row;
files.push(GitDiffDataFile {
path: &file.path,
old_path: file.old_path.as_deref(),
status: &file.status,
additions: file.additions,
deletions: file.deletions,
start_row,
row_count,
});
}
GitDiffData {
range: &diff.range,
title: &diff.title,
subtitle: diff.subtitle.as_deref(),
files,
rows,
total_additions: diff.files.iter().map(|file| file.additions).sum(),
total_deletions: diff.files.iter().map(|file| file.deletions).sum(),
}
}
fn push_diff_data_rows<'a>(
file_index: usize,
unified_diff: &str,
rows: &mut Vec<GitDiffDataRow<'a>>,
) {
let mut old_line_no: Option<usize> = None;
let mut new_line_no: Option<usize> = None;
let mut pending_deletes: Vec<(usize, &str)> = Vec::new();
let mut pending_inserts: Vec<(usize, &str)> = Vec::new();
for raw_line in unified_diff.split_inclusive('\n') {
let line = raw_line.trim_end_matches('\n').trim_end_matches('\r');
if line.starts_with("@@ ") {
flush_diff_data_change_block(
rows,
file_index,
&mut pending_deletes,
&mut pending_inserts,
);
if let Some((old_start, new_start)) = parse_hunk_line_numbers(line) {
old_line_no = Some(old_start);
new_line_no = Some(new_start);
}
push_diff_data_meta_line(rows, file_index, line);
continue;
}
if is_diff_delete_line(line) {
let current = old_line_no.unwrap_or(0);
pending_deletes.push((current, line));
if let Some(line_no) = old_line_no.as_mut() {
*line_no += 1;
}
continue;
}
if is_diff_insert_line(line) {
let current = new_line_no.unwrap_or(0);
pending_inserts.push((current, line));
if let Some(line_no) = new_line_no.as_mut() {
*line_no += 1;
}
continue;
}
flush_diff_data_change_block(rows, file_index, &mut pending_deletes, &mut pending_inserts);
if let Some(body) = line.strip_prefix(' ') {
push_diff_data_split_line(
rows,
file_index,
GitDiffDataSide {
line_no: old_line_no,
class_name: "git-diff-line",
segments: diff_data_plain_segments(body),
},
GitDiffDataSide {
line_no: new_line_no,
class_name: "git-diff-line",
segments: diff_data_plain_segments(body),
},
);
if let Some(line_no) = old_line_no.as_mut() {
*line_no += 1;
}
if let Some(line_no) = new_line_no.as_mut() {
*line_no += 1;
}
} else {
push_diff_data_meta_line(rows, file_index, line);
}
}
flush_diff_data_change_block(rows, file_index, &mut pending_deletes, &mut pending_inserts);
}
fn flush_diff_data_change_block<'a>(
rows: &mut Vec<GitDiffDataRow<'a>>,
file_index: usize,
deletes: &mut Vec<(usize, &str)>,
inserts: &mut Vec<(usize, &str)>,
) {
if deletes.is_empty() && inserts.is_empty() {
return;
}
let pairs = deletes.len().max(inserts.len());
for index in 0..pairs {
let old_line = deletes.get(index).copied();
let new_line = inserts.get(index).copied();
match (old_line, new_line) {
(Some((old_no, old_line)), Some((new_no, new_line))) => {
push_diff_data_word_split_line(rows, file_index, old_no, old_line, new_no, new_line)
}
(Some((old_no, old_line)), None) => push_diff_data_split_line(
rows,
file_index,
GitDiffDataSide {
line_no: Some(old_no),
class_name: "git-diff-line git-diff-del",
segments: diff_data_plain_segments(diff_line_body(old_line)),
},
GitDiffDataSide {
line_no: None,
class_name: "git-diff-line git-diff-empty-side",
segments: vec![GitDiffDataSegment::blank()],
},
),
(None, Some((new_no, new_line))) => push_diff_data_split_line(
rows,
file_index,
GitDiffDataSide {
line_no: None,
class_name: "git-diff-line git-diff-empty-side",
segments: vec![GitDiffDataSegment::blank()],
},
GitDiffDataSide {
line_no: Some(new_no),
class_name: "git-diff-line git-diff-add",
segments: diff_data_plain_segments(diff_line_body(new_line)),
},
),
(None, None) => {}
}
}
deletes.clear();
inserts.clear();
}
fn push_diff_data_word_split_line<'a>(
rows: &mut Vec<GitDiffDataRow<'a>>,
file_index: usize,
old_line_no: usize,
old_line: &str,
new_line_no: usize,
new_line: &str,
) {
let mut old_segments = Vec::new();
let mut new_segments = Vec::new();
let word_diff = TextDiff::from_words(diff_line_body(old_line), diff_line_body(new_line));
for change in word_diff.iter_all_changes() {
match change.tag() {
ChangeTag::Delete => {
old_segments.push(GitDiffDataSegment {
text: change.value().to_string(),
class_name: Some("git-diff-word-del"),
});
}
ChangeTag::Insert => {
new_segments.push(GitDiffDataSegment {
text: change.value().to_string(),
class_name: Some("git-diff-word-add"),
});
}
ChangeTag::Equal => {
old_segments.push(GitDiffDataSegment {
text: change.value().to_string(),
class_name: None,
});
new_segments.push(GitDiffDataSegment {
text: change.value().to_string(),
class_name: None,
});
}
}
}
if old_segments.is_empty() {
old_segments.push(GitDiffDataSegment::blank());
}
if new_segments.is_empty() {
new_segments.push(GitDiffDataSegment::blank());
}
push_diff_data_split_line(
rows,
file_index,
GitDiffDataSide {
line_no: Some(old_line_no),
class_name: "git-diff-line git-diff-del",
segments: old_segments,
},
GitDiffDataSide {
line_no: Some(new_line_no),
class_name: "git-diff-line git-diff-add",
segments: new_segments,
},
);
}
fn push_diff_data_meta_line<'a>(rows: &mut Vec<GitDiffDataRow<'a>>, file_index: usize, line: &str) {
push_diff_data_split_line(
rows,
file_index,
GitDiffDataSide {
line_no: None,
class_name: neutral_diff_line_class(line),
segments: diff_data_plain_segments(line),
},
GitDiffDataSide {
line_no: None,
class_name: neutral_diff_line_class(line),
segments: diff_data_plain_segments(line),
},
);
}
fn push_diff_data_split_line<'a>(
rows: &mut Vec<GitDiffDataRow<'a>>,
file_index: usize,
old: GitDiffDataSide,
new: GitDiffDataSide,
) {
rows.push(GitDiffDataRow::Line {
file_index,
old_line_no: old.line_no,
new_line_no: new.line_no,
old_class_name: old.class_name,
new_class_name: new.class_name,
old_segments: old.segments,
new_segments: new.segments,
});
}
fn diff_line_body(line: &str) -> &str {
line.get(1..).unwrap_or("")
}
fn diff_data_plain_segments(text: &str) -> Vec<GitDiffDataSegment> {
vec![GitDiffDataSegment {
text: if text.is_empty() {
" ".to_string()
} else {
text.to_string()
},
class_name: None,
}]
}
impl GitDiffDataSegment {
fn blank() -> Self {
Self {
text: " ".to_string(),
class_name: None,
}
}
}
fn parse_hunk_line_numbers(line: &str) -> Option<(usize, usize)> {
let mut parts = line.split_whitespace();
if parts.next()? != "@@" {
return None;
}
let old = parse_hunk_start(parts.next()?, '-')?;
let new = parse_hunk_start(parts.next()?, '+')?;
Some((old, new))
}
fn parse_hunk_start(token: &str, prefix: char) -> Option<usize> {
token
.strip_prefix(prefix)?
.split(',')
.next()?
.parse::<usize>()
.ok()
}
#[cfg(test)]
fn render_unified_diff_html(unified_diff: &str) -> String {
let mut out = String::new();
let mut pending_deletes: Vec<&str> = Vec::new();
let mut pending_inserts: Vec<&str> = Vec::new();
for raw_line in unified_diff.split_inclusive('\n') {
let line = raw_line.trim_end_matches('\n').trim_end_matches('\r');
if is_diff_delete_line(line) {
pending_deletes.push(line);
continue;
}
if is_diff_insert_line(line) {
pending_inserts.push(line);
continue;
}
flush_diff_change_block(&mut out, &mut pending_deletes, &mut pending_inserts);
render_diff_line(&mut out, line, neutral_diff_line_class(line));
}
flush_diff_change_block(&mut out, &mut pending_deletes, &mut pending_inserts);
out
}
fn is_diff_delete_line(line: &str) -> bool {
line.starts_with('-') && !line.starts_with("--- ")
}
fn is_diff_insert_line(line: &str) -> bool {
line.starts_with('+') && !line.starts_with("+++ ")
}
#[cfg(test)]
fn flush_diff_change_block<'a>(
out: &mut String,
deletes: &mut Vec<&'a str>,
inserts: &mut Vec<&'a str>,
) {
if deletes.is_empty() && inserts.is_empty() {
return;
}
let pairs = deletes.len().max(inserts.len());
for index in 0..pairs {
let old_line = deletes.get(index).copied();
let new_line = inserts.get(index).copied();
match (old_line, new_line) {
(Some(old_line), Some(new_line)) => {
render_word_diff_line(out, old_line, new_line, ChangeTag::Delete);
render_word_diff_line(out, old_line, new_line, ChangeTag::Insert);
}
(Some(old_line), None) => {
render_diff_line(out, old_line, "git-diff-line git-diff-del");
}
(None, Some(new_line)) => {
render_diff_line(out, new_line, "git-diff-line git-diff-add");
}
(None, None) => {}
}
}
deletes.clear();
inserts.clear();
}
#[cfg(test)]
fn render_word_diff_line(out: &mut String, old_line: &str, new_line: &str, side: ChangeTag) {
let (line, line_class, word_class) = match side {
ChangeTag::Delete => (old_line, "git-diff-line git-diff-del", "git-diff-word-del"),
ChangeTag::Insert => (new_line, "git-diff-line git-diff-add", "git-diff-word-add"),
ChangeTag::Equal => (old_line, "git-diff-line", ""),
};
let body = line.get(1..).unwrap_or("");
out.push_str("<span class=\"");
out.push_str(line_class);
out.push_str("\">");
push_escaped(out, &line[..line.len().min(1)]);
let word_diff = TextDiff::from_words(
old_line.get(1..).unwrap_or(""),
new_line.get(1..).unwrap_or(""),
);
for change in word_diff.iter_all_changes() {
match (side, change.tag()) {
(ChangeTag::Delete, ChangeTag::Delete) | (ChangeTag::Insert, ChangeTag::Insert) => {
out.push_str("<span class=\"");
out.push_str(word_class);
out.push_str("\">");
push_escaped(out, change.value());
out.push_str("</span>");
}
(ChangeTag::Delete, ChangeTag::Equal) | (ChangeTag::Insert, ChangeTag::Equal) => {
push_escaped(out, change.value());
}
_ => {}
}
}
if body.is_empty() {
out.push(' ');
}
out.push_str("</span>\n");
}
fn neutral_diff_line_class(line: &str) -> &'static str {
if line.starts_with("@@ ")
|| line.starts_with("--- ")
|| line.starts_with("+++ ")
|| line.starts_with("Binary files differ:")
|| line.starts_with("rename from ")
|| line.starts_with("rename to ")
|| line.starts_with("\\ No newline")
{
"git-diff-line git-diff-meta"
} else {
"git-diff-line"
}
}
#[cfg(test)]
fn render_diff_line(out: &mut String, line: &str, class_name: &str) {
out.push_str("<span class=\"");
out.push_str(class_name);
out.push_str("\">");
push_escaped(out, line);
if line.is_empty() {
out.push(' ');
}
out.push_str("</span>\n");
}
#[cfg(test)]
fn push_escaped(out: &mut String, text: &str) {
html_escape::encode_text_to_string(text, out);
}
fn git_diff_ref_values(root: &FsPath, diff: &git::GitDiff) -> (String, String) {
if diff.range == "HEAD..worktree" {
return ("HEAD".to_string(), "worktree".to_string());
}
if let Some((base, compare)) = diff.range.split_once("..") {
return (base.to_string(), compare.to_string());
}
let base = git::parent_commit(root, &diff.range)
.ok()
.flatten()
.unwrap_or_else(|| "HEAD".to_string());
(base, diff.range.clone())
}
#[derive(Clone, Copy)]
enum GitCompareOptionRole {
Base,
Compare,
}
#[derive(Clone, Copy)]
enum GitCompareOptionStatusMode {
Fast,
Checked,
}
fn git_compare_option_has_markdown_changes(
root: &FsPath,
value: &str,
other_ref: &str,
role: GitCompareOptionRole,
) -> bool {
let (base, compare) = match role {
GitCompareOptionRole::Base => (value, other_ref),
GitCompareOptionRole::Compare => (other_ref, value),
};
if base == compare {
return false;
}
git::diff_has_markdown_changes_unchecked(root, base, compare).unwrap_or(true)
}
fn git_compare_option_statuses(options: Vec<GitCompareOption>) -> Vec<GitCompareOptionStatus> {
options
.into_iter()
.map(|option| GitCompareOptionStatus {
value: option.value,
disabled: option.disabled,
})
.collect()
}
fn git_compare_options(
root: &FsPath,
selected: &str,
include_worktree: bool,
other_ref: &str,
role: GitCompareOptionRole,
status_mode: GitCompareOptionStatusMode,
) -> Vec<GitCompareOption> {
struct Cand {
value: String,
label: String,
alias: String,
kind: String,
subject: String,
detail: String,
date: String,
check: bool,
}
let mut seen = std::collections::BTreeSet::new();
let mut candidates: Vec<Cand> = Vec::new();
let mut add = |c: Cand| {
if seen.insert(c.value.clone()) {
candidates.push(c);
}
};
add(Cand {
value: "HEAD".to_string(),
label: "HEAD".to_string(),
alias: String::new(),
kind: "head".to_string(),
subject: "Latest commit".to_string(),
detail: String::new(),
date: String::new(),
check: true,
});
for branch in git::branches(root).unwrap_or_default() {
let label = if branch.current {
format!("{} (current)", branch.name)
} else {
branch.name.clone()
};
add(Cand {
value: branch.name,
label,
alias: String::new(),
kind: "branch".to_string(),
subject: String::new(),
detail: if branch.current {
"current".to_string()
} else {
String::new()
},
date: String::new(),
check: false,
});
}
for (idx, commit) in git::history(root, 50)
.unwrap_or_default()
.into_iter()
.enumerate()
{
add(Cand {
value: commit.hash,
label: format!("{} {}", commit.short_hash, commit.subject),
alias: if idx == 0 {
"Latest".to_string()
} else {
String::new()
},
kind: "commit".to_string(),
subject: commit.subject,
detail: commit.short_hash,
date: commit.relative_time,
check: true,
});
}
for tag in git::tags(root, 200).unwrap_or_default() {
add(Cand {
value: tag.name.clone(),
label: format!("{} (tag)", tag.name),
alias: String::new(),
kind: "tag".to_string(),
subject: String::new(),
detail: tag.short_hash,
date: tag.relative_time,
check: false,
});
}
if include_worktree {
add(Cand {
value: "worktree".to_string(),
label: "Worktree".to_string(),
alias: String::new(),
kind: "worktree".to_string(),
subject: "Uncommitted working-tree files".to_string(),
detail: String::new(),
date: String::new(),
check: true,
});
}
let checked = matches!(status_mode, GitCompareOptionStatusMode::Checked);
let mut out: Vec<GitCompareOption> = candidates
.par_iter()
.map(|c| {
let disabled = checked
&& c.check
&& c.value != selected
&& !git_compare_option_has_markdown_changes(root, &c.value, other_ref, role);
GitCompareOption {
selected: c.value == selected,
value: c.value.clone(),
label: c.label.clone(),
alias: c.alias.clone(),
kind: c.kind.clone(),
subject: c.subject.clone(),
detail: c.detail.clone(),
date: c.date.clone(),
disabled,
}
})
.collect();
if !selected.is_empty() && !seen.contains(selected) {
out.insert(
0,
GitCompareOption {
value: selected.to_string(),
label: short_git_ref(selected),
alias: String::new(),
kind: "commit".to_string(),
subject: String::new(),
detail: short_git_ref(selected),
date: String::new(),
selected: true,
disabled: false,
},
);
}
out
}
const GIT_EMPTY_TREE_HASH: &str = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
fn git_commit_compare_base(root: &FsPath, commit: &str) -> String {
git::parent_commit(root, commit)
.ok()
.flatten()
.unwrap_or_else(|| GIT_EMPTY_TREE_HASH.to_string())
}
fn git_commit_markdown_diff_url(
root: &FsPath,
workspace_id: &str,
commit: &git::GitCommit,
view: &str,
) -> Option<String> {
let diff = git::commit_diff(root, &commit.hash).ok()?;
if markdown_only_git_diff(&diff, None).files.is_empty() {
return None;
}
Some(pretty_compare_page_url(
workspace_id,
&git_commit_compare_base(root, &commit.hash),
&commit.hash,
view,
))
}
fn git_source_diff_url(
workspace_id: &str,
root: &FsPath,
diff: &git::GitDiff,
base: &str,
compare: &str,
) -> String {
if diff.range == "HEAD..worktree" {
pretty_compare_page_url(workspace_id, "HEAD", "worktree", "raw")
} else if diff.range.contains("..") {
pretty_compare_page_url(workspace_id, base, compare, "raw")
} else {
pretty_compare_page_url(
workspace_id,
&git_commit_compare_base(root, &diff.range),
&diff.range,
"raw",
)
}
}
fn markdown_work_diff_page_url(workspace_id: &str) -> String {
pretty_compare_page_url(workspace_id, "HEAD", "worktree", "rendered")
}
fn markdown_diff_page_url(
workspace_id: &str,
root: &FsPath,
diff: &git::GitDiff,
base: &str,
compare: &str,
) -> String {
if diff.range == "HEAD..worktree" {
markdown_work_diff_page_url(workspace_id)
} else if diff.range.contains("..") {
pretty_compare_page_url(workspace_id, base, compare, "rendered")
} else {
pretty_compare_page_url(
workspace_id,
&git_commit_compare_base(root, &diff.range),
&diff.range,
"rendered",
)
}
}
fn pretty_compare_page_url(workspace_id: &str, base: &str, compare: &str, view: &str) -> String {
format!(
"{}/{}...{}?view={}",
workspace_compare_base_url(workspace_id),
encode_compare_ref_for_path(base),
encode_compare_ref_for_path(compare),
view
)
}
fn pretty_compare_data_url(workspace_id: &str, base: &str, compare: &str, view: &str) -> String {
format!(
"{}&format=data",
pretty_compare_page_url(workspace_id, base, compare, view)
)
}
fn encode_compare_ref_for_path(value: &str) -> String {
urlencoding::encode(value).replace("%2F", "/")
}
fn markdown_diff_data_url(workspace_id: &str, base: &str, compare: &str) -> String {
pretty_compare_data_url(workspace_id, base, compare, "rendered")
}
fn render_git_diff_page(
state: &AppState,
workspace_id: &str,
ws: &WorkspaceEntry,
can_manage: bool,
diff: &git::GitDiff,
initial_view: &str,
) -> Response {
let flags = ws.flags();
let root = directory_root_or_not_found!(ws);
let (base_value, compare_value) = git_diff_ref_values(root, diff);
let initial_view = if initial_view == "rendered" {
"rendered"
} else {
"raw"
};
let display_diff = markdown_only_git_diff(diff, None);
let default_diff_path = "";
let mut context = base_context(state);
context.insert("title", &format!("Markdiff · {}", diff.range));
context.insert("workspace_id", workspace_id);
context.insert(
"diff",
&git_diff_template(
root,
&display_diff,
base_value.clone(),
compare_value.clone(),
),
);
context.insert("can_manage", &can_manage);
context.insert("shared_annotation", &flags.shared_annotation);
context.insert("enable_live", &flags.enable_live);
context.insert("enable_chat", &flags.enable_chat);
context.insert("history_url", &workspace_git_history_url(workspace_id));
context.insert("files_url", &workspace_root_url(workspace_id));
let ws_display_path = workspace_display_path(root);
context.insert("workspace_display_path", &ws_display_path);
context.insert("workspace_alias", &ws.alias());
context.insert("work_diff_url", &markdown_work_diff_page_url(workspace_id));
context.insert(
"markdown_diff_url",
&markdown_diff_page_url(workspace_id, root, diff, &base_value, &compare_value),
);
context.insert(
"source_diff_url",
&git_source_diff_url(workspace_id, root, diff, &base_value, &compare_value),
);
context.insert(
"compare_url",
&pretty_compare_page_url(workspace_id, &base_value, &compare_value, initial_view),
);
context.insert(
"compare_path_base",
&workspace_compare_base_url(workspace_id),
);
context.insert(
"compare_options_status_url",
&workspace_compare_options_url(workspace_id),
);
context.insert("initial_diff_view", initial_view);
context.insert("default_diff_path", &default_diff_path);
context.insert("is_markdown_diff", &(initial_view == "rendered"));
let base_options = git_compare_options(
root,
&base_value,
false,
&compare_value,
GitCompareOptionRole::Base,
GitCompareOptionStatusMode::Fast,
);
let compare_options = git_compare_options(
root,
&compare_value,
true,
&base_value,
GitCompareOptionRole::Compare,
GitCompareOptionStatusMode::Fast,
);
let picker_json = serde_json::json!({
"base": base_options,
"compare": compare_options,
"baseValue": base_value,
"compareValue": compare_value,
"pathBase": workspace_compare_base_url(workspace_id),
"statusUrl": workspace_compare_options_url(workspace_id),
})
.to_string();
context.insert("compare_picker_json", &picker_json);
let is_worktree_diff = compare_value == "worktree";
context.insert("is_worktree_diff", &is_worktree_diff);
let diff_editable =
is_worktree_diff && ws.enable_edit.load(std::sync::atomic::Ordering::Relaxed);
context.insert("diff_editable", &diff_editable);
context.insert("create_file_url", &workspace_file_create_url(workspace_id));
context.insert(
"create_folder_url",
&workspace_folder_create_url(workspace_id),
);
let markdown_diff_data_url = markdown_diff_data_url(workspace_id, &base_value, &compare_value);
context.insert("markdown_diff_data_url", &markdown_diff_data_url);
render_template(state, "git-diff.html", &context)
}
#[derive(Serialize)]
struct GitHistoryCommitTemplate<'a> {
short_hash: &'a str,
author: &'a str,
date: &'a str,
subject: &'a str,
diff_url: Option<String>,
}
#[derive(Serialize)]
struct GitHistoryDay<'a> {
day_label: String,
commits: Vec<GitHistoryCommitTemplate<'a>>,
}
fn git_history_day_label(date: &str) -> String {
if date.len() < 10 {
return date.to_string();
}
let year = &date[0..4];
let month = match &date[5..7] {
"01" => "Jan",
"02" => "Feb",
"03" => "Mar",
"04" => "Apr",
"05" => "May",
"06" => "Jun",
"07" => "Jul",
"08" => "Aug",
"09" => "Sep",
"10" => "Oct",
"11" => "Nov",
"12" => "Dec",
other => other,
};
let day = date[8..10].trim_start_matches('0');
let day = if day.is_empty() { "0" } else { day };
format!("{month} {day}, {year}")
}
#[derive(Serialize)]
struct GitBranchOption {
name: String,
current: bool,
}
#[derive(Serialize)]
struct GitRangeOption {
key: &'static str,
label: &'static str,
current: bool,
}
#[derive(Serialize)]
struct GitAuthorOption {
name: String,
current: bool,
}
fn render_git_history_page(
state: &AppState,
workspace_id: &str,
root: &FsPath,
commits: &[git::GitCommit],
selected_branch: Option<&str>,
selected_author: Option<&str>,
range_key: &str,
) -> Response {
let commit_hashes: Vec<&str> = commits.iter().map(|c| c.hash.as_str()).collect();
let diff_index = git::commit_diff_index(root, &commit_hashes).unwrap_or_default();
let mut groups: Vec<GitHistoryDay<'_>> = Vec::new();
let mut last_key: Option<&str> = None;
for commit in commits {
let key = commit.date.get(0..10).unwrap_or(commit.date.as_str());
let diff_url = diff_index
.get(&commit.hash)
.filter(|info| info.has_markdown)
.map(|info| {
pretty_compare_page_url(
workspace_id,
info.parent.as_deref().unwrap_or(GIT_EMPTY_TREE_HASH),
&commit.hash,
"rendered",
)
});
let item = GitHistoryCommitTemplate {
short_hash: &commit.short_hash,
author: &commit.author,
date: &commit.date,
subject: &commit.subject,
diff_url,
};
if last_key == Some(key) {
groups
.last_mut()
.expect("last_key set implies a prior group")
.commits
.push(item);
} else {
groups.push(GitHistoryDay {
day_label: git_history_day_label(&commit.date),
commits: vec![item],
});
last_key = Some(key);
}
}
let all_branches = git::branches(root).unwrap_or_default();
let checked_out = all_branches
.iter()
.find(|b| b.current)
.map(|b| b.name.clone())
.unwrap_or_else(|| "main".to_string());
let active_branch = selected_branch
.filter(|name| all_branches.iter().any(|b| b.name == *name))
.map(str::to_string);
let current_branch_label = active_branch.clone().unwrap_or_else(|| checked_out.clone());
let branch_options: Vec<GitBranchOption> = all_branches
.iter()
.map(|b| GitBranchOption {
name: b.name.clone(),
current: b.name == current_branch_label,
})
.collect();
let author_list = git::authors(root).unwrap_or_default();
let author_options: Vec<GitAuthorOption> = author_list
.iter()
.map(|name| GitAuthorOption {
name: name.clone(),
current: selected_author == Some(name.as_str()),
})
.collect();
let current_author_label = selected_author
.map(str::to_string)
.unwrap_or_else(|| "All users".to_string());
const RANGE_PRESETS: [(&str, &str); 5] = [
("", "All time"),
("day", "Last 24 hours"),
("week", "Last 7 days"),
("month", "Last 30 days"),
("year", "Last 12 months"),
];
let range_options: Vec<GitRangeOption> = RANGE_PRESETS
.iter()
.map(|(key, label)| GitRangeOption {
key,
label,
current: *key == range_key,
})
.collect();
let current_range_label = RANGE_PRESETS
.iter()
.find(|(key, _)| *key == range_key)
.map(|(_, label)| *label)
.unwrap_or("All time");
let work_diff_url = git::diff_has_markdown_changes(root, "HEAD", "worktree")
.unwrap_or(false)
.then(|| markdown_work_diff_page_url(workspace_id));
let mut context = base_context(state);
context.insert("title", "markon git history");
context.insert("workspace_id", workspace_id);
context.insert("groups", &groups);
context.insert("commit_count", &commits.len());
context.insert("current_branch", ¤t_branch_label);
context.insert("current_branch_label", ¤t_branch_label);
context.insert("branches", &branch_options);
context.insert("authors", &author_options);
context.insert("current_author", &selected_author);
context.insert("current_author_label", ¤t_author_label);
context.insert("ranges", &range_options);
context.insert("current_range", &range_key);
context.insert("current_range_label", ¤t_range_label);
context.insert("files_url", &workspace_root_url(workspace_id));
context.insert("has_commits", &!groups.is_empty());
let filters_active = selected_author.is_some() || (!range_key.is_empty() && range_key != "all");
context.insert("filters_active", &filters_active);
context.insert("work_diff_url", &work_diff_url);
render_template(state, "git-history.html", &context)
}
fn render_git_branches_page(
state: &AppState,
workspace_id: &str,
branches: &[git::GitBranchDetail],
) -> Response {
let mut context = base_context(state);
context.insert("title", "markon git branches");
context.insert("workspace_id", workspace_id);
context.insert("files_url", &workspace_root_url(workspace_id));
context.insert("history_url", &workspace_git_history_url(workspace_id));
context.insert("page_title", "Branches");
context.insert("page_title_key", "web.ws.git.branches");
context.insert("empty_key", "web.ws.git.no_branches");
context.insert("mode", "branches");
#[derive(Serialize)]
struct BranchRow {
name: String,
updated: String,
is_default: bool,
has_counts: bool,
behind: usize,
ahead: usize,
}
let to_row = |b: &git::GitBranchDetail| BranchRow {
name: b.name.clone(),
updated: b.updated.clone(),
is_default: b.is_default,
has_counts: b.behind.is_some() && b.ahead.is_some(),
behind: b.behind.unwrap_or(0),
ahead: b.ahead.unwrap_or(0),
};
let default_branch = branches.iter().find(|b| b.is_default).map(to_row);
let mut other_branches: Vec<BranchRow> = branches
.iter()
.filter(|b| !b.is_default)
.map(to_row)
.collect();
other_branches.sort_by(|a, b| a.name.cmp(&b.name));
let default_branch_name = default_branch
.as_ref()
.map(|b| b.name.clone())
.unwrap_or_default();
context.insert("default_branch", &default_branch);
context.insert("other_branches", &other_branches);
context.insert("default_branch_name", &default_branch_name);
context.insert("branch_total", &branches.len());
context.insert("has_items", &!branches.is_empty());
render_template(state, "git-refs.html", &context)
}
fn render_git_tags_page(state: &AppState, workspace_id: &str, tags: &[git::GitTag]) -> Response {
let mut context = base_context(state);
context.insert("title", "markon git tags");
context.insert("workspace_id", workspace_id);
context.insert("files_url", &workspace_root_url(workspace_id));
context.insert("history_url", &workspace_git_history_url(workspace_id));
context.insert("page_title", "Tags");
context.insert("page_title_key", "web.ws.git.tags");
context.insert("empty_key", "web.ws.git.no_tags");
context.insert("mode", "tags");
context.insert("branches", &Vec::<git::GitBranch>::new());
context.insert("tags", tags);
context.insert("has_items", &!tags.is_empty());
render_template(state, "git-refs.html", &context)
}
struct MarkdownBuildItem<'a> {
entry: &'a git::MarkdownDiffEntry,
old_id: Option<String>,
new_id: Option<String>,
new_worktree: Option<String>,
read_diagnostics: Vec<MarkdownDiffDiagnostic>,
file_key: Option<MarkdownDiffFileCacheKey>,
}
fn markdown_compare_diff_data(
state: &AppState,
workspace_id: &str,
workspace_fs: &WorkspaceFs,
base: &str,
compare: &str,
file_filter: Option<&str>,
) -> git::Result<MarkdownDiffData> {
let root = workspace_fs
.directory_root()
.ok_or_else(|| git::GitError::Io("directory workspace required".to_string()))?;
let engine = markdown_ast::engine_info();
let listing = git::markdown_diff_listing(workspace_fs, base, compare)?;
if !engine.enabled {
return Ok(MarkdownDiffData {
title: "Markdown visual diff".to_string(),
subtitle: engine.message.map(str::to_string),
engine,
files: Vec::new(),
});
}
let filter = file_filter.map(str::trim).filter(|f| !f.is_empty());
let entries: Vec<&git::MarkdownDiffEntry> = listing
.entries
.iter()
.filter(|e| filter.is_none_or(|f| e.path == f || e.old_path.as_deref() == Some(f)))
.collect();
enum Slot<'a> {
Cached(Arc<MarkdownDiffFile>),
Build(Box<MarkdownBuildItem<'a>>),
}
let mut slots: Vec<Slot> = Vec::with_capacity(entries.len());
let mut needed_blobs: Vec<String> = Vec::new();
for entry in &entries {
let mut read_diagnostics = Vec::new();
let old_id = if entry.status == "added" {
None
} else {
entry.old_blob.clone()
};
let mut new_worktree = None;
let new_id = if entry.status == "deleted" {
None
} else if let Some(blob) = &entry.new_blob {
Some(blob.clone())
} else {
match workspace_fs.read_content_to_string(&entry.path) {
Ok(content) => {
let id = format!("h:{}", markdown_content_hash(&content));
new_worktree = Some(content);
Some(id)
}
Err(e) => {
read_diagnostics.push(markdown_diff_diagnostic(
"new",
"read_worktree_failed",
"error",
format!("Failed to read {}: {e}", entry.path),
));
None
}
}
};
let file_key = read_diagnostics
.is_empty()
.then(|| MarkdownDiffFileCacheKey {
version: MARKDOWN_DIFF_CACHE_VERSION,
theme: state.theme.as_str().to_string(),
workspace_id: workspace_id.to_string(),
path: entry.path.clone(),
old_path: entry.old_path.clone(),
status: entry.status.clone(),
old_id: old_id.clone(),
new_id: new_id.clone(),
});
if let Some(key) = &file_key {
if let Some(cached) = state
.markdown_diff_cache
.lock()
.expect("markdown diff cache poisoned")
.get_file(key)
{
slots.push(Slot::Cached(cached));
continue;
}
}
if let Some(oid) = &old_id {
needed_blobs.push(oid.clone());
}
if entry.new_blob.is_some() {
if let Some(oid) = &new_id {
needed_blobs.push(oid.clone());
}
}
slots.push(Slot::Build(Box::new(MarkdownBuildItem {
entry,
old_id,
new_id,
new_worktree,
read_diagnostics,
file_key,
})));
}
needed_blobs.sort();
needed_blobs.dedup();
let blobs = git::read_blobs(root, &needed_blobs)?;
let builds: Vec<(usize, &MarkdownBuildItem)> = slots
.iter()
.enumerate()
.filter_map(|(i, slot)| match slot {
Slot::Build(item) => Some((i, item.as_ref())),
Slot::Cached(_) => None,
})
.collect();
let mut built: HashMap<usize, MarkdownDiffFile> = builds
.into_par_iter()
.map(|(i, item)| {
(
i,
build_markdown_diff_file(state, workspace_id, item, &blobs, workspace_fs),
)
})
.collect();
let mut files = Vec::with_capacity(slots.len());
for (i, slot) in slots.iter().enumerate() {
match slot {
Slot::Cached(arc) => files.push((**arc).clone()),
Slot::Build(item) => {
let file = built.remove(&i).expect("built file present");
if let Some(key) = item.file_key.clone() {
let cached = state
.markdown_diff_cache
.lock()
.expect("markdown diff cache poisoned")
.insert_file(key, file);
files.push((*cached).clone());
} else {
files.push(file);
}
}
}
}
Ok(MarkdownDiffData {
title: listing.title,
subtitle: Some(format!(
"{} Markdown files changed · {}",
files.len(),
listing.range
)),
engine,
files,
})
}
async fn markdown_compare_diff_data_async(
state: &AppState,
workspace_id: &str,
workspace_fs: Arc<WorkspaceFs>,
base: &str,
compare: &str,
file_filter: Option<&str>,
) -> git::Result<MarkdownDiffData> {
let state = state.clone();
let workspace_id = workspace_id.to_string();
let base = base.to_string();
let compare = compare.to_string();
let file_filter = file_filter.map(str::to_string);
tokio::task::spawn_blocking(move || {
markdown_compare_diff_data(
&state,
&workspace_id,
&workspace_fs,
&base,
&compare,
file_filter.as_deref(),
)
})
.await
.unwrap_or_else(|e| {
tracing::error!("markdown_compare_diff_data join error: {e}");
Err(git::GitError::Io("diff render task failed".to_string()))
})
}
fn build_markdown_diff_file(
state: &AppState,
workspace_id: &str,
item: &MarkdownBuildItem,
blobs: &HashMap<String, Vec<u8>>,
workspace_fs: &WorkspaceFs,
) -> MarkdownDiffFile {
let entry = item.entry;
let root = workspace_fs
.directory_root()
.expect("markdown diff requires a directory workspace");
let abs_path = {
let joined = root.join(&entry.path);
workspace_fs
.resolve_content(&entry.path)
.map(|path| path.to_string_lossy().into_owned())
.unwrap_or_else(|_| joined.to_string_lossy().into_owned())
};
let mut diagnostics = item.read_diagnostics.clone();
let blob_text =
|id: &str, side: &'static str, diags: &mut Vec<MarkdownDiffDiagnostic>| match blobs.get(id)
{
Some(bytes) => Some(String::from_utf8_lossy(bytes).into_owned()),
None => {
diags.push(markdown_diff_diagnostic(
side,
"read_blob_failed",
"error",
format!("Missing blob {id}"),
));
None
}
};
let old_content = item
.old_id
.as_deref()
.and_then(|id| blob_text(id, "old", &mut diagnostics));
let new_content = if entry.status == "deleted" {
None
} else if let Some(content) = &item.new_worktree {
Some(content.clone())
} else {
item.new_id
.as_deref()
.and_then(|id| blob_text(id, "new", &mut diagnostics))
};
let old_path = entry.old_path.as_deref().unwrap_or(&entry.path);
let old_file_path = root.join(old_path);
let new_file_path = root.join(&entry.path);
let old_renderer = default_markdown_engine(state.theme.as_str()).with_asset_context(
workspace_id,
&old_file_path,
root,
);
let new_renderer = default_markdown_engine(state.theme.as_str()).with_asset_context(
workspace_id,
&new_file_path,
root,
);
let old = summarize_side_cached(
state,
"old",
old_content.as_deref(),
item.old_id.as_deref(),
workspace_id,
&old_file_path,
&old_renderer,
);
diagnostics.extend(markdown_side_diagnostics("old", old.as_ref()));
let new = summarize_side_cached(
state,
"new",
new_content.as_deref(),
item.new_id.as_deref(),
workspace_id,
&new_file_path,
&new_renderer,
);
diagnostics.extend(markdown_side_diagnostics("new", new.as_ref()));
let blocks = diff_markdown_blocks(
old.as_ref().map(|s| s.blocks.as_slice()),
new.as_ref().map(|s| s.blocks.as_slice()),
);
MarkdownDiffFile {
path: entry.path.clone(),
abs_path,
old_path: entry.old_path.clone(),
status: entry.status.clone(),
old: old.as_ref().map(MarkdownDocOutline::from_summary),
new: new.as_ref().map(MarkdownDocOutline::from_summary),
old_source: old_content,
new_source: new_content,
additions: entry.additions,
deletions: entry.deletions,
blocks,
diagnostics,
}
}
fn summarize_side_cached(
state: &AppState,
side: &'static str,
content: Option<&str>,
content_id: Option<&str>,
workspace_id: &str,
file_path: &FsPath,
renderer: &MarkdownRenderer,
) -> Option<markdown_ast::MarkdownDocumentSummary> {
let content = content?;
let id_owned;
let id = match content_id {
Some(id) => id,
None => {
id_owned = format!("h:{}", markdown_content_hash(content));
id_owned.as_str()
}
};
let key = markdown_document_cache_key(state, id, workspace_id, file_path);
if let Some(summary) = state
.markdown_diff_cache
.lock()
.expect("markdown diff cache poisoned")
.get_document(&key)
{
return Some((*summary).clone());
}
let mut render_block = |fragment: &str| renderer.render_html(fragment).html;
let summary = match markdown_ast::summarize_document(content, &mut render_block) {
Ok(summary) => summary,
Err(e) => markdown_summary_error(side, e.message),
};
let cached = state
.markdown_diff_cache
.lock()
.expect("markdown diff cache poisoned")
.insert_document(key, summary);
Some((*cached).clone())
}
fn is_markdown_diff_file(file: &git::GitDiffFile) -> bool {
is_markdown_route_path(&file.path)
|| file.old_path.as_deref().is_some_and(is_markdown_route_path)
}
fn is_markdown_route_path(path: &str) -> bool {
FsPath::new(path)
.extension()
.is_some_and(|e| e.to_string_lossy().eq_ignore_ascii_case("md"))
}
fn markdown_content_hash(content: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(content.as_bytes());
format!("{:x}", hasher.finalize())
}
fn markdown_document_cache_key(
state: &AppState,
content_hash: &str,
workspace_id: &str,
file_path: &FsPath,
) -> MarkdownDocumentCacheKey {
MarkdownDocumentCacheKey {
version: MARKDOWN_DIFF_CACHE_VERSION,
theme: state.theme.as_str().to_string(),
workspace_id: workspace_id.to_string(),
file_path: file_path.to_string_lossy().into_owned(),
content_hash: content_hash.to_string(),
}
}
fn markdown_summary_error(
side: &'static str,
message: String,
) -> markdown_ast::MarkdownDocumentSummary {
markdown_ast::MarkdownDocumentSummary {
block_count: 0,
diagnostics: vec![markdown_ast::MarkdownAstDiagnostic {
code: format!("{side}_parse_failed"),
severity: "error".to_string(),
message,
start_line: None,
end_line: None,
}],
blocks: Vec::new(),
}
}
fn markdown_side_diagnostics(
side: &'static str,
summary: Option<&markdown_ast::MarkdownDocumentSummary>,
) -> Vec<MarkdownDiffDiagnostic> {
summary
.into_iter()
.flat_map(|summary| {
summary
.diagnostics
.iter()
.map(move |diagnostic| MarkdownDiffDiagnostic {
side,
code: diagnostic.code.clone(),
severity: diagnostic.severity.clone(),
message: diagnostic.message.clone(),
start_line: diagnostic.start_line,
end_line: diagnostic.end_line,
})
})
.collect()
}
fn markdown_diff_diagnostic(
side: &'static str,
code: &'static str,
severity: &'static str,
message: String,
) -> MarkdownDiffDiagnostic {
MarkdownDiffDiagnostic {
side,
code: code.to_string(),
severity: severity.to_string(),
message,
start_line: None,
end_line: None,
}
}
#[derive(Debug, Clone)]
enum MarkdownBlockOp {
Equal(
markdown_ast::MarkdownBlockSummary,
markdown_ast::MarkdownBlockSummary,
),
Delete(markdown_ast::MarkdownBlockSummary),
Add(markdown_ast::MarkdownBlockSummary),
}
fn diff_markdown_blocks(
old: Option<&[markdown_ast::MarkdownBlockSummary]>,
new: Option<&[markdown_ast::MarkdownBlockSummary]>,
) -> Vec<MarkdownDiffBlock> {
let old = old.unwrap_or(&[]);
let new = new.unwrap_or(&[]);
let ops = if old.len().saturating_mul(new.len()) <= 40_000 {
diff_markdown_blocks_lcs(old, new)
} else {
diff_markdown_blocks_by_index(old, new)
};
coalesce_markdown_block_ops(ops)
}
fn diff_markdown_blocks_lcs(
old: &[markdown_ast::MarkdownBlockSummary],
new: &[markdown_ast::MarkdownBlockSummary],
) -> Vec<MarkdownBlockOp> {
let n = old.len();
let m = new.len();
let mut dp = vec![vec![0usize; m + 1]; n + 1];
for i in (0..n).rev() {
for j in (0..m).rev() {
dp[i][j] = if old[i].digest == new[j].digest {
dp[i + 1][j + 1] + 1
} else {
dp[i + 1][j].max(dp[i][j + 1])
};
}
}
let mut ops = Vec::new();
let (mut i, mut j) = (0, 0);
while i < n && j < m {
if old[i].digest == new[j].digest {
ops.push(MarkdownBlockOp::Equal(old[i].clone(), new[j].clone()));
i += 1;
j += 1;
} else if dp[i + 1][j] >= dp[i][j + 1] {
ops.push(MarkdownBlockOp::Delete(old[i].clone()));
i += 1;
} else {
ops.push(MarkdownBlockOp::Add(new[j].clone()));
j += 1;
}
}
while i < n {
ops.push(MarkdownBlockOp::Delete(old[i].clone()));
i += 1;
}
while j < m {
ops.push(MarkdownBlockOp::Add(new[j].clone()));
j += 1;
}
ops
}
fn diff_markdown_blocks_by_index(
old: &[markdown_ast::MarkdownBlockSummary],
new: &[markdown_ast::MarkdownBlockSummary],
) -> Vec<MarkdownBlockOp> {
let mut ops = Vec::new();
let max_len = old.len().max(new.len());
for index in 0..max_len {
match (old.get(index), new.get(index)) {
(Some(old), Some(new)) if old.digest == new.digest => {
ops.push(MarkdownBlockOp::Equal(old.clone(), new.clone()));
}
(Some(old), Some(new)) => {
ops.push(MarkdownBlockOp::Delete(old.clone()));
ops.push(MarkdownBlockOp::Add(new.clone()));
}
(Some(old), None) => ops.push(MarkdownBlockOp::Delete(old.clone())),
(None, Some(new)) => ops.push(MarkdownBlockOp::Add(new.clone())),
(None, None) => {}
}
}
ops
}
fn coalesce_markdown_block_ops(ops: Vec<MarkdownBlockOp>) -> Vec<MarkdownDiffBlock> {
let mut out = Vec::new();
let mut index = 0;
while index < ops.len() {
match &ops[index] {
MarkdownBlockOp::Equal(old, new) => {
out.push(MarkdownDiffBlock {
kind: "equal",
old: Some(old.clone()),
new: Some(new.clone()),
});
index += 1;
}
MarkdownBlockOp::Delete(_) => {
let start = index;
while index < ops.len() && matches!(ops[index], MarkdownBlockOp::Delete(_)) {
index += 1;
}
let delete_end = index;
while index < ops.len() && matches!(ops[index], MarkdownBlockOp::Add(_)) {
index += 1;
}
let adds = ops[delete_end..index]
.iter()
.filter_map(|op| match op {
MarkdownBlockOp::Add(block) => Some(block.clone()),
_ => None,
})
.collect::<Vec<_>>();
let deletes = ops[start..delete_end]
.iter()
.filter_map(|op| match op {
MarkdownBlockOp::Delete(block) => Some(block.clone()),
_ => None,
})
.collect::<Vec<_>>();
let pair_count = deletes.len().min(adds.len());
for pair_index in 0..pair_count {
out.push(MarkdownDiffBlock {
kind: "modified",
old: Some(deletes[pair_index].clone()),
new: Some(adds[pair_index].clone()),
});
}
for block in deletes.into_iter().skip(pair_count) {
out.push(MarkdownDiffBlock {
kind: "deleted",
old: Some(block),
new: None,
});
}
for block in adds.into_iter().skip(pair_count) {
out.push(MarkdownDiffBlock {
kind: "added",
old: None,
new: Some(block),
});
}
}
MarkdownBlockOp::Add(new) => {
out.push(MarkdownDiffBlock {
kind: "added",
old: None,
new: Some(new.clone()),
});
index += 1;
}
}
}
out
}
const MAX_TEXT_PREVIEW_BYTES: u64 = 2 * 1024 * 1024;
fn read_text_for_preview(path: &FsPath) -> Option<(String, String)> {
let mime = mime_guess::from_path(path).first_or_octet_stream();
if matches!(mime.type_().as_str(), "image" | "video" | "audio")
|| mime.essence_str() == "application/pdf"
{
return None;
}
if fs::metadata(path).map(|m| m.len()).unwrap_or(0) > MAX_TEXT_PREVIEW_BYTES {
return None;
}
let bytes = fs::read(path).ok()?;
if bytes.contains(&0) {
return None;
}
let content = String::from_utf8(bytes).ok()?;
let token = path
.extension()
.and_then(|e| e.to_str())
.filter(|s| !s.is_empty())
.or_else(|| path.file_name().and_then(|n| n.to_str()))
.unwrap_or_default()
.to_string();
Some((content, token))
}
fn render_file_view(
path: &FsPath,
content: String,
token: String,
workspace_id: &str,
ws: &WorkspaceEntry,
root: &FsPath,
state: &AppState,
) -> Response {
let file_name = path
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_default();
let back_link = workspace_file_back_link(workspace_id, path, root);
let rel_display = workspace_relative_path(path, root)
.map(|rel| rel.to_string_lossy().replace('\\', "/"))
.unwrap_or_else(|| file_name.clone());
let normalized = content.strip_suffix('\n').unwrap_or(content.as_str());
let code_html = crate::markdown::highlight_source_file(&token, normalized);
let line_count = normalized.split('\n').count().max(1);
let gutter = (1..=line_count)
.map(|n| n.to_string())
.collect::<Vec<_>>()
.join("\n");
let mut context = base_context(state);
context.insert("title", &format!("markon - {file_name}"));
context.insert("workspace_id", workspace_id);
insert_workspace_header_context(&mut context, ws, root);
context.insert("version", env!("CARGO_PKG_VERSION"));
context.insert("file_name", &file_name);
context.insert("rel_display", &rel_display);
context.insert("back_link", &back_link);
context.insert("show_back_link", &!ws.is_ephemeral());
context.insert("code_html", &code_html);
context.insert("gutter", &gutter);
context.insert("line_count", &line_count);
render_template(state, "file-view.html", &context)
}
async fn render_markdown_file_async(
file_path: String,
workspace_id: String,
ws: Arc<WorkspaceEntry>,
root: PathBuf,
state: AppState,
is_local: bool,
) -> Response {
tokio::task::spawn_blocking(move || {
render_markdown_file(&file_path, &workspace_id, &ws, &root, &state, is_local)
})
.await
.unwrap_or_else(|e| {
tracing::error!("render_markdown_file join error: {e}");
(StatusCode::INTERNAL_SERVER_ERROR, "render task failed").into_response()
})
}
async fn render_preview_or_none(
canonical: PathBuf,
workspace_id: String,
ws: Arc<WorkspaceEntry>,
root: PathBuf,
state: AppState,
) -> Option<Response> {
tokio::task::spawn_blocking(move || {
let (content, token) = read_text_for_preview(&canonical)?;
Some(render_file_view(
&canonical,
content,
token,
&workspace_id,
&ws,
&root,
&state,
))
})
.await
.unwrap_or_else(|e| {
tracing::error!("render_file_view join error: {e}");
Some((StatusCode::INTERNAL_SERVER_ERROR, "preview task failed").into_response())
})
}
fn render_markdown_file(
file_path: &str,
workspace_id: &str,
ws: &WorkspaceEntry,
root: &FsPath,
state: &AppState,
can_manage: bool,
) -> Response {
match fs::read_to_string(file_path) {
Ok(markdown_input) => {
let renderer = default_markdown_engine(&state.theme).with_asset_context(
workspace_id,
file_path,
root,
);
let rendered = MarkdownEngine::render(&renderer, &markdown_input);
let title = std::path::Path::new(file_path)
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| file_path.to_string());
let mut context = base_context(state);
context.insert("title", &title);
context.insert("file_path", file_path);
context.insert("workspace_id", workspace_id);
insert_workspace_header_context(&mut context, ws, root);
context.insert("version", env!("CARGO_PKG_VERSION"));
context.insert("content", &rendered.html);
context.insert("history_url", &workspace_git_history_url(workspace_id));
let back_link =
workspace_file_back_link(workspace_id, std::path::Path::new(file_path), root);
context.insert("back_link", &back_link);
context.insert("show_back_link", &!ws.is_ephemeral());
context.insert("has_mermaid", &rendered.has_mermaid);
context.insert("has_math", &rendered.has_math);
context.insert("toc", &rendered.toc);
context.insert("markdown_diagnostics", &rendered.diagnostics);
context.insert("referenced_assets", &rendered.referenced_assets);
let flags = ws.flags();
context.insert("shared_annotation", &flags.shared_annotation);
context.insert("enable_viewed", &flags.enable_viewed);
context.insert("enable_search", &flags.enable_search);
context.insert("can_manage", &can_manage);
context.insert("enable_edit", &flags.enable_edit);
context.insert("enable_live", &flags.enable_live);
context.insert("enable_chat", &flags.enable_chat);
if flags.enable_edit {
let json = js_json_safe(serde_json::to_string(&markdown_input).unwrap_or_default());
context.insert("markdown_content_json", &json);
context.insert(
"save_token",
&workspace_save_token(&state.save_token, workspace_id),
);
}
render_template(state, "layout.html", &context)
}
Err(e) => {
let mut context = base_context(state);
context.insert("title", "Error");
context.insert("version", env!("CARGO_PKG_VERSION"));
context.insert(
"content",
&format!(
r#"<p style="color: red;">Error reading file '{file_path}': {e}</p>
<a href="/">← Back to file list</a>"#
),
);
context.insert("show_back_link", &false);
context.insert("has_mermaid", &false);
context.insert("has_math", &false);
render_template(state, "layout.html", &context)
}
}
}
#[derive(serde::Serialize)]
struct DirListingEntry {
name: String,
is_dir: bool,
is_markdown: bool,
is_hidden: bool,
show_in_markdown: bool,
link: String,
rel_git_path: String,
last_commit_subject: Option<String>,
last_commit_time: Option<String>,
}
fn collect_directory_entries(
workspace_id: &str,
root: &FsPath,
current_dir: &FsPath,
) -> std::io::Result<Vec<DirListingEntry>> {
let mut entries: Vec<DirListingEntry> = fs::read_dir(current_dir)?
.filter_map(|e| e.ok())
.filter_map(|entry| {
let path = entry.path();
let name = entry.file_name().to_string_lossy().to_string();
let is_hidden = name.starts_with('.');
let file_type = entry.file_type().ok()?;
let is_dir = file_type.is_dir();
let is_markdown = !is_dir && is_markdown_path(&path);
let rel = path.strip_prefix(root).unwrap_or(&path).to_path_buf();
let rel_git_path = rel.to_string_lossy().replace('\\', "/");
let rel_url = path_to_route(&rel);
let link = if is_dir {
workspace_file_url(workspace_id, &format!("{rel_url}/"))
} else {
workspace_file_url(workspace_id, &rel_url)
};
Some(DirListingEntry {
name,
is_dir,
is_markdown,
is_hidden,
show_in_markdown: !is_hidden && is_markdown,
link,
rel_git_path,
last_commit_subject: None,
last_commit_time: None,
})
})
.collect();
if entries.iter().any(|entry| entry.is_dir && !entry.is_hidden) {
let dirs_with_markdown = direct_child_dirs_with_markdown_descendants(root, current_dir);
for entry in entries.iter_mut().filter(|entry| entry.is_dir) {
entry.show_in_markdown =
!entry.is_hidden && dirs_with_markdown.contains(&entry.rel_git_path);
}
}
entries.sort_by(|a, b| match (a.is_dir, b.is_dir) {
(true, false) => std::cmp::Ordering::Less,
(false, true) => std::cmp::Ordering::Greater,
_ => a.name.cmp(&b.name),
});
let git_status = git::status(root);
if git_status.available {
let rel_paths: Vec<String> = entries
.iter()
.map(|entry| entry.rel_git_path.clone())
.collect();
if let Ok(path_commits) = git::last_commits_for_paths(root, &rel_paths) {
for entry in entries.iter_mut() {
let Some(commit) = path_commits.get(&entry.rel_git_path) else {
continue;
};
entry.last_commit_subject = Some(commit.subject.clone());
entry.last_commit_time = Some(commit.time.clone());
}
}
}
Ok(entries)
}
fn direct_child_dirs_with_markdown_descendants(
root: &FsPath,
current_dir: &FsPath,
) -> HashSet<String> {
let mut dirs = HashSet::new();
let walker = crate::fswalk::default_walker(current_dir).build();
for entry in walker.filter_map(|entry| entry.ok()) {
let path = entry.path();
if path == current_dir || !path.is_file() || !is_markdown_path(path) {
continue;
}
let Ok(rel_to_current) = path.strip_prefix(current_dir) else {
continue;
};
let Some(std::path::Component::Normal(first_component)) =
rel_to_current.components().next()
else {
continue;
};
let direct_child = current_dir.join(first_component);
if direct_child == path {
continue;
}
let rel_to_root = direct_child.strip_prefix(root).unwrap_or(&direct_child);
dirs.insert(path_to_route(rel_to_root));
}
dirs
}
async fn handle_workspace_dir_data(
State(state): State<AppState>,
AxumPath(workspace_id): AxumPath<String>,
Query(query): Query<DirListingQuery>,
) -> impl IntoResponse {
let Some(ws) = state.workspace_registry.get(&workspace_id) else {
return StatusCode::NOT_FOUND.into_response();
};
if ws.is_ephemeral() {
let rel = query.path.as_deref().unwrap_or("").trim().trim_matches('/');
if rel.split('/').any(|part| part == ".." || part == ".") {
return StatusCode::NOT_FOUND.into_response();
}
return Json(scoped_directory_entries(&workspace_id, &ws, rel)).into_response();
}
let root = canonical_workspace_root(&ws);
let rel = query.path.as_deref().unwrap_or("").trim().trim_matches('/');
let target = if rel.is_empty() {
root.clone()
} else {
root.join(rel)
};
let current_dir = match canonicalize_route_path(&target) {
Ok(p) => p,
Err(_) => return StatusCode::NOT_FOUND.into_response(),
};
if !current_dir.starts_with(&root) {
return StatusCode::NOT_FOUND.into_response();
}
match collect_directory_entries(&workspace_id, &root, ¤t_dir) {
Ok(entries) => Json(entries).into_response(),
Err(_) => Json(Vec::<DirListingEntry>::new()).into_response(),
}
}
fn scoped_directory_entries(
workspace_id: &str,
ws: &WorkspaceEntry,
directory: &str,
) -> Vec<DirListingEntry> {
let prefix = directory.trim_matches('/');
let mut entries: HashMap<String, DirListingEntry> = HashMap::new();
for (rel, path) in ws.fs.served_files(2000) {
let route = rel.as_route();
let rest = if prefix.is_empty() {
route.as_str()
} else if let Some(rest) = route.strip_prefix(prefix).and_then(|r| r.strip_prefix('/')) {
rest
} else {
continue;
};
let (name, is_dir) = match rest.split_once('/') {
Some((name, _)) => (name, true),
None => (rest, false),
};
if name.is_empty() {
continue;
}
let child_route = if prefix.is_empty() {
name.to_string()
} else {
format!("{prefix}/{name}")
};
let link_route = if is_dir {
format!("{child_route}/")
} else {
child_route.clone()
};
let markdown_descendant = is_markdown_path(&path);
let entry = entries
.entry(name.to_string())
.or_insert_with(|| DirListingEntry {
name: name.to_string(),
is_dir,
is_markdown: !is_dir && markdown_descendant,
is_hidden: name.starts_with('.'),
show_in_markdown: !name.starts_with('.') && markdown_descendant,
link: workspace_file_url(workspace_id, &link_route),
rel_git_path: child_route,
last_commit_subject: None,
last_commit_time: None,
});
entry.show_in_markdown |= !entry.is_hidden && markdown_descendant;
}
let mut entries: Vec<_> = entries.into_values().collect();
entries.sort_by(|a, b| match (a.is_dir, b.is_dir) {
(true, false) => std::cmp::Ordering::Less,
(false, true) => std::cmp::Ordering::Greater,
_ => a.name.cmp(&b.name),
});
entries
}
#[derive(Deserialize)]
struct DirListingQuery {
path: Option<String>,
}
fn render_directory_listing(
workspace_id: &str,
ws: &WorkspaceEntry,
root: &FsPath,
dir_param: Option<&str>,
state: &AppState,
can_manage: bool,
) -> Response {
let Some(workspace_root) = ws.fs.directory_root() else {
return StatusCode::NOT_FOUND.into_response();
};
let current_dir = if let Some(dir_str) = dir_param {
let p = PathBuf::from(dir_str);
if p.is_absolute() {
p
} else {
workspace_root.join(&p)
}
} else {
workspace_root.to_path_buf()
};
let current_dir = match canonicalize_route_path(¤t_dir) {
Ok(p) => p,
Err(e) => {
return (StatusCode::BAD_REQUEST, format!("Invalid directory: {e}")).into_response()
}
};
if !current_dir.starts_with(root) {
return StatusCode::NOT_FOUND.into_response();
}
let entries = match collect_directory_entries(workspace_id, root, ¤t_dir) {
Ok(entries) => entries,
Err(e) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Error reading directory: {e}"),
)
.into_response()
}
};
let git_status = git::status(root);
let show_parent = current_dir != root;
let parent_link: Option<String> = if show_parent {
current_dir.parent().map(|parent| {
let rel = parent
.strip_prefix(root)
.map(path_to_route)
.unwrap_or_default();
if rel.is_empty() {
workspace_root_url(workspace_id)
} else {
workspace_file_url(workspace_id, &format!("{rel}/"))
}
})
} else {
None
};
#[derive(serde::Serialize)]
struct BreadcrumbSegment {
name: String,
link: String,
is_current: bool,
}
let workspace_display_name = workspace_display_name(ws, root);
let rel_components: Vec<String> = current_dir
.strip_prefix(root)
.ok()
.map(|rel| {
rel.components()
.filter_map(|c| match c {
std::path::Component::Normal(part) => Some(part.to_string_lossy().to_string()),
_ => None,
})
.collect()
})
.unwrap_or_default();
let mut breadcrumb: Vec<BreadcrumbSegment> = Vec::new();
let depth = rel_components.len();
breadcrumb.push(BreadcrumbSegment {
name: workspace_display_name,
link: workspace_root_url(workspace_id),
is_current: depth == 0,
});
let mut acc = String::new();
for (i, comp) in rel_components.iter().enumerate() {
if acc.is_empty() {
acc = comp.clone();
} else {
acc = format!("{acc}/{comp}");
}
breadcrumb.push(BreadcrumbSegment {
name: comp.clone(),
link: workspace_file_url(workspace_id, &format!("{acc}/")),
is_current: i + 1 == depth,
});
}
let flags = ws.flags();
let feature_statuses = vec![
WorkspaceFeatureStatus {
key: "enable_search",
label: "Search",
label_key: "web.ws.feature.search",
enabled: flags.enable_search,
},
WorkspaceFeatureStatus {
key: "enable_viewed",
label: "Viewed tracking",
label_key: "web.ws.feature.viewed",
enabled: flags.enable_viewed,
},
WorkspaceFeatureStatus {
key: "enable_edit",
label: "Edit",
label_key: "web.ws.feature.edit",
enabled: flags.enable_edit,
},
WorkspaceFeatureStatus {
key: "enable_live",
label: "Live",
label_key: "web.ws.feature.live",
enabled: flags.enable_live,
},
WorkspaceFeatureStatus {
key: "enable_chat",
label: "AI Chat",
label_key: "web.ws.feature.chat",
enabled: flags.enable_chat,
},
WorkspaceFeatureStatus {
key: "shared_annotation",
label: "Shared notes",
label_key: "web.ws.feature.shared",
enabled: flags.shared_annotation,
},
];
let git_commits = if git_status.available {
git::history(root, 6).unwrap_or_default()
} else {
Vec::new()
};
let git_commit_count = if git_status.available {
git::commit_count(root).unwrap_or(0)
} else {
0
};
let git_branches = if git_status.available {
git::branches_detailed(root).unwrap_or_default()
} else {
Vec::new()
};
let git_branch_count = if git_status.available {
git_branches.len()
} else {
0
};
let git_tag_count = if git_status.available {
git::tag_count(root).unwrap_or(0)
} else {
0
};
let git_changed_count = git_status.added
+ git_status.modified
+ git_status.deleted
+ git_status.renamed
+ git_status.untracked;
let work_diff_has_markdown_changes = git_status.available
&& git::diff_has_markdown_changes(root, "HEAD", "worktree").unwrap_or(false);
let work_diff_url =
work_diff_has_markdown_changes.then(|| markdown_work_diff_page_url(workspace_id));
let latest_commit = git_commits.first().cloned();
let latest_commit_diff_url = latest_commit
.as_ref()
.and_then(|commit| git_commit_markdown_diff_url(root, workspace_id, commit, "rendered"));
let is_workspace_root = current_dir == root;
let can_add_file = can_manage && flags.enable_edit;
let mut context = base_context(state);
context.insert("workspace_id", workspace_id);
context.insert("workspace_alias", &ws.alias());
context.insert(
"workspace_name",
&root
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_default(),
);
context.insert("can_manage", &can_manage);
context.insert("current_dir", ¤t_dir.display().to_string());
context.insert("history_url", &workspace_git_history_url(workspace_id));
context.insert("work_diff_url", &work_diff_url);
context.insert("latest_commit", &latest_commit);
context.insert("latest_commit_diff_url", &latest_commit_diff_url);
context.insert("git_changed_count", &git_changed_count);
context.insert("git_commit_count", &git_commit_count);
context.insert("git_branch_count", &git_branch_count);
context.insert("git_tag_count", &git_tag_count);
context.insert("git_branches", &git_branches);
context.insert("git_commits", &git_commits);
context.insert("feature_statuses", &feature_statuses);
context.insert("git", &git_status);
context.insert("is_workspace_root", &is_workspace_root);
context.insert("can_add_file", &can_add_file);
context.insert("version", env!("CARGO_PKG_VERSION"));
context.insert("branches_url", &workspace_git_branches_url(workspace_id));
context.insert("tags_url", &workspace_git_tags_url(workspace_id));
context.insert("checkout_url", &workspace_git_checkout_url(workspace_id));
context.insert("files_data_url", &workspace_files_data_url(workspace_id));
context.insert("files_dir_url", &workspace_files_dir_url(workspace_id));
context.insert(
"settings_features_url",
&workspace_settings_features_url(workspace_id),
);
context.insert("create_file_url", &workspace_file_create_url(workspace_id));
context.insert(
"create_folder_url",
&workspace_folder_create_url(workspace_id),
);
context.insert("entries", &entries);
context.insert("show_parent", &show_parent);
context.insert("parent_link", &parent_link);
context.insert("breadcrumb", &breadcrumb);
context.insert("enable_search", &flags.enable_search);
context.insert("enable_live", &flags.enable_live);
context.insert("enable_chat", &flags.enable_chat);
render_template(state, "directory.html", &context)
}
async fn serve_favicon() -> impl IntoResponse {
(
StatusCode::MOVED_PERMANENTLY,
[(header::LOCATION, "/_/favicon.svg")],
)
.into_response()
}
async fn serve_favicon_svg() -> impl IntoResponse {
serve_static_file("favicon.svg", IconAssets::get, "image/svg+xml")
}
async fn serve_css(AxumPath(filename): AxumPath<String>) -> impl IntoResponse {
serve_static_file(&filename, CssAssets::get, "text/css")
}
async fn serve_js(AxumPath(path): AxumPath<String>) -> impl IntoResponse {
let content_type = mime_guess::from_path(&path)
.first_or_octet_stream()
.essence_str()
.to_string();
serve_static_file(&path, JsAssets::get, &content_type)
}
fn serve_static_file<F>(filename: &str, getter: F, content_type: &str) -> Response
where
F: FnOnce(&str) -> Option<rust_embed::EmbeddedFile>,
{
match getter(filename) {
Some(file) => (
StatusCode::OK,
[(header::CONTENT_TYPE, content_type)],
file.data,
)
.into_response(),
None => (StatusCode::NOT_FOUND, "File not found").into_response(),
}
}
async fn serve_file(path: &std::path::Path, req_headers: &axum::http::HeaderMap) -> Response {
use tower::ServiceExt;
let mut req = axum::http::Request::new(axum::body::Body::empty());
for name in [
header::RANGE,
header::IF_RANGE,
header::IF_MODIFIED_SINCE,
header::IF_NONE_MATCH,
header::ACCEPT_ENCODING,
] {
if let Some(value) = req_headers.get(&name) {
req.headers_mut().insert(name, value.clone());
}
}
match tower_http::services::ServeFile::new(path)
.oneshot(req)
.await
{
Ok(resp) => resp.map(axum::body::Body::new).into_response(),
Err(_) => (StatusCode::INTERNAL_SERVER_ERROR, "Error reading file").into_response(),
}
}
#[derive(Deserialize)]
struct SaveFileRequest {
workspace_id: String,
file_path: String,
content: String,
}
#[derive(Serialize)]
struct SaveFileResponse {
success: bool,
message: String,
}
fn atomic_write(target: &FsPath, content: &[u8]) -> std::io::Result<()> {
use std::io::Write;
static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
let dir = target.parent().ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"target has no parent directory",
)
})?;
let base = target
.file_name()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_default();
let n = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let tmp_path = dir.join(format!(".{base}.{}.{n}.tmp", std::process::id()));
let mut file = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&tmp_path)?;
if let Err(e) = file.write_all(content).and_then(|()| file.sync_all()) {
drop(file);
let _ = std::fs::remove_file(&tmp_path);
return Err(e);
}
drop(file);
#[cfg(unix)]
if let Ok(meta) = std::fs::metadata(target) {
let _ = std::fs::set_permissions(&tmp_path, meta.permissions());
}
match std::fs::rename(&tmp_path, target) {
Ok(()) => Ok(()),
Err(e) => {
let _ = std::fs::remove_file(&tmp_path);
Err(e)
}
}
}
async fn save_file_handler(
State(state): State<AppState>,
headers: axum::http::HeaderMap,
Json(payload): Json<SaveFileRequest>,
) -> impl IntoResponse {
let supplied_token = headers
.get("X-Markon-Token")
.and_then(|value| value.to_str().ok());
let scoped_token = workspace_save_token(&state.save_token, &payload.workspace_id);
let token_valid = supplied_token.is_some_and(|token| {
ct_eq(token.as_bytes(), scoped_token.as_bytes())
|| ct_eq(token.as_bytes(), state.management_token.as_bytes())
});
if !token_valid {
return StatusCode::UNAUTHORIZED.into_response();
}
let ws = match state.workspace_registry.get(&payload.workspace_id) {
Some(w) => w,
None => {
return Json(SaveFileResponse {
success: false,
message: "Workspace not found".into(),
})
.into_response()
}
};
if !ws.enable_edit.load(std::sync::atomic::Ordering::Relaxed) {
return Json(SaveFileResponse {
success: false,
message: "Edit feature is not enabled".into(),
})
.into_response();
}
let decoded = match urlencoding::decode(&payload.file_path) {
Ok(p) => p,
Err(_) => {
return Json(SaveFileResponse {
success: false,
message: "Invalid file path encoding".into(),
})
.into_response()
}
};
let decoded_path = std::path::Path::new(decoded.as_ref());
let canonical = match ws.fs.resolve_editable_input(decoded_path) {
Ok(path) => path,
Err(
crate::workspace_fs::WorkspaceFsError::InvalidPath
| crate::workspace_fs::WorkspaceFsError::Denied,
) => {
return Json(SaveFileResponse {
success: false,
message: "Access denied".into(),
})
.into_response()
}
Err(
crate::workspace_fs::WorkspaceFsError::NotFound
| crate::workspace_fs::WorkspaceFsError::Io(_),
) => {
return Json(SaveFileResponse {
success: false,
message: format!("File not found: {decoded}"),
})
.into_response()
}
};
if !canonical.is_file() {
return Json(SaveFileResponse {
success: false,
message: "Path is not a file".into(),
})
.into_response();
}
if !is_markdown_path(&canonical) {
return Json(SaveFileResponse {
success: false,
message: "Only .md files can be edited".into(),
})
.into_response();
}
let content = payload.content;
let write_result =
tokio::task::spawn_blocking(move || atomic_write(&canonical, content.as_bytes())).await;
match write_result {
Ok(Ok(())) => Json(SaveFileResponse {
success: true,
message: "File saved successfully".into(),
})
.into_response(),
Ok(Err(e)) if e.kind() == std::io::ErrorKind::PermissionDenied => Json(SaveFileResponse {
success: false,
message: "File is read-only".into(),
})
.into_response(),
Ok(Err(e)) => Json(SaveFileResponse {
success: false,
message: format!("Failed to save: {e}"),
})
.into_response(),
Err(e) => {
tracing::error!("save_file_handler blocking task join error: {e}");
Json(SaveFileResponse {
success: false,
message: "Failed to save: internal error".into(),
})
.into_response()
}
}
}
#[derive(Deserialize)]
struct PreviewRequest {
content: String,
}
#[derive(Serialize)]
struct PreviewResponse {
html: String,
has_mermaid: bool,
has_math: bool,
}
async fn preview_handler(
State(state): State<AppState>,
Json(payload): Json<PreviewRequest>,
) -> impl IntoResponse {
let renderer = default_markdown_engine(&state.theme);
let rendered = MarkdownEngine::render(&renderer, &payload.content);
Json(PreviewResponse {
html: rendered.html,
has_mermaid: rendered.has_mermaid,
has_math: rendered.has_math,
})
.into_response()
}
#[cfg(test)]
mod tests {
use super::*;
use axum::body::to_bytes;
use serde_json::json;
use axum::http::HeaderMap;
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
use tower::ServiceExt;
fn test_tera() -> Tera {
let mut tera = Tera::default();
for file_name in Templates::iter() {
let file = Templates::get(&file_name).expect("embedded template");
let content = std::str::from_utf8(&file.data).expect("utf-8 template");
tera.add_raw_template(&file_name, content)
.expect("template registration");
}
tera
}
fn test_state(registry: Arc<WorkspaceRegistry>) -> AppState {
let (shutdown_tx, _) = tokio::sync::mpsc::channel(1);
AppState {
theme: Arc::new("light".into()),
tera: Arc::new(test_tera()),
db: None,
workspace_registry: registry,
management_token: Arc::new("test-token".into()),
admin_bootstraps: Arc::new(AdminBootstrapStore::new()),
allowed_hosts: Arc::new(build_allowed_hosts("127.0.0.1", "", 6419, &[], &[])),
save_token: Arc::new("save-token".into()),
i18n_json: Arc::new(i18n::load_i18n()),
i18n_lang: Arc::new("en".into()),
shortcuts_json: Arc::new("null".into()),
styles_css: Arc::new("".into()),
default_chat_mode: Arc::new("in_page".into()),
editor_theme: Arc::new("follow".into()),
collaborator_access_code_hash: Arc::new(String::new()),
access_secret: Arc::new("test-salt".into()),
access_attempts: Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())),
markdown_diff_cache: Arc::new(Mutex::new(MarkdownDiffCache::default())),
print_collapsed_content: false,
shutdown_tx,
#[cfg(debug_assertions)]
dev_reload_tx: Arc::new(broadcast::channel::<()>(1).0),
}
}
fn add_test_workspace(
registry: &WorkspaceRegistry,
root: PathBuf,
flags: WorkspaceFlags,
) -> String {
registry.add(WorkspaceConfig {
path: dunce::canonicalize(root).expect("canonical workspace root"),
flags,
single_file: None,
collaborator_access_code_hash: String::new(),
..Default::default()
})
}
async fn spawn_collaboration_test_server(
state: AppState,
) -> (SocketAddr, tokio::task::JoinHandle<()>) {
let app = Router::new()
.route(WORKSPACE_WS_ROUTE, get(ws_handler))
.fallback(|| async { StatusCode::NOT_FOUND })
.layer(axum::middleware::from_fn_with_state(
state.clone(),
require_access_code,
))
.with_state(state);
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let task = tokio::spawn(async move {
let _ = axum::serve(
listener,
app.into_make_service_with_connect_info::<SocketAddr>(),
)
.await;
});
(addr, task)
}
fn collaborator_access_scope_for(state: &AppState, id: &str) -> Option<(String, String)> {
access_requirements_for(state, id)
.into_iter()
.find(|req| req.role == AccessRole::Collaborator)
.map(|req| (req.hash, req.scope))
}
#[test]
fn workspace_code_overrides_global_and_survives_reseed() {
let tmp = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(tmp.path()).unwrap();
let salt = "test-salt";
let ws_hash = crate::workspace::hash_access_code(salt, "wsCode");
let global_hash = crate::workspace::hash_access_code(salt, "global");
let reg = Arc::new(WorkspaceRegistry::new(salt.into()));
let id = reg.add(WorkspaceConfig {
path: root.clone(),
flags: WorkspaceFlags::default(),
single_file: None,
collaborator_access_code_hash: String::new(),
..Default::default()
});
assert!(reg.set_collaborator_access_code(&id, &ws_hash));
let mut state = test_state(reg.clone());
state.collaborator_access_code_hash = Arc::new(global_hash.clone());
let (h, scope) = collaborator_access_scope_for(&state, &id).expect("workspace is gated");
assert_eq!(
scope,
format!("w:{id}:collaborator"),
"must use the workspace scope"
);
assert_eq!(h, ws_hash, "live: workspace code must win over global");
let reg2 = Arc::new(WorkspaceRegistry::new(salt.into()));
let id2 = reg2.add(WorkspaceConfig {
path: root,
flags: WorkspaceFlags::default(),
single_file: None,
collaborator_access_code_hash: ws_hash.clone(),
..Default::default()
});
assert_eq!(id, id2, "workspace id must be stable across reseed");
assert_eq!(
reg2.get(&id2).unwrap().collaborator_access_code_hash(),
ws_hash,
"code must survive the reseed"
);
let mut state2 = test_state(reg2);
state2.collaborator_access_code_hash = Arc::new(global_hash);
let (h2, scope2) =
collaborator_access_scope_for(&state2, &id2).expect("gated after reseed");
assert_eq!(scope2, format!("w:{id2}:collaborator"));
assert_eq!(h2, ws_hash, "after restart: workspace code must STILL win");
}
#[test]
fn access_cookie_round_trips_workspace_scope() {
let secret = "test-salt";
let scopes = vec![
("w:1a2b3c4d".to_string(), "4f965".to_string()),
("s".to_string(), "abcde".to_string()),
];
let cookie = make_access_cookie(secret, &scopes, access_now_unix() + 1000, false);
let back = access_cookie_scopes(secret, Some(&cookie));
assert!(
back.contains(&("w:1a2b3c4d".to_string(), "4f965".to_string())),
"workspace scope must survive the cookie round-trip: {back:?}"
);
assert!(back.contains(&("s".to_string(), "abcde".to_string())));
}
#[test]
fn access_cookie_resolves_collaborator_role() {
let tmp = tempfile::tempdir().unwrap();
let salt = "test-salt";
let collaborator_hash = crate::workspace::hash_access_code(salt, "guest-code");
let reg = Arc::new(WorkspaceRegistry::new(salt.into()));
let id = reg.add(WorkspaceConfig {
path: dunce::canonicalize(tmp.path()).unwrap(),
flags: WorkspaceFlags::default(),
single_file: None,
collaborator_access_code_hash: collaborator_hash.clone(),
..Default::default()
});
let state = test_state(reg);
let collaborator_cookie = make_access_cookie(
salt,
&[(format!("w:{id}:collaborator"), collaborator_hash)],
access_now_unix() + 100,
false,
);
assert_eq!(
access_role_from_cookie(&state, &id, Some(&collaborator_cookie)),
Some(AccessRole::Collaborator)
);
}
async fn response_text(response: Response) -> String {
let bytes = response_bytes(response).await;
String::from_utf8(bytes.to_vec()).expect("utf-8 response")
}
async fn response_bytes(response: Response) -> axum::body::Bytes {
to_bytes(response.into_body(), usize::MAX)
.await
.expect("response body")
}
fn all_flags() -> WorkspaceFlags {
WorkspaceFlags {
enable_search: true,
enable_viewed: true,
enable_edit: true,
enable_live: true,
enable_chat: true,
shared_annotation: true,
}
}
fn headers_with(origin: Option<&str>, host: Option<&str>) -> HeaderMap {
let mut h = HeaderMap::new();
if let Some(o) = origin {
h.insert("origin", o.parse().unwrap());
}
if let Some(host) = host {
h.insert("host", host.parse().unwrap());
}
h
}
fn save_headers(state: &AppState, workspace_id: &str) -> HeaderMap {
let mut headers = HeaderMap::new();
headers.insert(
"X-Markon-Token",
workspace_save_token(&state.save_token, workspace_id)
.parse()
.unwrap(),
);
headers
}
fn loopback() -> SocketAddr {
SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 1618)
}
fn lan_peer() -> SocketAddr {
SocketAddr::new(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 50)), 51234)
}
#[test]
fn ws_origin_accepts_matching_authority() {
let h = headers_with(Some("http://192.168.1.10:1618"), Some("192.168.1.10:1618"));
assert!(check_ws_origin(&h, &lan_peer()));
}
#[test]
fn ws_origin_rejects_cross_origin() {
let h = headers_with(Some("http://evil.example.com"), Some("192.168.1.10:1618"));
assert!(!check_ws_origin(&h, &lan_peer()));
}
#[test]
fn ws_origin_rejects_port_mismatch() {
let h = headers_with(Some("http://127.0.0.1:9000"), Some("127.0.0.1:1618"));
assert!(!check_ws_origin(&h, &loopback()));
}
#[test]
fn ws_origin_rejects_null_origin() {
let h = headers_with(Some("null"), Some("127.0.0.1:1618"));
assert!(!check_ws_origin(&h, &loopback()));
}
#[test]
fn ws_missing_origin_allowed_only_from_loopback() {
let h = headers_with(None, Some("127.0.0.1:1618"));
assert!(check_ws_origin(&h, &loopback()));
assert!(!check_ws_origin(&h, &lan_peer()));
}
#[test]
fn save_origin_allows_lan_same_origin_but_not_missing_or_cross_origin() {
let same_origin = headers_with(
Some("http://192.168.1.13:59285"),
Some("192.168.1.13:59285"),
);
assert!(same_origin_or_loopback_no_origin(&same_origin, &lan_peer()));
let cross_origin =
headers_with(Some("http://evil.example.com"), Some("192.168.1.13:59285"));
assert!(!same_origin_or_loopback_no_origin(
&cross_origin,
&lan_peer()
));
let missing_origin = headers_with(None, Some("192.168.1.13:59285"));
assert!(!same_origin_or_loopback_no_origin(
&missing_origin,
&lan_peer()
));
assert!(same_origin_or_loopback_no_origin(
&missing_origin,
&loopback()
));
}
#[test]
fn ws_origin_case_insensitive_host_match() {
let h = headers_with(
Some("http://Example.Local:1618"),
Some("example.local:1618"),
);
assert!(check_ws_origin(&h, &loopback()));
}
#[test]
fn ws_origin_with_trailing_path_still_matches_authority() {
let h = headers_with(Some("http://127.0.0.1:1618/"), Some("127.0.0.1:1618"));
assert!(check_ws_origin(&h, &loopback()));
}
#[test]
fn ws_hello_requires_structured_non_legacy_protocol() {
let hello: WsHello = serde_json::from_str(
r#"{"type":"hello","target":{"kind":"surface","key":"/abcd1234/"}}"#,
)
.unwrap();
assert!(matches!(hello.target, WsTarget::Surface { .. }));
assert!(serde_json::from_str::<WsHello>(r#""/tmp/workspace/doc.md""#).is_err());
assert!(serde_json::from_str::<WsHello>(
r#"{"type":"legacy","target":{"kind":"surface","key":"/abcd1234/"}}"#
)
.is_err());
}
#[test]
fn ws_document_target_is_canonical_and_workspace_scoped() {
let root = tempfile::tempdir().unwrap();
let outside = tempfile::tempdir().unwrap();
let document = root.path().join("note.md");
let outside_file = outside.path().join("secret.md");
fs::write(&document, "# note").unwrap();
fs::write(&outside_file, "secret").unwrap();
let registry = Arc::new(WorkspaceRegistry::new("ws-document-scope".into()));
let id = add_test_workspace(®istry, root.path().to_path_buf(), all_flags());
let entry = registry.get(&id).unwrap();
let session = authorize_ws_target(
&entry,
WsTarget::Document {
path: document.to_string_lossy().into_owned(),
},
)
.expect("workspace document should be authorized");
let canonical = dunce::canonicalize(&document)
.unwrap()
.to_string_lossy()
.into_owned();
assert_eq!(
session.target,
WsSessionTarget::Document {
file_path: canonical.clone()
}
);
assert_eq!(session.channel, format!("document:{canonical}"));
assert!(authorize_ws_target(
&entry,
WsTarget::Document {
path: outside_file.to_string_lossy().into_owned(),
}
)
.is_none());
assert!(authorize_ws_target(
&entry,
WsTarget::Document {
path: "note.md".into(),
}
)
.is_none());
}
#[test]
fn ws_document_target_obeys_single_file_capability() {
let root = tempfile::tempdir().unwrap();
let pinned = root.path().join("pinned.md");
let sibling = root.path().join("sibling.md");
fs::write(&pinned, "# pinned").unwrap();
fs::write(&sibling, "# sibling").unwrap();
let registry = Arc::new(WorkspaceRegistry::new("ws-single-file".into()));
let id = registry.add(WorkspaceConfig {
path: dunce::canonicalize(root.path()).unwrap(),
flags: all_flags(),
single_file: Some("pinned.md".into()),
collaborator_access_code_hash: String::new(),
alias: String::new(),
});
let entry = registry.get(&id).unwrap();
assert!(authorize_ws_target(
&entry,
WsTarget::Document {
path: pinned.to_string_lossy().into_owned(),
}
)
.is_some());
assert!(authorize_ws_target(
&entry,
WsTarget::Document {
path: sibling.to_string_lossy().into_owned(),
}
)
.is_none());
}
#[test]
fn ws_surface_target_is_live_only_and_bound_to_workspace_url() {
let root = tempfile::tempdir().unwrap();
let registry = Arc::new(WorkspaceRegistry::new("ws-surface".into()));
let id = add_test_workspace(®istry, root.path().to_path_buf(), all_flags());
let entry = registry.get(&id).unwrap();
let surface = authorize_ws_target(
&entry,
WsTarget::Surface {
key: format!("/_/{id}/compare?base=main#change"),
},
)
.unwrap();
assert_eq!(
surface.channel,
format!("surface:/_/{id}/compare?base=main")
);
assert!(authorize_ws_target(
&entry,
WsTarget::Surface {
key: "/_/deadbeef/compare".into(),
}
)
.is_none());
registry.update_flags(
&id,
WorkspaceFlags {
shared_annotation: true,
enable_live: false,
..Default::default()
},
);
assert!(authorize_ws_target(
&entry,
WsTarget::Surface {
key: format!("/{id}/"),
}
)
.is_none());
}
#[test]
fn workspace_event_filter_prevents_cross_channel_delivery() {
let event = WorkspaceEvent::Channel {
channel: "document:/workspace/a.md".into(),
payload: "a".into(),
};
assert_eq!(
workspace_event_payload(event.clone(), "document:/workspace/a.md").as_deref(),
Some("a")
);
assert!(workspace_event_payload(event, "document:/workspace/b.md").is_none());
assert_eq!(
workspace_event_payload(
WorkspaceEvent::Workspace {
payload: "reload".into()
},
"surface:/abcd1234/"
)
.as_deref(),
Some("reload")
);
}
#[tokio::test]
async fn ws_server_enforces_live_and_annotation_capabilities() {
let root = tempfile::tempdir().unwrap();
let document = root.path().join("note.md");
fs::write(&document, "# note").unwrap();
let registry = Arc::new(WorkspaceRegistry::new("ws-feature-gates".into()));
let id = add_test_workspace(
®istry,
root.path().to_path_buf(),
WorkspaceFlags {
shared_annotation: true,
enable_live: false,
..Default::default()
},
);
let entry = registry.get(&id).unwrap();
let session = Arc::new(
authorize_ws_target(
&entry,
WsTarget::Document {
path: document.to_string_lossy().into_owned(),
},
)
.unwrap(),
);
let mut rx = entry.events_tx.subscribe();
handle_client_msg(
None,
entry.clone(),
session,
WebSocketMessage::LiveAction { data: json!({}) },
)
.await;
assert!(matches!(
rx.try_recv(),
Err(tokio::sync::broadcast::error::TryRecvError::Empty)
));
registry.update_flags(
&id,
WorkspaceFlags {
shared_annotation: false,
enable_live: true,
..Default::default()
},
);
let surface = Arc::new(
authorize_ws_target(
&entry,
WsTarget::Surface {
key: format!("/{id}/"),
},
)
.unwrap(),
);
handle_client_msg(
None,
entry,
surface,
WebSocketMessage::NewAnnotation {
annotation: json!({ "id": "forbidden" }),
op_id: None,
},
)
.await;
assert!(matches!(
rx.try_recv(),
Err(tokio::sync::broadcast::error::TryRecvError::Empty)
));
}
#[test]
fn test_websocket_message_serialization() {
let msg = WebSocketMessage::LiveAction {
data: json!({
"clientId": "test-id",
"action": "scroll_to",
"xpath": "/p[1]",
"offset": 0.5
}),
};
let serialized = serde_json::to_string(&msg).unwrap();
assert!(serialized.contains("\"type\":\"live_action\""));
assert!(serialized.contains("\"clientId\":\"test-id\""));
let file = WebSocketMessage::FileChanged {
workspace_id: "ws1".into(),
path: "docs/a.md".into(),
};
let serialized = serde_json::to_string(&file).unwrap();
assert!(serialized.contains("\"type\":\"file_changed\""));
assert!(serialized.contains("\"workspace_id\":\"ws1\""));
}
#[test]
fn test_new_annotation_op_id_round_trip() {
let with = WebSocketMessage::NewAnnotation {
annotation: json!({ "id": "anno-1", "text": "hi" }),
op_id: Some("op-abc".into()),
};
let json_with = serde_json::to_string(&with).unwrap();
assert!(
json_with.contains("\"op_id\":\"op-abc\""),
"wire form should include op_id: {json_with}"
);
let parsed: WebSocketMessage = serde_json::from_str(&json_with).unwrap();
match parsed {
WebSocketMessage::NewAnnotation { op_id, .. } => {
assert_eq!(op_id.as_deref(), Some("op-abc"));
}
_ => panic!("expected NewAnnotation"),
}
let without = WebSocketMessage::NewAnnotation {
annotation: json!({ "id": "anno-2" }),
op_id: None,
};
let json_without = serde_json::to_string(&without).unwrap();
assert!(
!json_without.contains("op_id"),
"wire form should omit op_id when None: {json_without}"
);
let legacy = r#"{"type":"new_annotation","annotation":{"id":"x"}}"#;
let parsed_legacy: WebSocketMessage = serde_json::from_str(legacy).unwrap();
match parsed_legacy {
WebSocketMessage::NewAnnotation { op_id, .. } => assert!(op_id.is_none()),
_ => panic!("expected NewAnnotation"),
}
}
#[test]
fn test_app_state_identity() {
let (tx, _) = tokio::sync::mpsc::channel(1);
let registry = Arc::new(crate::workspace::WorkspaceRegistry::new("salt".into()));
let state = AppState {
theme: Arc::new("dark".into()),
tera: Arc::new(Tera::default()),
db: None,
workspace_registry: registry,
management_token: Arc::new("token".into()),
admin_bootstraps: Arc::new(AdminBootstrapStore::new()),
allowed_hosts: Arc::new(build_allowed_hosts("127.0.0.1", "", 6419, &[], &[])),
save_token: Arc::new("save-token".into()),
i18n_json: Arc::new("{}".into()),
i18n_lang: Arc::new("zh".into()),
shortcuts_json: Arc::new("{}".into()),
styles_css: Arc::new("".into()),
default_chat_mode: Arc::new("in_page".into()),
editor_theme: Arc::new("follow".into()),
collaborator_access_code_hash: Arc::new(String::new()),
access_secret: Arc::new("test-salt".into()),
access_attempts: Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())),
markdown_diff_cache: Arc::new(Mutex::new(MarkdownDiffCache::default())),
print_collapsed_content: false,
shutdown_tx: tx,
#[cfg(debug_assertions)]
dev_reload_tx: Arc::new(broadcast::channel::<()>(1).0),
};
assert_eq!(state.management_token.as_str(), "token");
}
fn sample_hosts() -> Vec<crate::net::BindHostOption> {
use crate::net::{BindHostKind, BindHostOption};
vec![
BindHostOption {
address: "127.0.0.1".into(),
kind: BindHostKind::Localhost,
interface: None,
},
BindHostOption {
address: "::1".into(),
kind: BindHostKind::Localhost,
interface: None,
},
BindHostOption {
address: "0.0.0.0".into(),
kind: BindHostKind::AllInterfaces,
interface: None,
},
BindHostOption {
address: "::".into(),
kind: BindHostKind::AllInterfaces,
interface: None,
},
BindHostOption {
address: "192.168.1.20".into(),
kind: BindHostKind::Interface,
interface: Some("en0".into()),
},
BindHostOption {
address: "10.0.0.5".into(),
kind: BindHostKind::Interface,
interface: Some("eth1".into()),
},
BindHostOption {
address: "fd00::20".into(),
kind: BindHostKind::Interface,
interface: Some("en0".into()),
},
BindHostOption {
address: "2001:db8::5".into(),
kind: BindHostKind::Interface,
interface: Some("utun0".into()),
},
]
}
#[test]
fn reachable_ipv4_wildcard_lists_ipv4_localhost_then_interfaces() {
let r = assemble_reachable_urls("0.0.0.0", "", 6419, &sample_hosts());
assert_eq!(r.all.len(), 3);
assert_eq!(r.all[0].label, "localhost");
assert_eq!(r.all[0].url, "http://127.0.0.1:6419");
assert_eq!(r.all[1].url, "http://192.168.1.20:6419");
assert_eq!(r.all[2].url, "http://10.0.0.5:6419");
assert_eq!(r.featured, "http://192.168.1.20:6419");
}
#[test]
fn reachable_ipv6_wildcard_lists_ipv6_localhost_then_interfaces() {
let r = assemble_reachable_urls("::", "", 6419, &sample_hosts());
assert_eq!(r.all.len(), 3);
assert_eq!(r.all[0].label, "localhost");
assert_eq!(r.all[0].url, "http://[::1]:6419");
assert_eq!(r.all[1].url, "http://[fd00::20]:6419");
assert_eq!(r.all[2].url, "http://[2001:db8::5]:6419");
assert_eq!(r.featured, "http://[fd00::20]:6419");
}
#[test]
fn reachable_wildcard_honours_advertised_host_and_falls_back() {
let hosts = sample_hosts();
assert_eq!(
assemble_reachable_urls("0.0.0.0", "10.0.0.5", 6419, &hosts).featured,
"http://10.0.0.5:6419"
);
assert_eq!(
assemble_reachable_urls("0.0.0.0", "172.16.9.9", 6419, &hosts).featured,
"http://192.168.1.20:6419"
);
assert_eq!(
assemble_reachable_urls("::", "2001:db8::5", 6419, &hosts).featured,
"http://[2001:db8::5]:6419"
);
assert_eq!(
assemble_reachable_urls("::", "[fd00::99]", 6419, &hosts).featured,
"http://[fd00::20]:6419"
);
}
#[test]
fn reachable_wildcard_without_interfaces_falls_back_to_localhost() {
use crate::net::{BindHostKind, BindHostOption};
let hosts = vec![
BindHostOption {
address: "127.0.0.1".into(),
kind: BindHostKind::Localhost,
interface: None,
},
BindHostOption {
address: "::1".into(),
kind: BindHostKind::Localhost,
interface: None,
},
BindHostOption {
address: "0.0.0.0".into(),
kind: BindHostKind::AllInterfaces,
interface: None,
},
BindHostOption {
address: "::".into(),
kind: BindHostKind::AllInterfaces,
interface: None,
},
];
let r = assemble_reachable_urls("0.0.0.0", "", 6419, &hosts);
assert_eq!(r.all.len(), 1);
assert_eq!(r.featured, "http://127.0.0.1:6419");
let r = assemble_reachable_urls("::", "", 6419, &hosts);
assert_eq!(r.all.len(), 1);
assert_eq!(r.featured, "http://[::1]:6419");
}
#[test]
fn reachable_specific_bind_lists_only_that_address() {
let r = assemble_reachable_urls("192.168.1.20", "", 6419, &sample_hosts());
assert_eq!(r.all.len(), 1);
assert_eq!(r.all[0].label, "en0");
assert_eq!(r.featured, "http://192.168.1.20:6419");
}
#[test]
fn reachable_specific_ipv6_bind_lists_bracketed_address() {
let r = assemble_reachable_urls("fd00::20", "", 6419, &sample_hosts());
assert_eq!(r.all.len(), 1);
assert_eq!(r.all[0].label, "en0");
assert_eq!(r.all[0].url, "http://[fd00::20]:6419");
assert_eq!(r.featured, "http://[fd00::20]:6419");
}
#[test]
fn reachable_loopback_binds() {
let hosts = sample_hosts();
let v4 = assemble_reachable_urls("127.0.0.1", "", 6419, &hosts);
assert_eq!(v4.all.len(), 1);
assert_eq!(v4.featured, "http://127.0.0.1:6419");
let v6 = assemble_reachable_urls("::1", "", 6419, &hosts);
assert_eq!(v6.featured, "http://[::1]:6419");
}
#[test]
fn access_cookie_round_trips_and_rejects_tamper() {
let secret = "test-secret";
let scopes = vec![("s".to_string(), "h1".to_string())];
let raw = make_access_cookie(secret, &scopes, access_now_unix() + 100, false);
let kv = raw.split(';').next().unwrap(); assert_eq!(access_cookie_scopes(secret, Some(kv)), scopes);
assert!(access_cookie_scopes("other-secret", Some(kv)).is_empty());
assert!(access_cookie_scopes(secret, Some(&format!("{kv}00"))).is_empty());
let expired = make_access_cookie(secret, &scopes, 1, false);
assert!(access_cookie_scopes(secret, Some(expired.split(';').next().unwrap())).is_empty());
let secure = make_access_cookie(secret, &scopes, access_now_unix() + 100, true);
assert!(secure.contains("; Secure"));
}
#[test]
fn allowed_hosts_are_exact_and_track_explicit_https_origins() {
let allowed = build_allowed_hosts(
"127.0.0.1",
"",
6419,
&["https://md.example.com".into(), "notes.local".into()],
&[],
);
assert!(allowed.allows_header(Some("127.0.0.1:6419")));
assert!(allowed.allows_header(Some("[::1]:6419")));
assert!(allowed.allows_header(Some("LOCALHOST:9999")));
assert!(allowed.allows_header(Some("md.example.com")));
assert!(allowed.allows_header(Some("notes.local:443")));
assert!(!allowed.allows_header(Some("evil.example")));
assert!(!allowed.allows_header(Some("md.example.com.evil")));
assert!(allowed.is_secure_header(Some("md.example.com")));
assert!(!allowed.is_secure_header(Some("notes.local")));
}
#[tokio::test]
async fn unknown_host_is_rejected_before_route_execution() {
let state = test_state(Arc::new(WorkspaceRegistry::new("host-gate".into())));
let app = Router::new()
.route("/state-change", post(|| async { StatusCode::NO_CONTENT }))
.layer(axum::middleware::from_fn_with_state(
state.clone(),
require_allowed_host,
))
.with_state(state);
let evil = axum::http::Request::builder()
.method("POST")
.uri("/state-change")
.header(header::HOST, "evil.example:6419")
.header(header::ORIGIN, "http://evil.example:6419")
.body(axum::body::Body::empty())
.unwrap();
assert_eq!(
app.clone().oneshot(evil).await.unwrap().status(),
StatusCode::MISDIRECTED_REQUEST
);
let local = axum::http::Request::builder()
.method("POST")
.uri("/state-change")
.header(header::HOST, "127.0.0.1:6419")
.body(axum::body::Body::empty())
.unwrap();
assert_eq!(
app.oneshot(local).await.unwrap().status(),
StatusCode::NO_CONTENT
);
}
#[tokio::test]
async fn loopback_is_not_an_admin_identity() {
let root = tempfile::tempdir().unwrap();
let registry = Arc::new(WorkspaceRegistry::new("admin-role".into()));
let id = add_test_workspace(®istry, root.path().to_path_buf(), all_flags());
let required_hash = crate::workspace::hash_access_code("test-salt", "guest");
assert!(registry.set_collaborator_access_code(&id, &required_hash));
let state = test_state(registry);
let route = format!("/_/{id}/danger");
let app = Router::new()
.route(
"/_/{workspace_id}/danger",
post(|| async { StatusCode::NO_CONTENT })
.route_layer(axum::middleware::from_fn(require_admin_role)),
)
.layer(axum::middleware::from_fn_with_state(
state.clone(),
require_access_code,
));
let request = |cookie: Option<String>| {
let mut builder = axum::http::Request::builder()
.method("POST")
.uri(&route)
.extension(axum::extract::ConnectInfo(loopback()));
if let Some(cookie) = cookie {
builder = builder.header(header::COOKIE, cookie);
}
builder.body(axum::body::Body::empty()).unwrap()
};
assert_eq!(
app.clone().oneshot(request(None)).await.unwrap().status(),
StatusCode::UNAUTHORIZED
);
let collaborator = make_access_cookie(
&state.access_secret,
&[(format!("w:{id}:collaborator"), required_hash)],
access_now_unix() + 60,
false,
);
assert_eq!(
app.clone()
.oneshot(request(Some(collaborator)))
.await
.unwrap()
.status(),
StatusCode::FORBIDDEN
);
let admin =
admin_auth::make_admin_cookie(&state.management_token, access_now_unix(), false);
assert_eq!(
app.oneshot(request(Some(admin))).await.unwrap().status(),
StatusCode::NO_CONTENT
);
}
#[tokio::test]
async fn admin_nonce_exchange_sets_single_use_http_only_session() {
let state = test_state(Arc::new(WorkspaceRegistry::new("admin-exchange".into())));
let nonce = state.admin_bootstraps.issue_url("/abcd1234/");
let headers = headers_with(Some("http://127.0.0.1:6419"), Some("127.0.0.1:6419"));
let response = admin_session_handler(
State(state.clone()),
axum::extract::ConnectInfo(loopback()),
headers.clone(),
Json(AdminSessionRequest {
nonce: Some(nonce.clone()),
code: None,
}),
)
.await;
assert_eq!(response.status(), StatusCode::OK);
let cookie = response
.headers()
.get(header::SET_COOKIE)
.and_then(|value| value.to_str().ok())
.expect("admin session cookie");
assert!(cookie.contains("HttpOnly; SameSite=Strict"));
assert!(admin_auth::admin_cookie_valid(
&state.management_token,
Some(cookie),
access_now_unix(),
));
let replay = admin_session_handler(
State(state),
axum::extract::ConnectInfo(loopback()),
headers,
Json(AdminSessionRequest {
nonce: Some(nonce),
code: None,
}),
)
.await;
assert_eq!(replay.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn save_capability_cannot_cross_workspace_boundary() {
let root_a = tempfile::tempdir().unwrap();
let root_b = tempfile::tempdir().unwrap();
std::fs::write(root_a.path().join("a.md"), "workspace a").unwrap();
std::fs::write(root_b.path().join("b.md"), "workspace b").unwrap();
let registry = Arc::new(WorkspaceRegistry::new("save-scope".into()));
let id_a = add_test_workspace(®istry, root_a.path().to_path_buf(), all_flags());
let id_b = add_test_workspace(®istry, root_b.path().to_path_buf(), all_flags());
let state = test_state(registry);
let token_a = workspace_save_token(&state.save_token, &id_a);
let token_b = workspace_save_token(&state.save_token, &id_b);
assert_ne!(token_a, token_b);
let app = Router::new()
.route("/api/save", post(save_file_handler))
.layer(axum::middleware::from_fn_with_state(
state.clone(),
require_local_save_origin,
))
.with_state(state);
let request = |token: &str, content: &str| {
axum::http::Request::builder()
.method("POST")
.uri("/api/save")
.header(header::CONTENT_TYPE, "application/json")
.header(header::HOST, "127.0.0.1:6419")
.header(header::ORIGIN, "http://127.0.0.1:6419")
.header("X-Markon-Token", token)
.extension(axum::extract::ConnectInfo(loopback()))
.body(axum::body::Body::from(
json!({
"workspace_id": id_b,
"file_path": "b.md",
"content": content,
})
.to_string(),
))
.unwrap()
};
let denied = app
.clone()
.oneshot(request(&token_a, "stolen"))
.await
.unwrap();
assert_eq!(denied.status(), StatusCode::UNAUTHORIZED);
assert_eq!(
std::fs::read_to_string(root_b.path().join("b.md")).unwrap(),
"workspace b"
);
let allowed = app.oneshot(request(&token_b, "updated b")).await.unwrap();
assert_eq!(allowed.status(), StatusCode::OK);
assert_eq!(
std::fs::read_to_string(root_b.path().join("b.md")).unwrap(),
"updated b"
);
}
#[test]
fn annotation_id_cannot_replace_another_documents_row() {
let conn = Connection::open_in_memory().unwrap();
conn.execute(
"CREATE TABLE annotations (
id TEXT PRIMARY KEY,
file_path TEXT NOT NULL,
data TEXT NOT NULL
)",
[],
)
.unwrap();
assert!(upsert_annotation_for_file(
&conn,
"shared-id",
"/workspace/a.md",
r#"{"id":"shared-id","text":"a"}"#,
)
.unwrap());
assert!(!upsert_annotation_for_file(
&conn,
"shared-id",
"/workspace/b.md",
r#"{"id":"shared-id","text":"b"}"#,
)
.unwrap());
let (file_path, data): (String, String) = conn
.query_row(
"SELECT file_path, data FROM annotations WHERE id = ?1",
["shared-id"],
|row| Ok((row.get(0)?, row.get(1)?)),
)
.unwrap();
assert_eq!(file_path, "/workspace/a.md");
assert!(data.contains(r#""text":"a""#));
assert!(upsert_annotation_for_file(
&conn,
"shared-id",
"/workspace/a.md",
r#"{"id":"shared-id","text":"a2"}"#,
)
.unwrap());
}
#[test]
fn access_cooldown_locks_after_threshold() {
let state = test_state(Arc::new(WorkspaceRegistry::new("s".into())));
let ip: std::net::IpAddr = "1.2.3.4".parse().unwrap();
for _ in 0..ACCESS_MAX_FAILS - 1 {
assert!(access_record_failure(&state, ip).is_none());
}
assert!(access_cooldown_remaining(&state, ip).is_none());
assert!(access_record_failure(&state, ip).is_some()); assert!(access_cooldown_remaining(&state, ip).is_some());
access_record_success(&state, ip);
assert!(access_cooldown_remaining(&state, ip).is_none());
}
#[test]
fn access_gated_workspace_recognizes_routes() {
assert_eq!(
access_gated_workspace("/abcd1234/doc.md").as_deref(),
Some("abcd1234")
);
assert_eq!(
access_gated_workspace("/api/chat/abcd1234/threads").as_deref(),
Some("abcd1234")
);
assert_eq!(
access_gated_workspace("/_/ws/abcd1234").as_deref(),
Some("abcd1234")
);
assert_eq!(
access_gated_workspace("/_/abcd1234/ws").as_deref(),
Some("abcd1234")
);
assert!(access_gated_workspace("/_/ws").is_none());
assert_eq!(
access_gated_workspace("/_/abcd1234/git/diff/work").as_deref(),
Some("abcd1234")
);
assert_eq!(
access_gated_workspace("/_/abcd1234/search").as_deref(),
Some("abcd1234")
);
for encoded in [
"/%61bcd1234/doc.md",
"/api/chat/%61bcd1234/threads",
"/_/ws/%61bcd1234",
"/_/%61bcd1234/ws",
] {
assert_eq!(
access_gated_workspace(encoded).as_deref(),
Some("abcd1234"),
"encoded workspace id escaped the gate: {encoded}"
);
}
assert!(access_gated_workspace("/_/%2Fbad123/ws").is_none());
assert!(access_gated_workspace("/_/css/tokens.css").is_none());
assert!(access_gated_workspace("/_/unlock").is_none());
assert!(access_gated_workspace("/api/preview").is_none());
assert!(access_gated_workspace("/favicon.ico").is_none());
}
#[tokio::test]
async fn workspace_ws_upgrade_requires_remote_access_cookie() {
let root = tempfile::tempdir().unwrap();
let registry = Arc::new(WorkspaceRegistry::new("ws-access-gate".into()));
let id = add_test_workspace(
®istry,
root.path().to_path_buf(),
WorkspaceFlags {
enable_live: true,
..Default::default()
},
);
let required_hash = "required-hash".to_string();
assert!(registry.set_collaborator_access_code(&id, &required_hash));
let state = test_state(registry);
let app = Router::new()
.route(WORKSPACE_WS_ROUTE, get(|| async { StatusCode::NO_CONTENT }))
.layer(axum::middleware::from_fn_with_state(
state.clone(),
require_access_code,
));
let request = axum::http::Request::builder()
.uri(format!("/_/{id}/ws"))
.header(header::UPGRADE, "websocket")
.header(header::ORIGIN, "http://markon.local")
.header(header::HOST, "markon.local")
.extension(axum::extract::ConnectInfo(lan_peer()))
.body(axum::body::Body::empty())
.unwrap();
let response = app.clone().oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
let encoded_id = format!("%{:02X}{}", id.as_bytes()[0], &id[1..]);
let encoded_request = axum::http::Request::builder()
.uri(format!("/_/{encoded_id}/ws"))
.header(header::UPGRADE, "websocket")
.header(header::ORIGIN, "http://markon.local")
.header(header::HOST, "markon.local")
.extension(axum::extract::ConnectInfo(lan_peer()))
.body(axum::body::Body::empty())
.unwrap();
let encoded_response = app.clone().oneshot(encoded_request).await.unwrap();
assert_eq!(encoded_response.status(), StatusCode::UNAUTHORIZED);
let cookie = make_access_cookie(
&state.access_secret,
&[(format!("w:{id}:collaborator"), required_hash)],
access_now_unix() + 60,
false,
);
let request = axum::http::Request::builder()
.uri(format!("/_/{id}/ws"))
.header(header::UPGRADE, "websocket")
.header(header::COOKIE, cookie)
.extension(axum::extract::ConnectInfo(lan_peer()))
.body(axum::body::Body::empty())
.unwrap();
let response = app.oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::NO_CONTENT);
}
#[tokio::test]
async fn workspace_ws_route_and_broadcast_are_actually_isolated() {
use tokio_tungstenite::tungstenite::{Error as WsError, Message as ClientMessage};
let root_a = tempfile::tempdir().unwrap();
let root_b = tempfile::tempdir().unwrap();
let registry = Arc::new(WorkspaceRegistry::new("ws-integration".into()));
let live_flags = WorkspaceFlags {
enable_live: true,
..Default::default()
};
let id_a = add_test_workspace(®istry, root_a.path().to_path_buf(), live_flags);
let id_b = add_test_workspace(®istry, root_b.path().to_path_buf(), live_flags);
let (addr, server) = spawn_collaboration_test_server(test_state(registry.clone())).await;
let (mut socket_a, _) =
tokio_tungstenite::connect_async(format!("ws://{addr}/_/{id_a}/ws"))
.await
.unwrap();
let (mut socket_a_other_channel, _) =
tokio_tungstenite::connect_async(format!("ws://{addr}/_/{id_a}/ws"))
.await
.unwrap();
let (mut socket_b, _) =
tokio_tungstenite::connect_async(format!("ws://{addr}/_/{id_b}/ws"))
.await
.unwrap();
socket_a
.send(ClientMessage::Text(
serde_json::json!({
"type": "hello",
"target": { "kind": "surface", "key": format!("/{id_a}/") }
})
.to_string()
.into(),
))
.await
.unwrap();
socket_a_other_channel
.send(ClientMessage::Text(
serde_json::json!({
"type": "hello",
"target": {
"kind": "surface",
"key": format!("/_/{id_a}/compare")
}
})
.to_string()
.into(),
))
.await
.unwrap();
socket_b
.send(ClientMessage::Text(
serde_json::json!({
"type": "hello",
"target": { "kind": "surface", "key": format!("/{id_b}/") }
})
.to_string()
.into(),
))
.await
.unwrap();
socket_a
.send(ClientMessage::Text(
r#"{"type":"live_action","data":{"action":"focus"}}"#.into(),
))
.await
.unwrap();
let echoed = tokio::time::timeout(std::time::Duration::from_secs(2), socket_a.next())
.await
.expect("same channel should receive live event")
.unwrap()
.unwrap();
assert!(echoed.to_text().unwrap().contains("live_action"));
assert!(tokio::time::timeout(
std::time::Duration::from_millis(150),
socket_a_other_channel.next()
)
.await
.is_err());
assert!(
tokio::time::timeout(std::time::Duration::from_millis(150), socket_b.next())
.await
.is_err()
);
let legacy = tokio_tungstenite::connect_async(format!("ws://{addr}/_/ws"))
.await
.unwrap_err();
assert!(
matches!(legacy, WsError::Http(response) if response.status() == StatusCode::NOT_FOUND)
);
assert!(registry.remove(&id_a));
let detached = tokio::time::timeout(std::time::Duration::from_secs(2), socket_a.next())
.await
.expect("detaching a workspace must close existing sockets");
assert!(matches!(
detached,
None | Some(Ok(ClientMessage::Close(_))) | Some(Err(_))
));
server.abort();
}
#[tokio::test]
async fn workspace_ws_handshake_rejects_workspace_without_features() {
use tokio_tungstenite::tungstenite::Error as WsError;
let root = tempfile::tempdir().unwrap();
let registry = Arc::new(WorkspaceRegistry::new("ws-disabled".into()));
let id = add_test_workspace(
®istry,
root.path().to_path_buf(),
WorkspaceFlags::default(),
);
let (addr, server) = spawn_collaboration_test_server(test_state(registry)).await;
let error = tokio_tungstenite::connect_async(format!("ws://{addr}/_/{id}/ws"))
.await
.unwrap_err();
assert!(
matches!(error, WsError::Http(response) if response.status() == StatusCode::FORBIDDEN)
);
server.abort();
}
#[tokio::test]
async fn live_only_document_receives_no_stored_data_and_rejects_foreign_path() {
use tokio_tungstenite::tungstenite::Message as ClientMessage;
let root_a = tempfile::tempdir().unwrap();
let root_b = tempfile::tempdir().unwrap();
let document_a = root_a.path().join("a.md");
let document_b = root_b.path().join("b.md");
fs::write(&document_a, "# a").unwrap();
fs::write(&document_b, "# b").unwrap();
let registry = Arc::new(WorkspaceRegistry::new("ws-live-only".into()));
let id_a = add_test_workspace(
®istry,
root_a.path().to_path_buf(),
WorkspaceFlags {
enable_live: true,
shared_annotation: false,
..Default::default()
},
);
let _id_b = add_test_workspace(®istry, root_b.path().to_path_buf(), all_flags());
let conn = Connection::open_in_memory().unwrap();
conn.execute(
"CREATE TABLE annotations (id TEXT PRIMARY KEY, file_path TEXT NOT NULL, data TEXT NOT NULL)",
[],
)
.unwrap();
conn.execute(
"CREATE TABLE viewed_state (file_path TEXT PRIMARY KEY, state TEXT NOT NULL, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)",
[],
)
.unwrap();
conn.execute(
"INSERT INTO annotations (id, file_path, data) VALUES ('secret', ?1, '{\"id\":\"secret\"}')",
[dunce::canonicalize(&document_a)
.unwrap()
.to_string_lossy()
.as_ref()],
)
.unwrap();
let db = Arc::new(Mutex::new(conn));
let mut state = test_state(registry);
state.db = Some(db.clone());
let (addr, server) = spawn_collaboration_test_server(state).await;
let (mut valid, _) = tokio_tungstenite::connect_async(format!("ws://{addr}/_/{id_a}/ws"))
.await
.unwrap();
valid
.send(ClientMessage::Text(
serde_json::json!({
"type": "hello",
"target": {
"kind": "document",
"path": document_a.to_string_lossy()
}
})
.to_string()
.into(),
))
.await
.unwrap();
assert!(
tokio::time::timeout(std::time::Duration::from_millis(150), valid.next())
.await
.is_err()
);
valid
.send(ClientMessage::Text(
r#"{"type":"live_action","data":{"action":"focus"}}"#.into(),
))
.await
.unwrap();
let live = tokio::time::timeout(std::time::Duration::from_secs(2), valid.next())
.await
.unwrap()
.unwrap()
.unwrap();
assert!(live.to_text().unwrap().contains("live_action"));
valid
.send(ClientMessage::Text(
r#"{"type":"new_annotation","annotation":{"id":"forbidden"}}"#.into(),
))
.await
.unwrap();
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
let forbidden_count: i64 = db
.lock()
.unwrap()
.query_row(
"SELECT COUNT(*) FROM annotations WHERE id = 'forbidden'",
[],
|row| row.get(0),
)
.unwrap();
assert_eq!(forbidden_count, 0);
let (mut foreign, _) = tokio_tungstenite::connect_async(format!("ws://{addr}/_/{id_a}/ws"))
.await
.unwrap();
foreign
.send(ClientMessage::Text(
serde_json::json!({
"type": "hello",
"target": {
"kind": "document",
"path": document_b.to_string_lossy()
}
})
.to_string()
.into(),
))
.await
.unwrap();
let closed = tokio::time::timeout(std::time::Duration::from_secs(2), foreign.next())
.await
.expect("foreign path must be rejected before any data is sent");
assert!(matches!(
closed,
None | Some(Ok(ClientMessage::Close(_))) | Some(Err(_))
));
server.abort();
}
#[test]
fn canonical_route_helpers_keep_file_and_tool_spaces_separate() {
assert_eq!(workspace_root_url("abcd1234"), "/abcd1234/");
assert_eq!(
workspace_file_url("abcd1234", "docs/readme.md"),
"/abcd1234/docs/readme.md"
);
assert_eq!(
workspace_file_url("abcd1234", "docs/a b#c?.md"),
"/abcd1234/docs/a%20b%23c%3F.md"
);
assert_eq!(
workspace_internal_url("abcd1234", "git/history"),
"/_/abcd1234/git/history"
);
assert_eq!(workspace_internal_url("abcd1234", "ws"), "/_/abcd1234/ws");
assert_eq!(
workspace_compare_base_url("abcd1234"),
"/_/abcd1234/compare"
);
}
#[test]
fn featured_base_url_loopback_and_specific_are_network_independent() {
assert_eq!(
featured_base_url("127.0.0.1", "", 6419),
"http://127.0.0.1:6419"
);
assert_eq!(
featured_base_url("198.51.100.7", "", 6419),
"http://198.51.100.7:6419"
);
let r = reachable_urls("127.0.0.1", "", 6419);
assert_eq!(r.all.len(), 1);
assert_eq!(r.featured, "http://127.0.0.1:6419");
}
#[cfg(target_os = "windows")]
#[test]
fn canonicalize_route_path_strips_windows_verbatim_prefix() {
let dir = tempfile::tempdir().unwrap();
let std_path = std::fs::canonicalize(dir.path()).unwrap();
let route_path = canonicalize_route_path(dir.path()).unwrap();
assert!(
std_path.to_string_lossy().starts_with(r"\\?\"),
"test expects Windows std::fs::canonicalize to return verbatim paths, got {std_path:?}"
);
assert!(
!route_path.to_string_lossy().starts_with(r"\\?\"),
"route canonicalization must match workspace roots stored through dunce: {route_path:?}"
);
}
#[tokio::test]
async fn workspace_path_handler_renders_markdown_inside_workspace() {
let dir = tempfile::tempdir().unwrap();
let docs = dir.path().join("docs");
fs::create_dir(&docs).unwrap();
let file = docs.join("EVDI_IMPLEMENTATION_PLAN.md");
fs::write(&file, "# Windows route check\n\nalpha beta gamma").unwrap();
let registry = Arc::new(WorkspaceRegistry::new("route-test".into()));
let id = add_test_workspace(®istry, dir.path().to_path_buf(), all_flags());
let state = test_state(registry);
let response = handle_workspace_path(
State(state),
AxumPath((id.clone(), "docs/EVDI_IMPLEMENTATION_PLAN.md".to_string())),
Some(Extension(AccessRole::Admin)),
axum::http::HeaderMap::new(),
)
.await
.into_response();
assert_eq!(response.status(), StatusCode::OK);
let body = html_escape::decode_html_entities(&response_text(response).await).to_string();
assert!(body.contains("Windows route check"));
assert!(body.contains("alpha beta gamma"));
assert!(body.contains("enable-edit"));
assert!(body.contains("enable-search"));
assert!(body.contains("window.MarkonTheme"));
assert!(body.contains("<title>EVDI_IMPLEMENTATION_PLAN.md</title>"));
assert!(!body.contains("<title>markon - EVDI_IMPLEMENTATION_PLAN.md</title>"));
assert!(body.contains(&format!("href=\"/{id}/#docs/EVDI_IMPLEMENTATION_PLAN.md\"")));
let root = canonicalize_route_path(dir.path()).unwrap();
let workspace_name = root
.file_name()
.map(|name| name.to_string_lossy().to_string())
.unwrap_or_default();
let workspace_path = workspace_display_path(&root);
assert!(body.contains(&format!(
"class=\"workspace-back-name\">{workspace_name}</span>"
)));
assert!(body.contains(&format!(
"class=\"workspace-back-path\">{workspace_path}</span>"
)));
assert!(!body.contains("id=\"back-link-text\""));
assert!(!body.contains(&format!("/_/{id}/git/history")));
}
#[tokio::test]
async fn workspace_path_handler_renders_text_file_as_content_only_view() {
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join("notes.txt"), "alpha\nbeta").unwrap();
let registry = Arc::new(WorkspaceRegistry::new("text-preview-test".into()));
let id = add_test_workspace(®istry, dir.path().to_path_buf(), all_flags());
let state = test_state(registry);
let response = handle_workspace_path(
State(state),
AxumPath((id, "notes.txt".to_string())),
Some(Extension(AccessRole::Admin)),
axum::http::HeaderMap::new(),
)
.await
.into_response();
assert_eq!(response.status(), StatusCode::OK);
let body = response_text(response).await;
assert!(body.contains("class=\"code-view\""), "{body}");
assert!(body.contains("alpha"), "{body}");
assert!(body.contains("beta"), "{body}");
assert!(!body.contains("class=\"fv-back\""), "{body}");
assert!(!body.contains("class=\"fv-head\""), "{body}");
assert!(!body.contains("Back to workspace"), "{body}");
assert!(!body.contains("notes.txt</span>"), "{body}");
}
#[tokio::test]
async fn workspace_path_handler_loads_math_assets_when_needed() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("README.md");
fs::write(&file, "Inline $E = mc^2$.\n\n$$\na^2 + b^2 = c^2\n$$").unwrap();
let registry = Arc::new(WorkspaceRegistry::new("math-assets-test".into()));
let id = add_test_workspace(®istry, dir.path().to_path_buf(), all_flags());
let state = test_state(registry);
let response = handle_workspace_path(
State(state),
AxumPath((id, "README.md".to_string())),
Some(Extension(AccessRole::Admin)),
axum::http::HeaderMap::new(),
)
.await
.into_response();
assert_eq!(response.status(), StatusCode::OK);
let body = response_text(response).await;
assert!(body.contains("/_/js/katex/katex.min.css"), "{body}");
assert!(body.contains("/_/js/katex/katex.min.js"), "{body}");
assert!(body.contains("/_/js/math-render.js"), "{body}");
assert!(body.contains("data-math-display=\"true\""), "{body}");
}
#[tokio::test]
async fn workspace_feature_controls_render_and_update_flags() {
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join("README.md"), "# Feature controls").unwrap();
let registry = Arc::new(WorkspaceRegistry::new("feature-controls-test".into()));
let id = add_test_workspace(
®istry,
dir.path().to_path_buf(),
WorkspaceFlags {
enable_search: true,
enable_viewed: false,
enable_edit: false,
enable_live: false,
enable_chat: false,
shared_annotation: false,
},
);
let state = test_state(registry.clone());
let response = handle_workspace_root(
State(state.clone()),
AxumPath(id.clone()),
Some(Extension(AccessRole::Admin)),
)
.await
.into_response();
assert_eq!(response.status(), StatusCode::OK);
let body = html_escape::decode_html_entities(&response_text(response).await).to_string();
assert!(body.contains("data-workspace-feature-form"));
assert!(body.contains(&format!(r#"data-update-url="/_/{id}/settings/features""#)));
assert!(body.contains(r#"data-feature-key="enable_search""#));
assert!(body.contains(r#"type="checkbox""#));
let next_flags = WorkspaceFlags {
enable_search: false,
enable_viewed: true,
enable_edit: true,
enable_live: true,
enable_chat: true,
shared_annotation: true,
};
let response = handle_workspace_update_features(
State(state.clone()),
AxumPath(id.clone()),
Json(UpdateWorkspaceFeaturesRequest { flags: next_flags }),
)
.await;
assert_eq!(response.status(), StatusCode::OK);
let body: serde_json::Value = serde_json::from_str(&response_text(response).await).unwrap();
assert_eq!(body["success"], true);
assert_eq!(registry.get(&id).unwrap().flags(), next_flags);
let response = handle_workspace_root(
State(state),
AxumPath(id),
Some(Extension(AccessRole::Collaborator)),
)
.await
.into_response();
assert_eq!(response.status(), StatusCode::OK);
let body = response_text(response).await;
assert!(body.contains(r#"data-can-edit="false""#));
assert!(body.contains("disabled"));
}
#[tokio::test]
async fn remote_collaborator_editor_and_chat_follow_flags() {
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join("README.md"), "# hi\n\nbody\n").unwrap();
let reg_on = Arc::new(WorkspaceRegistry::new("remote-flags-on".into()));
let id_on = add_test_workspace(
®_on,
dir.path().to_path_buf(),
WorkspaceFlags {
enable_edit: true,
enable_chat: true,
..Default::default()
},
);
let on = response_text(
handle_workspace_path(
State(test_state(reg_on)),
AxumPath((id_on, "README.md".to_string())),
Some(Extension(AccessRole::Collaborator)),
axum::http::HeaderMap::new(),
)
.await
.into_response(),
)
.await;
assert!(
on.contains(r#"<meta name="enable-edit" content="true">"#),
"{on}"
);
assert!(
on.contains(r#"name="save-token""#),
"remote collaborator with the edit flag must receive the save token"
);
assert!(on.contains(r#"<meta name="enable-chat" content="true">"#));
let reg_off = Arc::new(WorkspaceRegistry::new("remote-flags-off".into()));
let id_off = add_test_workspace(
®_off,
dir.path().to_path_buf(),
WorkspaceFlags::default(),
);
let off = response_text(
handle_workspace_path(
State(test_state(reg_off)),
AxumPath((id_off, "README.md".to_string())),
Some(Extension(AccessRole::Collaborator)),
axum::http::HeaderMap::new(),
)
.await
.into_response(),
)
.await;
assert!(off.contains(r#"<meta name="enable-edit" content="false">"#));
assert!(
!off.contains(r#"name="save-token""#),
"no edit flag → no save token even on a readable page"
);
assert!(off.contains(r#"<meta name="enable-chat" content="false">"#));
}
#[tokio::test]
async fn dist_asset_route_uses_extension_mime_type() {
let response = serve_js(AxumPath("katex/katex.min.css".into()))
.await
.into_response();
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response.headers().get(header::CONTENT_TYPE).unwrap(),
"text/css"
);
}
#[test]
fn unified_diff_html_renders_word_highlights_and_escapes_text() {
let html = render_unified_diff_html(
"--- a.md\n+++ a.md\n@@ -1 +1 @@\n-old price <b>\n+new price <b>\n",
);
assert!(html.contains("git-diff-meta"));
assert!(html.contains("git-diff-word-del"));
assert!(html.contains("git-diff-word-add"));
assert!(html.contains("<b>"));
assert!(!html.contains("<b>"));
}
#[test]
fn pretty_compare_range_accepts_slash_refs() {
let (base, compare) =
parse_pretty_compare_range("main...feat/wasm-ref-test-backend").unwrap();
assert_eq!(base, "main");
assert_eq!(compare, "feat/wasm-ref-test-backend");
}
#[tokio::test]
async fn directory_git_diff_actions_disable_without_markdown_changes() {
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join("a.md"), "# Notes\n").unwrap();
std::process::Command::new("git")
.arg("-C")
.arg(dir.path())
.arg("init")
.output()
.unwrap();
std::process::Command::new("git")
.arg("-C")
.arg(dir.path())
.args(["config", "user.email", "test@example.com"])
.output()
.unwrap();
std::process::Command::new("git")
.arg("-C")
.arg(dir.path())
.args(["config", "user.name", "Test User"])
.output()
.unwrap();
std::process::Command::new("git")
.arg("-C")
.arg(dir.path())
.args(["add", "."])
.output()
.unwrap();
std::process::Command::new("git")
.arg("-C")
.arg(dir.path())
.args(["commit", "-m", "initial markdown"])
.output()
.unwrap();
fs::write(dir.path().join("notes.txt"), "Not markdown\n").unwrap();
std::process::Command::new("git")
.arg("-C")
.arg(dir.path())
.args(["add", "."])
.output()
.unwrap();
std::process::Command::new("git")
.arg("-C")
.arg(dir.path())
.args(["commit", "-m", "txt only"])
.output()
.unwrap();
let registry = Arc::new(WorkspaceRegistry::new("git-md-actions-test".into()));
let id = add_test_workspace(®istry, dir.path().to_path_buf(), all_flags());
let state = test_state(registry);
let response = handle_workspace_root(
State(state),
AxumPath(id.clone()),
Some(Extension(AccessRole::Admin)),
)
.await
.into_response();
assert_eq!(response.status(), StatusCode::OK);
let body = html_escape::decode_html_entities(&response_text(response).await).to_string();
assert!(body.contains("txt only"));
assert!(body.contains("workspace-action-disabled"));
assert!(!body.contains(&format!("/_/{id}/git/show/")));
assert!(!body.contains(&format!("/_/{id}/git/diff/work?view=rendered")));
assert!(!body.contains(&format!("/_/{id}/compare/")));
assert!(!body.contains(">Markdown diff<"));
assert!(body.contains(&format!("/_/{id}/git/history")));
}
#[tokio::test]
async fn non_git_workspace_hides_git_ui_and_git_routes_return_text() {
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join("README.md"), "# Notes\n").unwrap();
let registry = Arc::new(WorkspaceRegistry::new("non-git-workspace-test".into()));
let id = add_test_workspace(®istry, dir.path().to_path_buf(), all_flags());
let state = test_state(registry);
let response = handle_workspace_root(
State(state.clone()),
AxumPath(id.clone()),
Some(Extension(AccessRole::Admin)),
)
.await
.into_response();
assert_eq!(response.status(), StatusCode::OK);
let body = html_escape::decode_html_entities(&response_text(response).await).to_string();
assert!(body.contains("workspace-shell--no-git"));
assert!(body.contains("workspace-repo-toolbar--plain"));
assert!(body.contains("data-workspace-spotlight-trigger"));
assert!(!body.contains("workspace-file-kicker"));
assert!(!body.contains(r#"<div class="workspace-ref-menu""#));
assert!(!body.contains(r#"<a class="workspace-ref-link workspace-secondary-link""#));
assert!(!body.contains(r#"data-branch-panel"#));
assert!(!body.contains(r#"<article class="workspace-change-primary""#));
assert!(!body.contains(r#"<h2 class="workspace-section-title" data-i18n="web.ws.changes""#));
assert!(!body.contains(r#"data-i18n="web.ws.git.non_repo""#));
assert!(!body.contains(&format!(r#"href="/_/{id}/git/branches""#)));
assert!(!body.contains(&format!(r#"href="/_/{id}/git/tags""#)));
assert!(!body.contains(&format!(r#"href="/_/{id}/git/history""#)));
let diff = handle_git_working_diff(
State(state.clone()),
AxumPath(id.clone()),
Query(GitViewQuery {
view: Some("rendered".into()),
f: None,
}),
Some(Extension(AccessRole::Admin)),
)
.await
.into_response();
assert_eq!(diff.status(), StatusCode::CONFLICT);
assert_eq!(
response_text(diff).await,
"Workspace is not a git repository"
);
let compare = handle_pretty_compare_diff(
State(state.clone()),
AxumPath((id.clone(), "HEAD...worktree".into())),
Query(PrettyCompareQuery {
view: Some("rendered".into()),
format: None,
f: None,
}),
Some(Extension(AccessRole::Admin)),
)
.await
.into_response();
assert_eq!(compare.status(), StatusCode::CONFLICT);
assert_eq!(
response_text(compare).await,
"Workspace is not a git repository"
);
let options = handle_git_compare_options_status(
State(state),
AxumPath(id),
Query(GitCompareOptionsStatusQuery {
base: "HEAD".into(),
compare: "worktree".into(),
}),
)
.await
.into_response();
assert_eq!(options.status(), StatusCode::CONFLICT);
assert_eq!(
response_text(options).await,
"Workspace is not a git repository"
);
}
#[tokio::test]
async fn git_history_and_working_diff_pages_render() {
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join("a.md"), "# Old\n").unwrap();
std::process::Command::new("git")
.arg("-C")
.arg(dir.path())
.arg("init")
.output()
.unwrap();
std::process::Command::new("git")
.arg("-C")
.arg(dir.path())
.args(["config", "user.email", "test@example.com"])
.output()
.unwrap();
std::process::Command::new("git")
.arg("-C")
.arg(dir.path())
.args(["config", "user.name", "Test User"])
.output()
.unwrap();
std::process::Command::new("git")
.arg("-C")
.arg(dir.path())
.args(["add", "."])
.output()
.unwrap();
std::process::Command::new("git")
.arg("-C")
.arg(dir.path())
.args(["commit", "-m", "initial"])
.output()
.unwrap();
fs::write(dir.path().join("a.md"), "# New\n").unwrap();
fs::write(dir.path().join("notes.txt"), "Not markdown\n").unwrap();
let registry = Arc::new(WorkspaceRegistry::new("git-web-test".into()));
let id = add_test_workspace(®istry, dir.path().to_path_buf(), all_flags());
let state = test_state(registry);
let root = handle_workspace_root(
State(state.clone()),
AxumPath(id.clone()),
Some(Extension(AccessRole::Admin)),
)
.await
.into_response();
assert_eq!(root.status(), StatusCode::OK);
let body = html_escape::decode_html_entities(&response_text(root).await).to_string();
assert!(body.contains("workspace-shell"));
assert!(body.contains("workspace-meta-panel"));
assert!(body.contains("workspace-side-section"));
assert!(body.contains("workspace-repo-toolbar"));
assert!(body.contains("data-workspace-spotlight-trigger"));
assert!(body.contains("data-open-add-file"));
assert!(body.contains("data-checkout-url"));
assert!(body.contains(&format!("/_/{id}/git/branches")));
assert!(body.contains(&format!("/_/{id}/git/tags")));
assert!(body.contains("workspace-commit-header"));
assert!(body.contains("workspace-commits-link"));
assert!(body.contains("workspace-entry-commit"));
assert!(body.contains("data-copy-text"));
assert!(body.contains("Workspace changes"));
assert!(body.contains("initial"));
assert!(body.contains("1 Commits"));
assert!(!body.contains("workspace-topbar"));
assert!(!body.contains("workspace-side-card"));
assert!(!body.contains("workspace-tabs"));
assert!(!body.contains("data-inline-diff"));
assert!(body.contains(&format!("/_/{id}/compare/")));
assert!(body.contains("?view=rendered"));
assert!(body.contains(&format!("/_/{id}/compare/HEAD...worktree?view=rendered")));
assert!(!body.contains(&format!("/_/{id}/git/show/")));
assert!(!body.contains(&format!("/_/{id}/git/diff/work?view=rendered")));
assert!(!body.contains(">Markdown diff<"));
assert!(!body.contains("Snapshot"));
let diff = handle_git_working_diff(
State(state.clone()),
AxumPath(id.clone()),
Query(GitViewQuery {
view: None,
f: None,
}),
Some(Extension(AccessRole::Admin)),
)
.await
.into_response();
assert_eq!(diff.status(), StatusCode::OK);
let body = html_escape::decode_html_entities(&response_text(diff).await).to_string();
assert!(body.contains("Markdiff"));
assert!(body.contains("git-diff-sidebar"));
assert!(body.contains("git-source-diff-pane"));
assert!(body.contains("data-diff-filter"));
assert!(body.contains("data-diff-file-list"));
assert!(body.contains("Markdiff"));
assert!(body.contains(">Base<"));
assert!(body.contains(">Compare<"));
assert!(!body.contains("data-md-engine-status"));
assert!(!body.contains("git-diff-compare-submit"));
assert!(body.contains("data-compare-status-url"));
assert!(body.contains("data-compare-picker"));
assert!(body.contains("a.md"));
assert!(!body.contains("notes.txt"));
assert!(body.contains("data-virtual-diff"));
assert!(body.contains("data-current-diff-view=\"rendered\""));
assert!(body.contains("git-diff-view-seg"));
assert!(body.contains("data-diff-view-seg"));
assert!(body.contains("data-markdown-diff"));
assert!(body.contains("data-md-diff-content"));
assert!(!body.contains("data-md-old-content"));
assert!(!body.contains("data-md-new-content"));
assert!(!body.contains("data-markon-interactive-body"));
assert!(!body.contains("/_/js/main.js"));
assert!(body.contains("/_/js/workspace-diff.js"));
assert!(body.contains("/_/js/markdown-diff.js"));
assert!(!body.contains(&format!(
"/_/{id}/compare/HEAD...worktree?view=raw&format=data"
)));
assert!(body.contains(&format!("/_/{id}/compare/HEAD...worktree?view=rendered")));
assert!(!body.contains("md-diff-shell"));
assert!(!body.contains("html_diff"));
let compare = handle_pretty_compare_diff(
State(state.clone()),
AxumPath((id.clone(), "HEAD...worktree".to_string())),
Query(PrettyCompareQuery {
view: None,
format: None,
f: None,
}),
Some(Extension(AccessRole::Admin)),
)
.await
.into_response();
assert_eq!(compare.status(), StatusCode::OK);
let body = html_escape::decode_html_entities(&response_text(compare).await).to_string();
assert!(body.contains(&format!("/_/{id}/compare/HEAD...worktree?view=raw")));
assert!(body.contains("data-compare-trigger"));
assert!(body.contains("data-compare-picker"));
assert!(body.contains("Worktree"));
assert!(body.contains("\"alias\":\"Latest\""));
let compare_data = handle_pretty_compare_diff(
State(state.clone()),
AxumPath((id.clone(), "HEAD...worktree".to_string())),
Query(PrettyCompareQuery {
view: Some("raw".to_string()),
format: Some("data".to_string()),
f: None,
}),
Some(Extension(AccessRole::Admin)),
)
.await
.into_response();
assert_eq!(compare_data.status(), StatusCode::OK);
let body = response_text(compare_data).await;
assert!(body.contains("\"range\":\"HEAD..worktree\""));
assert!(body.contains("\"path\":\"a.md\""));
assert!(!body.contains("notes.txt"));
let diff_data = handle_git_working_diff_data(
State(state.clone()),
AxumPath(id.clone()),
Query(GitViewQuery {
view: Some("raw".to_string()),
f: None,
}),
)
.await
.into_response();
assert_eq!(diff_data.status(), StatusCode::OK);
let body = response_text(diff_data).await;
assert!(body.contains("\"title\":\"Working tree diff\""));
assert!(body.contains("\"path\":\"a.md\""));
assert!(!body.contains("notes.txt"));
assert!(body.contains("\"rows\""));
assert!(body.contains("git-diff-add"));
let diff_json: serde_json::Value = serde_json::from_str(&body).unwrap();
let split_line = diff_json["rows"]
.as_array()
.unwrap()
.iter()
.find(|row| {
row["kind"] == "line"
&& row
.get("old_class_name")
.is_some_and(|class| class.as_str().unwrap_or("").contains("git-diff-del"))
})
.unwrap();
assert!(split_line.get("old_line_no").is_some());
assert!(split_line.get("new_line_no").is_some());
assert!(split_line.get("old_segments").is_some());
assert!(split_line.get("new_segments").is_some());
assert!(!body.contains("html_diff"));
let filtered_diff_data = handle_git_working_diff_data(
State(state.clone()),
AxumPath(id.clone()),
Query(GitViewQuery {
view: None,
f: Some("missing.md".to_string()),
}),
)
.await
.into_response();
assert_eq!(filtered_diff_data.status(), StatusCode::OK);
let body = response_text(filtered_diff_data).await;
assert!(body.contains(r#""files":[]"#));
assert!(!body.contains("\"path\":\"a.md\""));
let markdown_diff = handle_git_working_diff(
State(state.clone()),
AxumPath(id.clone()),
Query(GitViewQuery {
view: Some("rendered".to_string()),
f: None,
}),
Some(Extension(AccessRole::Admin)),
)
.await
.into_response();
assert_eq!(markdown_diff.status(), StatusCode::OK);
let body =
html_escape::decode_html_entities(&response_text(markdown_diff).await).to_string();
assert!(!body.contains("__markon_diff__"));
assert!(!body.contains(r#"name="file-path""#));
assert!(body.contains(r#"name="is-worktree-diff" content="true""#));
assert!(body.contains(r#"name="shared-annotation" content="true""#));
assert!(body.contains(r#"name="enable-edit" content="false""#));
assert!(body.contains("git-diff-page"));
assert!(body.contains("git-diff-sidebar"));
assert!(body.contains("git-diff-view-seg"));
assert!(body.contains("data-markdown-diff"));
assert!(body.contains("data-md-diff-content"));
assert!(body.contains(r#"data-default-diff-path="""#));
assert!(!body.contains("data-md-old-content"));
assert!(!body.contains("data-md-new-content"));
assert!(!body.contains("data-markon-interactive-body"));
assert!(body.contains("/_/js/diff-ref-picker.js"));
assert!(body.contains("/_/css/editor.css"));
assert!(!body.contains("/_/js/main.js"));
assert!(body.contains("/_/js/markdown-diff.js"));
assert!(body.contains(&format!(
"/_/{id}/compare/HEAD...worktree?view=rendered&format=data"
)));
assert!(!body.contains("markdown-diff/data/work"));
assert!(!body.contains("md-diff-shell"));
assert!(!body.contains("md-diff-source"));
assert!(!body.contains("Open source diff"));
let markdown_work_data = handle_git_working_diff_data(
State(state.clone()),
AxumPath(id.clone()),
Query(GitViewQuery {
view: Some("rendered".to_string()),
f: None,
}),
)
.await
.into_response();
assert_eq!(markdown_work_data.status(), StatusCode::OK);
let body = response_text(markdown_work_data).await;
assert!(body.contains("\"title\":\"Compare HEAD and worktree\""));
assert!(body.contains("\"path\":\"a.md\""));
let filtered_markdown_work_data = handle_git_working_diff_data(
State(state.clone()),
AxumPath(id.clone()),
Query(GitViewQuery {
view: Some("rendered".to_string()),
f: Some("missing.md".to_string()),
}),
)
.await
.into_response();
assert_eq!(filtered_markdown_work_data.status(), StatusCode::OK);
let body = response_text(filtered_markdown_work_data).await;
assert!(body.contains(r#""files":[]"#));
assert!(!body.contains("\"path\":\"a.md\""));
let markdown_compare = handle_pretty_compare_diff(
State(state.clone()),
AxumPath((id.clone(), "HEAD...worktree".to_string())),
Query(PrettyCompareQuery {
view: Some("rendered".to_string()),
format: None,
f: None,
}),
Some(Extension(AccessRole::Admin)),
)
.await
.into_response();
assert_eq!(markdown_compare.status(), StatusCode::OK);
let body =
html_escape::decode_html_entities(&response_text(markdown_compare).await).to_string();
assert!(body.contains(&format!("/_/{id}/compare/HEAD...worktree?view=rendered")));
assert!(body.contains("data-compare-trigger"));
assert!(!body.contains(&format!("/_/{id}/git/diff/work")));
let markdown_compare_data = handle_pretty_compare_diff(
State(state.clone()),
AxumPath((id.clone(), "HEAD...worktree".to_string())),
Query(PrettyCompareQuery {
view: Some("rendered".to_string()),
format: Some("data".to_string()),
f: None,
}),
Some(Extension(AccessRole::Admin)),
)
.await
.into_response();
assert_eq!(markdown_compare_data.status(), StatusCode::OK);
let body = response_text(markdown_compare_data).await;
assert!(body.contains("\"title\":\"Compare HEAD and worktree\""));
assert!(body.contains("\"path\":\"a.md\""));
let history = handle_git_history(
State(state.clone()),
AxumPath(id.clone()),
Query(GitHistoryQuery {
branch: None,
author: None,
range: None,
}),
)
.await
.into_response();
assert_eq!(history.status(), StatusCode::OK);
let body = html_escape::decode_html_entities(&response_text(history).await).to_string();
assert!(body.contains("markon git history"));
assert!(body.contains("web.ws.git.commits"));
assert!(body.contains("initial"));
assert!(body.contains(&format!("/_/{id}/compare/")));
assert!(body.contains(&format!("/_/{id}/compare/HEAD...worktree?view=rendered")));
assert!(!body.contains(&format!("/_/{id}/git/show/")));
assert!(!body.contains(&format!("/_/{id}/git/diff/work")));
let history_data = handle_git_history_data(State(state.clone()), AxumPath(id.clone()))
.await
.into_response();
assert_eq!(history_data.status(), StatusCode::OK);
let body = response_text(history_data).await;
assert!(body.contains("\"subject\":\"initial\""));
let branches = handle_git_branches(State(state.clone()), AxumPath(id.clone()))
.await
.into_response();
assert_eq!(branches.status(), StatusCode::OK);
let body = response_text(branches).await;
assert!(body.contains("refs-title"));
assert!(body.contains("web.ws.git.branches"));
let tags = handle_git_tags(State(state), AxumPath(id.clone()))
.await
.into_response();
assert_eq!(tags.status(), StatusCode::OK);
let body = response_text(tags).await;
assert!(body.contains("refs-title"));
assert!(body.contains("web.ws.git.tags"));
}
#[test]
fn rendered_markdown_diff_cache_hits_and_invalidates_worktree_content() {
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join("a.md"), "# Title\n\nOld body\n").unwrap();
std::process::Command::new("git")
.arg("-C")
.arg(dir.path())
.arg("init")
.output()
.unwrap();
std::process::Command::new("git")
.arg("-C")
.arg(dir.path())
.args(["config", "user.email", "test@example.com"])
.output()
.unwrap();
std::process::Command::new("git")
.arg("-C")
.arg(dir.path())
.args(["config", "user.name", "Test User"])
.output()
.unwrap();
std::process::Command::new("git")
.arg("-C")
.arg(dir.path())
.args(["add", "."])
.output()
.unwrap();
std::process::Command::new("git")
.arg("-C")
.arg(dir.path())
.args(["commit", "-m", "initial"])
.output()
.unwrap();
fs::write(dir.path().join("a.md"), "# Title\n\nNew body\n").unwrap();
let registry = Arc::new(WorkspaceRegistry::new("git-cache-test".into()));
let state = test_state(registry);
let workspace_fs = WorkspaceFs::new(dir.path().to_path_buf(), None);
let first = markdown_compare_diff_data(
&state,
"git-cache-test",
&workspace_fs,
"HEAD",
"worktree",
Some("a.md"),
)
.unwrap();
assert_eq!(first.files.len(), 1);
let expected_abs = canonicalize_route_path(&dir.path().join("a.md"))
.unwrap()
.to_string_lossy()
.into_owned();
assert_eq!(first.files[0].abs_path, expected_abs);
assert!(serde_json::to_string(&first).unwrap().contains("New body"));
let first_stats = state.markdown_diff_cache.lock().unwrap().stats();
assert_eq!(first_stats.file_hits, 0);
assert_eq!(first_stats.file_misses, 1);
assert_eq!(first_stats.document_hits, 0);
assert_eq!(first_stats.document_misses, 2);
assert_eq!(first_stats.document_entries, 2);
assert_eq!(first_stats.file_entries, 1);
let second = markdown_compare_diff_data(
&state,
"git-cache-test",
&workspace_fs,
"HEAD",
"worktree",
Some("a.md"),
)
.unwrap();
assert_eq!(second.files.len(), 1);
let second_stats = state.markdown_diff_cache.lock().unwrap().stats();
assert_eq!(second_stats.file_hits, 1);
assert_eq!(second_stats.file_misses, 1);
assert_eq!(second_stats.document_hits, 0);
assert_eq!(second_stats.document_misses, 2);
fs::write(dir.path().join("a.md"), "# Title\n\nNewest body\n").unwrap();
let third = markdown_compare_diff_data(
&state,
"git-cache-test",
&workspace_fs,
"HEAD",
"worktree",
Some("a.md"),
)
.unwrap();
let third_json = serde_json::to_string(&third).unwrap();
assert!(third_json.contains("Newest body"));
assert!(!third_json.contains("New body\\n"));
let third_stats = state.markdown_diff_cache.lock().unwrap().stats();
assert_eq!(third_stats.file_hits, 1);
assert_eq!(third_stats.file_misses, 2);
assert_eq!(third_stats.document_hits, 1);
assert_eq!(third_stats.document_misses, 3);
assert_eq!(third_stats.document_entries, 3);
assert_eq!(third_stats.file_entries, 2);
}
#[test]
fn rendered_markdown_diff_rewrites_relative_assets_per_file_path() {
let dir = tempfile::tempdir().unwrap();
for folder in ["docs", "guide"] {
let asset_dir = dir.path().join(folder).join("images");
fs::create_dir_all(&asset_dir).unwrap();
fs::write(asset_dir.join("pic one.png"), b"png").unwrap();
fs::write(
dir.path().join(folder).join("page.md"),
"# Title\n\n\n\nOld body\n",
)
.unwrap();
}
let git = |args: &[&str]| {
let output = std::process::Command::new("git")
.arg("-C")
.arg(dir.path())
.args(args)
.output()
.unwrap();
assert!(output.status.success(), "git {:?} failed", args);
};
git(&["init"]);
git(&["config", "user.email", "test@example.com"]);
git(&["config", "user.name", "Test User"]);
git(&["add", "."]);
git(&["commit", "-m", "initial"]);
for folder in ["docs", "guide"] {
fs::write(
dir.path().join(folder).join("page.md"),
"# Title\n\n\n\nNew body\n",
)
.unwrap();
}
let registry = Arc::new(WorkspaceRegistry::new("asset-diff-test".into()));
let state = test_state(registry);
let workspace_fs = WorkspaceFs::new(dir.path().to_path_buf(), None);
let data =
markdown_compare_diff_data(&state, "asset-ws", &workspace_fs, "HEAD", "worktree", None)
.unwrap();
for folder in ["docs", "guide"] {
let path = format!("{folder}/page.md");
let file = data
.files
.iter()
.find(|file| file.path == path)
.unwrap_or_else(|| panic!("missing rendered diff for {path}"));
let html = file
.blocks
.iter()
.flat_map(|block| block.old.iter().chain(block.new.iter()))
.map(|block| block.html.as_str())
.collect::<String>();
assert!(
html.contains(&format!("src=\"/asset-ws/{folder}/images/pic%20one.png\"")),
"unexpected rendered asset HTML: {html}",
);
assert!(!html.contains("src=\"images/pic%20one.png\""));
}
}
#[test]
fn rendered_markdown_diff_abs_path_matches_normal_view_in_repo_subdir() {
let repo = tempfile::tempdir().unwrap();
let git_init = |args: &[&str]| {
std::process::Command::new("git")
.arg("-C")
.arg(repo.path())
.args(args)
.output()
.unwrap();
};
let ws_root = repo.path().join("docs");
fs::create_dir_all(&ws_root).unwrap();
fs::write(ws_root.join("tracked.md"), "# Title\n\nOld body\n").unwrap();
git_init(&["init"]);
git_init(&["config", "user.email", "test@example.com"]);
git_init(&["config", "user.name", "Test User"]);
git_init(&["add", "."]);
git_init(&["commit", "-m", "initial"]);
fs::write(ws_root.join("tracked.md"), "# Title\n\nNew body\n").unwrap();
fs::write(ws_root.join("untracked.md"), "# Fresh\n\nBrand new\n").unwrap();
let registry = Arc::new(WorkspaceRegistry::new("subdir-abs-test".into()));
let state = test_state(registry);
let workspace_fs = WorkspaceFs::new(ws_root.clone(), None);
let data = markdown_compare_diff_data(
&state,
"subdir-abs-test",
&workspace_fs,
"HEAD",
"worktree",
None,
)
.unwrap();
assert_eq!(data.files.len(), 2, "expected tracked + untracked file");
for rel in ["tracked.md", "untracked.md"] {
let file = data
.files
.iter()
.find(|f| f.path == rel)
.unwrap_or_else(|| {
panic!(
"missing diff entry for {rel}; got {:?}",
data.files
.iter()
.map(|f| f.path.clone())
.collect::<Vec<_>>()
)
});
let expected = canonicalize_route_path(&ws_root.join(rel))
.unwrap()
.to_string_lossy()
.into_owned();
assert_eq!(
file.abs_path, expected,
"abs_path for {rel} must match the normal-view key byte-for-byte"
);
}
let json = serde_json::to_string(&data).unwrap();
assert!(
json.contains("New body"),
"tracked new side must load in a subdir workspace"
);
assert!(json.contains("Brand new"), "untracked new side must load");
}
#[cfg(unix)]
#[test]
fn rendered_markdown_diff_does_not_read_untracked_outside_symlink() {
use std::os::unix::fs::symlink;
let repo = tempfile::tempdir().unwrap();
let outside = tempfile::NamedTempFile::new().unwrap();
fs::write(outside.path(), "OUTSIDE SECRET MARKER").unwrap();
fs::write(repo.path().join("README.md"), "# tracked").unwrap();
let git = |args: &[&str]| {
let output = std::process::Command::new("git")
.arg("-C")
.arg(repo.path())
.args(args)
.output()
.unwrap();
assert!(output.status.success(), "git {args:?} failed");
};
git(&["init"]);
git(&["config", "user.email", "test@example.com"]);
git(&["config", "user.name", "Test User"]);
git(&["add", "README.md"]);
git(&["commit", "-m", "initial"]);
symlink(outside.path(), repo.path().join("leak.md")).unwrap();
let state = test_state(Arc::new(WorkspaceRegistry::new("symlink-diff-test".into())));
let workspace_fs = WorkspaceFs::new(repo.path().to_path_buf(), None);
let raw = git::working_diff(&workspace_fs).unwrap();
assert!(!raw.patch.contains("OUTSIDE SECRET MARKER"));
let compare = git::compare_diff(&workspace_fs, "HEAD", "worktree").unwrap();
assert!(!compare.patch.contains("OUTSIDE SECRET MARKER"));
let data = markdown_compare_diff_data(
&state,
"symlink-diff-test",
&workspace_fs,
"HEAD",
"worktree",
None,
)
.unwrap();
let entry = data
.files
.iter()
.find(|file| file.path == "leak.md")
.expect("untracked markdown symlink must exercise the diff path");
assert!(entry.new_source.is_none());
assert_eq!(entry.additions, 0);
assert!(!serde_json::to_string(&data)
.unwrap()
.contains("OUTSIDE SECRET MARKER"));
}
#[tokio::test]
async fn workspace_path_handler_rejects_parent_traversal() {
let dir = tempfile::tempdir().unwrap();
let outside = tempfile::NamedTempFile::new().unwrap();
fs::write(outside.path(), "# outside").unwrap();
let registry = Arc::new(WorkspaceRegistry::new("traversal-test".into()));
let id = add_test_workspace(
®istry,
dir.path().to_path_buf(),
WorkspaceFlags::default(),
);
let state = test_state(registry);
let outside_name = outside.path().file_name().unwrap().to_string_lossy();
let route = format!("../{outside_name}");
let response = handle_workspace_path(
State(state),
AxumPath((id, route)),
Some(Extension(AccessRole::Admin)),
axum::http::HeaderMap::new(),
)
.await
.into_response();
assert_eq!(response.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn directory_listing_uses_workspace_relative_links() {
let dir = tempfile::tempdir().unwrap();
let sub = dir.path().join("sub");
fs::create_dir(&sub).unwrap();
fs::write(sub.join("README.md"), "# nested").unwrap();
fs::write(sub.join("notes.txt"), "listed in all-files mode").unwrap();
fs::write(sub.join(".env"), "hidden but listed in all-files mode").unwrap();
let registry = Arc::new(WorkspaceRegistry::new("listing-test".into()));
let id = add_test_workspace(
®istry,
dir.path().to_path_buf(),
WorkspaceFlags::default(),
);
let state = test_state(registry);
let response = handle_workspace_path(
State(state.clone()),
AxumPath((id.clone(), "sub/".into())),
Some(Extension(AccessRole::Admin)),
axum::http::HeaderMap::new(),
)
.await
.into_response();
assert_eq!(response.status(), StatusCode::SEE_OTHER);
let location = response
.headers()
.get(axum::http::header::LOCATION)
.and_then(|value| value.to_str().ok())
.unwrap_or_default()
.to_string();
assert_eq!(location, format!("/{id}/#sub/"));
let response = handle_workspace_root(
State(state),
AxumPath(id.clone()),
Some(Extension(AccessRole::Admin)),
)
.await
.into_response();
assert_eq!(response.status(), StatusCode::OK);
let body = html_escape::decode_html_entities(&response_text(response).await).to_string();
assert!(body.contains("data-file-filter=\"markdown\""));
assert!(body.contains("data-entry-kind=\"dir\""));
assert!(body.contains("data-entry-path=\"sub\""));
assert!(body.contains("data-dir-link=\"/"));
assert!(body.contains(&format!("/{id}/sub/")));
assert!(body.contains("data-filter-visible-markdown=\"true\""));
assert!(!body.contains(&format!("/_/{id}/git/history")));
}
#[test]
fn directory_markdown_filter_keeps_only_markdown_files_and_parent_dirs() {
let dir = tempfile::tempdir().unwrap();
let docs_nested = dir.path().join("docs").join("guides");
let src = dir.path().join("src");
let empty = dir.path().join("empty");
fs::create_dir_all(&docs_nested).unwrap();
fs::create_dir(&src).unwrap();
fs::create_dir(&empty).unwrap();
fs::write(docs_nested.join("intro.md"), "# nested").unwrap();
fs::write(src.join("main.rs"), "fn main() {}\n").unwrap();
fs::write(empty.join("notes.txt"), "not markdown").unwrap();
fs::write(dir.path().join("README.md"), "# root").unwrap();
fs::write(dir.path().join("Cargo.toml"), "[package]\n").unwrap();
let root = dunce::canonicalize(dir.path()).unwrap();
let entries = collect_directory_entries("ws", &root, &root).unwrap();
let shown = |name: &str| -> bool {
entries
.iter()
.find(|entry| entry.name == name)
.unwrap_or_else(|| panic!("missing directory entry {name}"))
.show_in_markdown
};
assert!(shown("docs"));
assert!(shown("README.md"));
assert!(!shown("src"));
assert!(!shown("empty"));
assert!(!shown("Cargo.toml"));
}
#[tokio::test]
async fn save_file_handler_writes_relative_and_absolute_workspace_paths() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("README.md");
fs::write(&file, "# before").unwrap();
let registry = Arc::new(WorkspaceRegistry::new("save-test".into()));
let id = add_test_workspace(
®istry,
dir.path().to_path_buf(),
WorkspaceFlags {
enable_edit: true,
..WorkspaceFlags::default()
},
);
let state = test_state(registry);
let relative = SaveFileRequest {
workspace_id: id.clone(),
file_path: "README.md".into(),
content: "# relative save".into(),
};
let response = save_file_handler(
State(state.clone()),
save_headers(&state, &id),
Json(relative),
)
.await
.into_response();
assert_eq!(response.status(), StatusCode::OK);
let body: serde_json::Value = serde_json::from_str(&response_text(response).await).unwrap();
assert_eq!(body["success"], true);
assert_eq!(fs::read_to_string(&file).unwrap(), "# relative save");
let absolute = SaveFileRequest {
workspace_id: id.clone(),
file_path: file.to_string_lossy().to_string(),
content: "# absolute save".into(),
};
let response = save_file_handler(
State(state.clone()),
save_headers(&state, &id),
Json(absolute),
)
.await
.into_response();
assert_eq!(response.status(), StatusCode::OK);
let body: serde_json::Value = serde_json::from_str(&response_text(response).await).unwrap();
assert_eq!(body["success"], true);
assert_eq!(fs::read_to_string(&file).unwrap(), "# absolute save");
}
#[tokio::test]
async fn save_file_handler_rejects_outside_workspace_paths() {
let dir = tempfile::tempdir().unwrap();
let outside = tempfile::NamedTempFile::new().unwrap();
fs::write(outside.path(), "# outside").unwrap();
let registry = Arc::new(WorkspaceRegistry::new("save-outside-test".into()));
let id = add_test_workspace(
®istry,
dir.path().to_path_buf(),
WorkspaceFlags {
enable_edit: true,
..WorkspaceFlags::default()
},
);
let state = test_state(registry);
let request = SaveFileRequest {
workspace_id: id.clone(),
file_path: outside.path().to_string_lossy().to_string(),
content: "# should not write".into(),
};
let response = save_file_handler(
State(state.clone()),
save_headers(&state, &id),
Json(request),
)
.await
.into_response();
assert_eq!(response.status(), StatusCode::OK);
let body: serde_json::Value = serde_json::from_str(&response_text(response).await).unwrap();
assert_eq!(body["success"], false);
assert_eq!(body["message"], "Access denied");
assert_eq!(fs::read_to_string(outside.path()).unwrap(), "# outside");
}
#[tokio::test]
async fn workspace_create_file_creates_inside_workspace_and_rejects_traversal() {
let dir = tempfile::tempdir().unwrap();
let registry = Arc::new(WorkspaceRegistry::new("create-file-test".into()));
let id = add_test_workspace(
®istry,
dir.path().to_path_buf(),
WorkspaceFlags {
enable_edit: true,
..WorkspaceFlags::default()
},
);
let state = test_state(registry);
let request = CreateFileRequest {
path: "docs/new-note.md".into(),
content: Some("# New note\n".into()),
};
let response =
handle_workspace_create_file(State(state.clone()), AxumPath(id.clone()), Json(request))
.await
.into_response();
assert_eq!(response.status(), StatusCode::OK);
let body: serde_json::Value = serde_json::from_str(&response_text(response).await).unwrap();
assert_eq!(body["success"], true);
assert_eq!(
fs::read_to_string(dir.path().join("docs/new-note.md")).unwrap(),
"# New note\n"
);
let request = CreateFileRequest {
path: "../outside.md".into(),
content: Some("# outside".into()),
};
let response = handle_workspace_create_file(State(state), AxumPath(id), Json(request))
.await
.into_response();
assert_eq!(response.status(), StatusCode::OK);
let body: serde_json::Value = serde_json::from_str(&response_text(response).await).unwrap();
assert_eq!(body["success"], false);
assert_eq!(body["message"], "Invalid file path");
assert!(!dir.path().join("../outside.md").exists());
}
#[tokio::test]
async fn workspace_create_folder_creates_inside_workspace_and_rejects_traversal() {
let dir = tempfile::tempdir().unwrap();
let registry = Arc::new(WorkspaceRegistry::new("create-folder-test".into()));
let id = add_test_workspace(
®istry,
dir.path().to_path_buf(),
WorkspaceFlags {
enable_edit: true,
..WorkspaceFlags::default()
},
);
let state = test_state(registry);
let request = CreateFileRequest {
path: "docs/sub/new-folder".into(),
content: None,
};
let response = handle_workspace_create_folder(
State(state.clone()),
AxumPath(id.clone()),
Json(request),
)
.await
.into_response();
assert_eq!(response.status(), StatusCode::OK);
let body: serde_json::Value = serde_json::from_str(&response_text(response).await).unwrap();
assert_eq!(body["success"], true);
assert!(dir.path().join("docs/sub/new-folder").is_dir());
let request = CreateFileRequest {
path: "docs/sub/new-folder".into(),
content: None,
};
let response = handle_workspace_create_folder(
State(state.clone()),
AxumPath(id.clone()),
Json(request),
)
.await
.into_response();
let body: serde_json::Value = serde_json::from_str(&response_text(response).await).unwrap();
assert_eq!(body["success"], false);
assert_eq!(body["message"], "Folder already exists");
let request = CreateFileRequest {
path: "../escape".into(),
content: None,
};
let response = handle_workspace_create_folder(State(state), AxumPath(id), Json(request))
.await
.into_response();
let body: serde_json::Value = serde_json::from_str(&response_text(response).await).unwrap();
assert_eq!(body["success"], false);
assert!(!dir.path().join("../escape").exists());
}
#[tokio::test]
async fn single_file_workspace_redirects_and_hides_siblings() {
let dir = tempfile::tempdir().unwrap();
fs::create_dir_all(dir.path().join("nested")).unwrap();
let spaced_asset = dir.path().join("pic with space.png");
fs::write(
dir.path().join("opened.md"),
format!(
"# opened\n\n\n\n",
spaced_asset.to_string_lossy()
),
)
.unwrap();
fs::write(dir.path().join("sibling.md"), "# sibling").unwrap();
fs::write(dir.path().join("pic.png"), b"png").unwrap();
fs::write(&spaced_asset, b"png").unwrap();
fs::write(dir.path().join("nested/root.png"), b"png").unwrap();
let registry = Arc::new(WorkspaceRegistry::new("single-file-test".into()));
let id = registry.add(WorkspaceConfig {
path: dunce::canonicalize(dir.path()).unwrap(),
flags: WorkspaceFlags {
enable_edit: true,
..WorkspaceFlags::default()
},
single_file: Some("opened.md".into()),
collaborator_access_code_hash: String::new(),
..Default::default()
});
let state = test_state(registry);
let root = handle_workspace_root(
State(state.clone()),
AxumPath(id.clone()),
Some(Extension(AccessRole::Admin)),
)
.await
.into_response();
assert_eq!(root.status(), StatusCode::SEE_OTHER);
assert_eq!(
root.headers().get(header::LOCATION).unwrap(),
&format!("/{id}/opened.md")
);
let opened = handle_workspace_path(
State(state.clone()),
AxumPath((id.clone(), "opened.md".into())),
Some(Extension(AccessRole::Admin)),
axum::http::HeaderMap::new(),
)
.await
.into_response();
let opened_status = opened.status();
let opened_body = response_text(opened).await;
assert_eq!(opened_status, StatusCode::OK);
assert!(
opened_body.contains(&format!(r#"src="/{id}/pic%20with%20space.png""#)),
"body: {opened_body}"
);
assert!(
opened_body.contains(&format!(r#"src="/{id}/nested/root.png""#)),
"body: {opened_body}"
);
let asset = handle_workspace_path(
State(state.clone()),
AxumPath((id.clone(), "pic.png".into())),
Some(Extension(AccessRole::Admin)),
axum::http::HeaderMap::new(),
)
.await
.into_response();
assert_eq!(asset.status(), StatusCode::OK);
let spaced_asset = handle_workspace_path(
State(state.clone()),
AxumPath((id.clone(), "pic%20with%20space.png".into())),
Some(Extension(AccessRole::Admin)),
axum::http::HeaderMap::new(),
)
.await
.into_response();
assert_eq!(spaced_asset.status(), StatusCode::OK);
let root_asset = handle_workspace_path(
State(state.clone()),
AxumPath((id.clone(), "nested/root.png".into())),
Some(Extension(AccessRole::Admin)),
axum::http::HeaderMap::new(),
)
.await
.into_response();
assert_eq!(root_asset.status(), StatusCode::OK);
let sibling = handle_workspace_path(
State(state.clone()),
AxumPath((id.clone(), "sibling.md".into())),
Some(Extension(AccessRole::Admin)),
axum::http::HeaderMap::new(),
)
.await
.into_response();
assert_eq!(sibling.status(), StatusCode::NOT_FOUND);
let files = handle_workspace_files_data(State(state.clone()), AxumPath(id.clone()))
.await
.into_response();
assert_eq!(files.status(), StatusCode::OK);
let files = response_text(files).await;
assert!(files.contains("opened.md"), "body: {files}");
assert!(files.contains("pic.png"), "body: {files}");
assert!(!files.contains("sibling.md"), "body: {files}");
let directory = handle_workspace_dir_data(
State(state.clone()),
AxumPath(id.clone()),
Query(DirListingQuery { path: None }),
)
.await
.into_response();
assert_eq!(directory.status(), StatusCode::OK);
let directory = response_text(directory).await;
assert!(directory.contains("opened.md"), "body: {directory}");
assert!(!directory.contains("sibling.md"), "body: {directory}");
let git = handle_git_history_data(State(state.clone()), AxumPath(id.clone()))
.await
.into_response();
assert_eq!(git.status(), StatusCode::NOT_FOUND);
let save = save_file_handler(
State(state.clone()),
save_headers(&state, &id),
Json(SaveFileRequest {
workspace_id: id.clone(),
file_path: "sibling.md".into(),
content: "# overwritten".into(),
}),
)
.await
.into_response();
assert_eq!(save.status(), StatusCode::OK);
let save: serde_json::Value = serde_json::from_str(&response_text(save).await).unwrap();
assert_eq!(save["success"], false);
assert_eq!(
fs::read_to_string(dir.path().join("sibling.md")).unwrap(),
"# sibling"
);
let create = handle_workspace_create_file(
State(state.clone()),
AxumPath(id.clone()),
Json(CreateFileRequest {
path: "created.md".into(),
content: Some("# created".into()),
}),
)
.await
.into_response();
assert_eq!(create.status(), StatusCode::NOT_FOUND);
assert!(!dir.path().join("created.md").exists());
let delete = handle_workspace_delete_file(
State(state),
AxumPath(id),
Json(DeleteFileRequest {
path: "sibling.md".into(),
}),
)
.await
.into_response();
assert_eq!(delete.status(), StatusCode::NOT_FOUND);
assert!(dir.path().join("sibling.md").exists());
}
}