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:
1 parent
fab9add5d5
commit
5b569b6835
108 files changed
+6785
-279
No files matched your search
+107
-1
@@ -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,
|
||||
)
|
||||
|
||||
Reference in new issue
Block a user