Add security hardening, Ghidra, recreate skill, checkpoints, shells, web search and themes

- Sandbox (bubblewrap) for non-admin shell/git, MCP per-user rights, hard budgets and quotas,
  login rate limiting, audit log and backups
- Ghidra headless decompilation (ARM64 natives built from source), binary_info tool,
  clean room / deep workflow per session
- Recreate skill and commands for analysing and re-building old software
- File checkpoints with rewind/restore, background shells (bash_output, kill_shell)
- web_search tool (DuckDuckGo, SearXNG, Brave, Tavily)
- In-app dialogs and toasts instead of browser dialogs
- 15 base themes (trilium-themes palettes plus Nord, VS Code, GitHub, Catppuccin, One,
  Dracula, Rose Pine, Everforest, Kanagawa) with light/dark/auto switcher and license notes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard ZinkandClaude Opus 5.5 committed 2026-11-08 19:12:30 +01:00
1 parent fab9add5d5
commit 5b569b6835
108 files changed
+6785 -279

No files matched your search

+107 -1
View File
@@ -112,13 +112,117 @@ fn mime_for(p: &Path) -> &'static str {
Some("jpg") | Some("jpeg") => "image/jpeg",
Some("gif") => "image/gif",
Some("webp") => "image/webp",
Some("bmp") => "image/bmp",
Some("svg") => "image/svg+xml",
Some("pdf") => "application/pdf",
Some("wav") => "audio/wav",
Some("mp3") => "audio/mpeg",
Some("ogg") | Some("oga") => "audio/ogg",
Some("flac") => "audio/flac",
Some("m4a") => "audio/mp4",
Some("glb") => "model/gltf-binary",
Some("gltf") => "model/gltf+json",
Some("stl") => "model/stl",
Some("obj") => "model/obj",
Some("txt") | Some("md") | Some("log") => "text/plain; charset=utf-8",
_ => "application/octet-stream",
}
}
fn mtime_ms(p: &Path) -> u64 {
std::fs::metadata(p).ok().and_then(|m| m.modified().ok()).and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()).map(|d| d.as_millis() as u64).unwrap_or(0)
}
/// Cache file for a derived preview (keyed by path, mtime and variant).
fn cache_path(p: &Path, variant: &str, ext: &str) -> PathBuf {
let key = crate::auth::sha256_hex(&format!("{}|{}|{variant}", p.display(), mtime_ms(p)));
let dir = std::env::var("HOME").map(PathBuf::from).unwrap_or_else(|_| std::env::temp_dir()).join(".cache/codecraft/previews");
let _ = std::fs::create_dir_all(&dir);
dir.join(format!("{}.{ext}", &key[..32]))
}
async fn run_tool(cmd: &str, args: &[&str]) -> Result<(), ApiError> {
let out = tokio::time::timeout(std::time::Duration::from_secs(120), tokio::process::Command::new(cmd).args(args).kill_on_drop(true).output())
.await
.map_err(|_| ApiError(StatusCode::GATEWAY_TIMEOUT, format!("{cmd} timed out")))?
.map_err(|_| ApiError(StatusCode::NOT_IMPLEMENTED, format!("'{cmd}' is not installed")))?;
if !out.status.success() {
return Err(bad(format!("{cmd}: {}", String::from_utf8_lossy(&out.stderr).trim())));
}
Ok(())
}
#[derive(Deserialize)]
pub struct PreviewQuery {
path: String,
page: Option<u32>,
}
/// Page count of a PDF.
pub async fn pdf_info(Extension(user): Extension<User>, State(app): State<AppState>, Query(q): Query<PreviewQuery>) -> ApiResult {
let p = checked_path(&app, &user, &q.path, true)?;
let out = tokio::process::Command::new("pdfinfo").arg(&p).output().await.map_err(|_| ApiError(StatusCode::NOT_IMPLEMENTED, "pdfinfo is not installed".into()))?;
let text = String::from_utf8_lossy(&out.stdout);
let pages: u32 = text.lines().find_map(|l| l.strip_prefix("Pages:").map(|v| v.trim().parse().unwrap_or(1))).unwrap_or(1);
Ok(Json(json!({"pages": pages})))
}
/// One PDF page rendered as PNG (cached).
pub async fn pdf_page(Extension(user): Extension<User>, State(app): State<AppState>, Query(q): Query<PreviewQuery>) -> Result<Response, ApiError> {
let p = checked_path(&app, &user, &q.path, true)?;
let page = q.page.unwrap_or(1).max(1);
let out = cache_path(&p, &format!("page{page}"), "png");
if !out.exists() {
let base = out.with_extension("");
let n = page.to_string();
run_tool("pdftoppm", &["-png", "-r", "110", "-f", &n, "-l", &n, "-singlefile", &p.to_string_lossy(), &base.to_string_lossy()]).await?;
}
let bytes = tokio::fs::read(&out).await?;
Ok(([(header::CONTENT_TYPE, "image/png"), (header::CACHE_CONTROL, "private, max-age=3600")], bytes).into_response())
}
/// Office documents as text (docx, pptx) or tables (xlsx, xls, ods).
pub async fn doc_preview(Extension(user): Extension<User>, State(app): State<AppState>, Query(q): Query<PreviewQuery>) -> ApiResult {
let p = checked_path(&app, &user, &q.path, true)?;
let ext = p.extension().and_then(|e| e.to_str()).unwrap_or("").to_ascii_lowercase();
if matches!(ext.as_str(), "xlsx" | "xlsm" | "xls" | "ods") {
let path = p.clone();
let sheets = tokio::task::spawn_blocking(move || -> anyhow::Result<Value> {
use calamine::Reader;
let mut wb = calamine::open_workbook_auto(&path)?;
let mut out = Vec::new();
for name in wb.sheet_names() {
let range = wb.worksheet_range(&name)?;
let rows: Vec<Vec<String>> = range.rows().take(500).map(|r| r.iter().take(50).map(|c| c.to_string()).collect()).collect();
out.push(json!({"name": name, "rows": rows, "total_rows": range.get_size().0}));
}
Ok(json!(out))
})
.await??;
return Ok(Json(json!({"kind": "table", "sheets": sheets})));
}
let (text, _) = crate::docs::extract(&p, None, None).await?;
Ok(Json(json!({"kind": "text", "text": text})))
}
/// Tracker modules (xm, mod, s3m, it) rendered to WAV with openmpt123 (cached).
pub async fn module_audio(Extension(user): Extension<User>, State(app): State<AppState>, Query(q): Query<PreviewQuery>) -> Result<Response, ApiError> {
let p = checked_path(&app, &user, &q.path, true)?;
let out = cache_path(&p, "audio", "wav");
if !out.exists() {
// openmpt123 writes <input>.wav next to the input; render into the cache via a copy.
let tmp_in = out.with_extension(p.extension().and_then(|e| e.to_str()).unwrap_or("xm"));
tokio::fs::copy(&p, &tmp_in).await?;
let res = run_tool("openmpt123", &["--quiet", "--batch", "--force", "--render", &tmp_in.to_string_lossy()]).await;
let rendered = PathBuf::from(format!("{}.wav", tmp_in.display()));
let _ = tokio::fs::remove_file(&tmp_in).await;
res?;
tokio::fs::rename(&rendered, &out).await?;
}
let bytes = tokio::fs::read(&out).await?;
Ok(([(header::CONTENT_TYPE, "audio/wav"), (header::CACHE_CONTROL, "private, max-age=3600")], bytes).into_response())
}
#[derive(Deserialize)]
pub struct RawQuery {
path: String,
@@ -141,12 +245,14 @@ pub async fn raw(Extension(user): Extension<User>, State(app): State<AppState>,
} else {
format!("inline; filename=\"{name}\"")
};
// Raw files never run scripts in the app's origin; PDFs keep the browser viewer.
let csp = if mime == "application/pdf" { "sandbox" } else { "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; media-src 'self'" };
Ok((
[
(header::CONTENT_TYPE, mime.to_string()),
(header::CONTENT_DISPOSITION, disposition),
(header::X_CONTENT_TYPE_OPTIONS, "nosniff".to_string()),
(header::CONTENT_SECURITY_POLICY, "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'".to_string()),
(header::CONTENT_SECURITY_POLICY, csp.to_string()),
],
bytes,
)