feat: end-to-end integrity checks with self-healing; website; installer and Windows fixes
Store::verify compares the whole SQLite projection, every table row for row, against a fresh projection of the journal; verify_and_heal rebuilds a drifted database (keeping the old file) at every server start and via the new books.verify tool, which also re-hashes every blob. The GUI checks the server's database read-only and every stored file, and shows the result. rebuild() also drops stale WAL/SHM files. site/: the wanderbooks website (just site, just publish). install.sh: OS detection (Windows points to WSL2), C compiler and git checks, WANDERBOOKS_REPO. Windows: no directory fsync off Unix; .exe-aware lookup. Repo justfile: check, build, install, demo, site. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MtYEaR6M6Ybdhm6X98BWNu
This commit is contained in:
25 files changed
+802
-35
No files matched your search
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
/target/
|
/target/
|
||||||
Cargo.lock
|
|
||||||
*.orig
|
*.orig
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
site/dist/
|
||||||
@@ -169,6 +169,8 @@ fn print_usage() {
|
|||||||
/// A binary next to this one (cargo installs them side by side), else on
|
/// A binary next to this one (cargo installs them side by side), else on
|
||||||
/// the PATH.
|
/// the PATH.
|
||||||
fn find_binary(name: &str) -> Option<PathBuf> {
|
fn find_binary(name: &str) -> Option<PathBuf> {
|
||||||
|
let name = format!("{name}{}", env::consts::EXE_SUFFIX);
|
||||||
|
let name = name.as_str();
|
||||||
if let Some(dir) = env::current_exe()
|
if let Some(dir) = env::current_exe()
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|e| e.parent().map(Path::to_path_buf))
|
.and_then(|e| e.parent().map(Path::to_path_buf))
|
||||||
|
|||||||
@@ -361,16 +361,33 @@ fn live_status(ui: &mut Ui, b: &Books, since_update: f64, live: bool) {
|
|||||||
.color(p.muted),
|
.color(p.muted),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
ui.label(
|
let check = |ui: &mut Ui, ok: bool, text: String, tip: String| {
|
||||||
RichText::new(format!("{} Journal chain verified", icon::SEAL_CHECK))
|
let (glyph, color) = if ok { (icon::SEAL_CHECK, p.muted) } else { (icon::WARNING_CIRCLE, p.negative) };
|
||||||
.font(theme::regular(11.5))
|
ui.label(RichText::new(format!("{glyph} {text}")).font(theme::regular(11.5)).color(color))
|
||||||
.color(p.muted),
|
.on_hover_text(tip);
|
||||||
)
|
};
|
||||||
.on_hover_text(format!(
|
check(
|
||||||
"Every journal event's hash chains to the one before it.\nHead: {}\nData: {}",
|
ui,
|
||||||
b.head,
|
true,
|
||||||
b.data_dir.display()
|
"Journal chain verified".into(),
|
||||||
));
|
format!("Every journal event's hash chains to the one before it.\nHead: {}\nData: {}", b.head, b.data_dir.display()),
|
||||||
|
);
|
||||||
|
use ledger_store::DiskCheck;
|
||||||
|
let (ok, text, tip) = match &b.integrity.disk {
|
||||||
|
DiskCheck::Matches => (true, "Database matches journal".to_string(), "The server's SQLite database matches a fresh projection of the journal, every table, row for row.".to_string()),
|
||||||
|
DiskCheck::Behind { applied, journal } => (true, "Database catching up".to_string(), format!("The database has applied {applied} of {journal} journal events; the server applies the rest right after writing them.")),
|
||||||
|
DiskCheck::Missing => (true, "Database will rebuild".to_string(), "No database file yet; the server builds it from the journal when it starts.".to_string()),
|
||||||
|
DiskCheck::Differs(why) => (false, "Database differs".to_string(), format!("{why}. The server rebuilds it from the journal (books.verify, or on its next start).")),
|
||||||
|
DiskCheck::Unreadable(why) => (false, "Database unreadable".to_string(), why.clone()),
|
||||||
|
};
|
||||||
|
check(ui, ok, text, tip);
|
||||||
|
let bad = b.integrity.file_failures.len();
|
||||||
|
check(
|
||||||
|
ui,
|
||||||
|
bad == 0,
|
||||||
|
if bad == 0 { format!("{} files verified", b.integrity.files_ok) } else { format!("{bad} files damaged") },
|
||||||
|
"Every stored receipt and document re-hashed against its recorded SHA-256.".into(),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -260,6 +260,43 @@ pub fn find(b: &Books) -> Vec<Attention> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
use ledger_store::DiskCheck;
|
||||||
|
let verify_prompt = "In wanderbooks, run books.verify and tell me what it found.".to_string();
|
||||||
|
match &b.integrity.disk {
|
||||||
|
DiskCheck::Differs(why) => out.push(Attention {
|
||||||
|
severity: Severity::Problem,
|
||||||
|
title: "The database doesn't match the journal".into(),
|
||||||
|
detail: format!("{why}. The journal is the record; the server rebuilds the database from it (books.verify, or on its next start)."),
|
||||||
|
target: Some(Route::Journal),
|
||||||
|
prompt: verify_prompt.clone(),
|
||||||
|
}),
|
||||||
|
DiskCheck::Unreadable(why) => out.push(Attention {
|
||||||
|
severity: Severity::Problem,
|
||||||
|
title: "Couldn't check the database".into(),
|
||||||
|
detail: format!("{why}."),
|
||||||
|
target: Some(Route::Journal),
|
||||||
|
prompt: verify_prompt.clone(),
|
||||||
|
}),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
if !b.integrity.file_failures.is_empty() {
|
||||||
|
let ids: Vec<String> = b
|
||||||
|
.integrity
|
||||||
|
.file_failures
|
||||||
|
.iter()
|
||||||
|
.map(|(id, _)| id.to_string())
|
||||||
|
.collect();
|
||||||
|
out.push(Attention {
|
||||||
|
severity: Severity::Problem,
|
||||||
|
title: format!("{} damaged or missing", fmt::count(ids.len(), "stored file is", "stored files are")),
|
||||||
|
detail: format!(
|
||||||
|
"Attachment {}: the bytes on disk don't match their recorded hash. Restore them from a backup (the books dir's git history).",
|
||||||
|
ids.join(", ")
|
||||||
|
),
|
||||||
|
target: Some(Route::Documents),
|
||||||
|
prompt: verify_prompt,
|
||||||
|
});
|
||||||
|
}
|
||||||
out.sort_by_key(|a| a.severity);
|
out.sort_by_key(|a| a.severity);
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
@@ -85,6 +85,46 @@ pub struct Books {
|
|||||||
pub receipt_threshold: Decimal,
|
pub receipt_threshold: Decimal,
|
||||||
pub attention: Vec<Attention>,
|
pub attention: Vec<Attention>,
|
||||||
pub kpis: Kpis,
|
pub kpis: Kpis,
|
||||||
|
pub integrity: Integrity,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether what's on disk is what the journal says.
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct Integrity {
|
||||||
|
/// The writer's SQLite database against the journal.
|
||||||
|
pub disk: ledger_store::DiskCheck,
|
||||||
|
/// Stored files whose bytes match their hash.
|
||||||
|
pub files_ok: u64,
|
||||||
|
/// Files that are missing or damaged: (attachment id, why).
|
||||||
|
pub file_failures: Vec<(u64, String)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Integrity {
|
||||||
|
/// Check the database and every file not already verified (by hash, so
|
||||||
|
/// a file is read once however often the books change).
|
||||||
|
pub fn check(snap: &Snapshot, verified: &mut HashSet<String>) -> Integrity {
|
||||||
|
let store = ledger_store::BlobStore::open_read_only(&snap.data_dir().join("blobs"));
|
||||||
|
let mut ok = 0;
|
||||||
|
let mut failures = Vec::new();
|
||||||
|
for a in snap.attachments(None).unwrap_or_default() {
|
||||||
|
if verified.contains(&a.blob_sha) {
|
||||||
|
ok += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
match store.get(&a.blob_sha) {
|
||||||
|
Ok(_) => {
|
||||||
|
verified.insert(a.blob_sha.clone());
|
||||||
|
ok += 1;
|
||||||
|
}
|
||||||
|
Err(e) => failures.push((a.id, e.to_string())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Integrity {
|
||||||
|
disk: snap.check_disk(),
|
||||||
|
files_ok: ok,
|
||||||
|
file_failures: failures,
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The headline numbers.
|
/// The headline numbers.
|
||||||
@@ -113,6 +153,7 @@ impl Books {
|
|||||||
settings: BooksSettings,
|
settings: BooksSettings,
|
||||||
today: NaiveDate,
|
today: NaiveDate,
|
||||||
prev_seq: Option<u64>,
|
prev_seq: Option<u64>,
|
||||||
|
integrity: Integrity,
|
||||||
) -> Result<Books, String> {
|
) -> Result<Books, String> {
|
||||||
let ledger = snap.ledger().clone();
|
let ledger = snap.ledger().clone();
|
||||||
let attachments = snap.attachments(None).map_err(|e| e.to_string())?;
|
let attachments = snap.attachments(None).map_err(|e| e.to_string())?;
|
||||||
@@ -268,6 +309,7 @@ impl Books {
|
|||||||
receipt_threshold,
|
receipt_threshold,
|
||||||
attention: Vec::new(),
|
attention: Vec::new(),
|
||||||
kpis: Kpis::default(),
|
kpis: Kpis::default(),
|
||||||
|
integrity,
|
||||||
};
|
};
|
||||||
books.kpis = books.compute_kpis();
|
books.kpis = books.compute_kpis();
|
||||||
books.attention = crate::attention::find(&books);
|
books.attention = crate::attention::find(&books);
|
||||||
|
|||||||
@@ -5,11 +5,12 @@
|
|||||||
//! two. Filesystem events are the trigger; a slow poll backs them up in case
|
//! two. Filesystem events are the trigger; a slow poll backs them up in case
|
||||||
//! an event is missed (network drives, odd editors).
|
//! an event is missed (network drives, odd editors).
|
||||||
|
|
||||||
use crate::books::Books;
|
use crate::books::{Books, Integrity};
|
||||||
use chrono::NaiveDate;
|
use chrono::NaiveDate;
|
||||||
use ledger_config::BooksSettings;
|
use ledger_config::BooksSettings;
|
||||||
use ledger_store::Snapshot;
|
use ledger_store::Snapshot;
|
||||||
use notify::{RecursiveMode, Watcher};
|
use notify::{RecursiveMode, Watcher};
|
||||||
|
use std::collections::HashSet;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::sync::mpsc::{Receiver, RecvTimeoutError, Sender, channel};
|
use std::sync::mpsc::{Receiver, RecvTimeoutError, Sender, channel};
|
||||||
@@ -63,7 +64,8 @@ impl Loader {
|
|||||||
/// Load once, synchronously (tests and one-shot tools).
|
/// Load once, synchronously (tests and one-shot tools).
|
||||||
pub fn load_once(source: &Source) -> Result<Books, String> {
|
pub fn load_once(source: &Source) -> Result<Books, String> {
|
||||||
let snap = Snapshot::load(&source.data_dir).map_err(|e| e.to_string())?;
|
let snap = Snapshot::load(&source.data_dir).map_err(|e| e.to_string())?;
|
||||||
Books::build(&snap, settings(source)?, today(source), None)
|
let integrity = Integrity::check(&snap, &mut HashSet::new());
|
||||||
|
Books::build(&snap, settings(source)?, today(source), None, integrity)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn today(source: &Source) -> NaiveDate {
|
fn today(source: &Source) -> NaiveDate {
|
||||||
@@ -102,13 +104,22 @@ fn run(source: Source, ctx: egui::Context, tx: Sender<Update>) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let build = |snap: &Snapshot, prev: Option<u64>| -> Update {
|
let mut verified: HashSet<String> = HashSet::new();
|
||||||
match settings(&source).and_then(|s| Books::build(snap, s, today(&source), prev)) {
|
// Build the picture; also return the database check it was built with.
|
||||||
|
let mut build = |snap: &Snapshot, prev: Option<u64>| -> (Update, ledger_store::DiskCheck) {
|
||||||
|
let integrity = Integrity::check(snap, &mut verified);
|
||||||
|
let disk = integrity.disk.clone();
|
||||||
|
let update = match settings(&source)
|
||||||
|
.and_then(|s| Books::build(snap, s, today(&source), prev, integrity))
|
||||||
|
{
|
||||||
Ok(b) => Update::Books(Arc::new(b)),
|
Ok(b) => Update::Books(Arc::new(b)),
|
||||||
Err(e) => Update::Error(e),
|
Err(e) => Update::Error(e),
|
||||||
}
|
|
||||||
};
|
};
|
||||||
send(build(&snap, None));
|
(update, disk)
|
||||||
|
};
|
||||||
|
let (first, disk) = build(&snap, None);
|
||||||
|
let mut last_disk = Some(disk);
|
||||||
|
send(first);
|
||||||
|
|
||||||
let (ev_tx, ev_rx) = channel();
|
let (ev_tx, ev_rx) = channel();
|
||||||
let mut watcher = notify::recommended_watcher(move |res: notify::Result<notify::Event>| {
|
let mut watcher = notify::recommended_watcher(move |res: notify::Result<notify::Event>| {
|
||||||
@@ -149,10 +160,19 @@ fn run(source: Source, ctx: egui::Context, tx: Sender<Update>) {
|
|||||||
};
|
};
|
||||||
let toml_now = mtime(&source.books_toml);
|
let toml_now = mtime(&source.books_toml);
|
||||||
let new_day = today(&source);
|
let new_day = today(&source);
|
||||||
if grew || toml_now != toml_mtime || new_day != day {
|
// The writer journals first and updates its database right after:
|
||||||
|
// a "behind" database usually catches up a moment later, so look
|
||||||
|
// again rather than leave a stale warning up.
|
||||||
|
let disk_changed = !grew
|
||||||
|
&& last_disk
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|d| *d != ledger_store::DiskCheck::Matches && snap.check_disk() != *d);
|
||||||
|
if grew || disk_changed || toml_now != toml_mtime || new_day != day {
|
||||||
toml_mtime = toml_now;
|
toml_mtime = toml_now;
|
||||||
day = new_day;
|
day = new_day;
|
||||||
send(build(&snap, grew.then_some(before)));
|
let (update, disk) = build(&snap, grew.then_some(before));
|
||||||
|
last_disk = Some(disk);
|
||||||
|
send(update);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -115,7 +115,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
};
|
};
|
||||||
let company = settings.company;
|
let company = settings.company;
|
||||||
|
|
||||||
let store = if data_dir.join(ledger_store::JOURNAL_FILE).exists() {
|
let mut store = if data_dir.join(ledger_store::JOURNAL_FILE).exists() {
|
||||||
Store::open(&data_dir)?
|
Store::open(&data_dir)?
|
||||||
} else {
|
} else {
|
||||||
let code = config.base_currency.as_deref().unwrap_or("USD");
|
let code = config.base_currency.as_deref().unwrap_or("USD");
|
||||||
@@ -123,11 +123,15 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
eprintln!("ledger-mcp: creating new store (base currency {code})");
|
eprintln!("ledger-mcp: creating new store (base currency {code})");
|
||||||
Store::create(&data_dir, currency)?
|
Store::create(&data_dir, currency)?
|
||||||
};
|
};
|
||||||
// The journal is the source of truth: verify the chain and the
|
// The journal is the source of truth: verify the chain, the replay, and
|
||||||
// projection before serving a single tool call.
|
// the whole projection before serving a single tool call, rebuilding
|
||||||
store
|
// the projection if it drifted.
|
||||||
.verify()
|
let report = store
|
||||||
|
.verify_and_heal()
|
||||||
.map_err(|e| format!("store verify failed: {e}"))?;
|
.map_err(|e| format!("store verify failed: {e}"))?;
|
||||||
|
if let Some(why) = &report.healed {
|
||||||
|
eprintln!("ledger-mcp: rebuilt the database from the journal ({why})");
|
||||||
|
}
|
||||||
|
|
||||||
let rt = tokio::runtime::Builder::new_multi_thread()
|
let rt = tokio::runtime::Builder::new_multi_thread()
|
||||||
.enable_all()
|
.enable_all()
|
||||||
|
|||||||
@@ -1375,6 +1375,43 @@ impl BooksServer {
|
|||||||
Ok(Json(SearchEntriesOut { entries }))
|
Ok(Json(SearchEntriesOut { entries }))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tool(
|
||||||
|
name = "books.verify",
|
||||||
|
description = "Check the books end to end: the journal's hash chain; a fresh replay against the live ledger; every table of the SQLite database, row for row, against a fresh projection of the journal (rebuilding the database from the journal if it drifted, keeping the old file); and, by default, every stored receipt and document against its SHA-256. Run it whenever a number looks wrong or a human asks whether the books are intact."
|
||||||
|
)]
|
||||||
|
async fn verify(&self, params: Parameters<VerifyParams>) -> Result<Json<VerifyOut>, String> {
|
||||||
|
let mut store = self.lock()?;
|
||||||
|
let report = store.verify_and_heal().map_err(|e| e.to_string())?;
|
||||||
|
let blobs = if params.0.blobs {
|
||||||
|
Some(store.verify_blobs().map_err(|e| e.to_string())?)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
let mut checked = vec![
|
||||||
|
format!("journal hash chain ({} events)", report.events),
|
||||||
|
"fresh replay matches the live ledger (every statement, assets, classes, payroll)"
|
||||||
|
.to_string(),
|
||||||
|
"SQLite database matches a fresh projection of the journal, every table, row for row"
|
||||||
|
.to_string(),
|
||||||
|
];
|
||||||
|
if let Some(n) = blobs {
|
||||||
|
checked.push(format!("{n} stored files match their SHA-256"));
|
||||||
|
}
|
||||||
|
Ok(Json(VerifyOut {
|
||||||
|
status: if report.healed.is_some() {
|
||||||
|
"healed"
|
||||||
|
} else {
|
||||||
|
"ok"
|
||||||
|
}
|
||||||
|
.to_string(),
|
||||||
|
events: report.events,
|
||||||
|
head: report.head,
|
||||||
|
healed: report.healed,
|
||||||
|
blobs_verified: blobs,
|
||||||
|
checked,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
#[tool(
|
#[tool(
|
||||||
name = "books.digest",
|
name = "books.digest",
|
||||||
description = "What changed since a date: entries posted, per-account movement, and receipt coverage. Built for human review.",
|
description = "What changed since a date: entries posted, per-account movement, and receipt coverage. Built for human review.",
|
||||||
|
|||||||
@@ -375,6 +375,35 @@ pub struct Vendor1099Out {
|
|||||||
pub notes: Vec<String>,
|
pub notes: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Parameters for `books.verify`.
|
||||||
|
#[derive(Debug, Deserialize, JsonSchema)]
|
||||||
|
pub struct VerifyParams {
|
||||||
|
/// Also re-hash every stored receipt and document (default true).
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
pub blobs: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_true() -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Result of `books.verify`.
|
||||||
|
#[derive(Debug, Serialize, JsonSchema)]
|
||||||
|
pub struct VerifyOut {
|
||||||
|
/// "ok", or "healed" when the database was rebuilt from the journal.
|
||||||
|
pub status: String,
|
||||||
|
/// Journal events whose hash chain verified.
|
||||||
|
pub events: u64,
|
||||||
|
/// SHA-256 of the last journal line: the verified head.
|
||||||
|
pub head: String,
|
||||||
|
/// Why the database was rebuilt, if it was.
|
||||||
|
pub healed: Option<String>,
|
||||||
|
/// Stored files re-hashed, when checked.
|
||||||
|
pub blobs_verified: Option<u64>,
|
||||||
|
/// What was checked, in words.
|
||||||
|
pub checked: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
/// Parameters for `books.chart_template`.
|
/// Parameters for `books.chart_template`.
|
||||||
#[derive(Debug, Deserialize, JsonSchema)]
|
#[derive(Debug, Deserialize, JsonSchema)]
|
||||||
pub struct ChartTemplateParams {
|
pub struct ChartTemplateParams {
|
||||||
|
|||||||
@@ -139,7 +139,7 @@ fn mcp_end_to_end_over_stdio() {
|
|||||||
// Tool listing: 23 narrow tools, chart is read-only.
|
// Tool listing: 23 narrow tools, chart is read-only.
|
||||||
let list = mcp.request("tools/list", json!({}));
|
let list = mcp.request("tools/list", json!({}));
|
||||||
let tools = list["result"]["tools"].as_array().unwrap();
|
let tools = list["result"]["tools"].as_array().unwrap();
|
||||||
assert_eq!(tools.len(), 64, "expected 64 tools, got {}", tools.len());
|
assert_eq!(tools.len(), 65, "expected 65 tools, got {}", tools.len());
|
||||||
let chart = tools.iter().find(|t| t["name"] == "books.chart").unwrap();
|
let chart = tools.iter().find(|t| t["name"] == "books.chart").unwrap();
|
||||||
assert_eq!(chart["annotations"]["readOnlyHint"], true);
|
assert_eq!(chart["annotations"]["readOnlyHint"], true);
|
||||||
assert!(
|
assert!(
|
||||||
@@ -1548,4 +1548,9 @@ fn mcp_vendor_1099_over_stdio() {
|
|||||||
assert_eq!(r["vendors"][0]["paid"], "0.00");
|
assert_eq!(r["vendors"][0]["paid"], "0.00");
|
||||||
let vendors = mcp.call("books.list_vendors", json!({}));
|
let vendors = mcp.call("books.list_vendors", json!({}));
|
||||||
assert_eq!(vendors["vendors"][0]["tin_on_file"], true);
|
assert_eq!(vendors["vendors"][0]["tin_on_file"], true);
|
||||||
|
// The books check out end to end, receipt included.
|
||||||
|
let v = mcp.call("books.verify", json!({}));
|
||||||
|
assert_eq!(v["status"], "ok");
|
||||||
|
assert_eq!(v["blobs_verified"], 1);
|
||||||
|
assert!(v["events"].as_u64().unwrap() > 10);
|
||||||
}
|
}
|
||||||
@@ -60,9 +60,14 @@ impl BlobStore {
|
|||||||
f.sync_all()?;
|
f.sync_all()?;
|
||||||
}
|
}
|
||||||
fs::rename(&tmp, &path)?;
|
fs::rename(&tmp, &path)?;
|
||||||
// Fsync the directory so the rename itself is durable.
|
// Fsync the directory so the rename itself is durable. Unix only:
|
||||||
|
// Windows can't open a directory as a file, and NTFS journals the
|
||||||
|
// rename's metadata itself.
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
let dir = fs::File::open(path.parent().unwrap())?;
|
let dir = fs::File::open(path.parent().unwrap())?;
|
||||||
dir.sync_all()?;
|
dir.sync_all()?;
|
||||||
|
}
|
||||||
Ok((sha, false))
|
Ok((sha, false))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -28,8 +28,8 @@ pub mod store;
|
|||||||
pub use blob::BlobStore;
|
pub use blob::BlobStore;
|
||||||
pub use error::StoreError;
|
pub use error::StoreError;
|
||||||
pub use journal::{GENESIS_PREV, JournalLine, JournalWriter};
|
pub use journal::{GENESIS_PREV, JournalLine, JournalWriter};
|
||||||
pub use snapshot::Snapshot;
|
pub use snapshot::{DiskCheck, Snapshot};
|
||||||
pub use store::{
|
pub use store::{
|
||||||
Attachment, DB_FILE, DepreciationRun, DisposalPreview, EntryRow, EventRow, JOURNAL_FILE,
|
Attachment, DB_FILE, DepreciationRun, DisposalPreview, EntryRow, EventRow, JOURNAL_FILE,
|
||||||
PayrollLineInput, Store,
|
PayrollLineInput, Store, VerifyReport,
|
||||||
};
|
};
|
||||||
@@ -18,14 +18,36 @@ use crate::blob::BlobStore;
|
|||||||
use crate::error::StoreError;
|
use crate::error::StoreError;
|
||||||
use crate::journal::{GENESIS_PREV, JournalLine, sha256_hex};
|
use crate::journal::{GENESIS_PREV, JournalLine, sha256_hex};
|
||||||
use crate::store::{
|
use crate::store::{
|
||||||
Attachment, BLOBS_DIR, JOURNAL_FILE, Store, apply_line_to_sqlite, query_attachments,
|
Attachment, BLOBS_DIR, DB_FILE, JOURNAL_FILE, Store, apply_line_to_sqlite, compare_projections,
|
||||||
read_attachment_bytes, replay_line_into_ledger,
|
projection_rows, query_attachments, read_attachment_bytes, replay_line_into_ledger,
|
||||||
};
|
};
|
||||||
use ledger_core::{Currency, EntryId, Ledger};
|
use ledger_core::{Currency, EntryId, Ledger};
|
||||||
use rusqlite::Connection;
|
use rusqlite::Connection;
|
||||||
use std::io::{Read, Seek, SeekFrom};
|
use std::io::{Read, Seek, SeekFrom};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
/// How the writer's on-disk SQLite database compares with the journal.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub enum DiskCheck {
|
||||||
|
/// Every table matches a fresh projection of the journal.
|
||||||
|
Matches,
|
||||||
|
/// The database hasn't applied the newest journal lines yet (normal
|
||||||
|
/// for a moment after a write; the writer applies right after
|
||||||
|
/// journaling, and heals on its next start).
|
||||||
|
Behind {
|
||||||
|
/// Journal lines the database has applied.
|
||||||
|
applied: u64,
|
||||||
|
/// Journal lines there are.
|
||||||
|
journal: u64,
|
||||||
|
},
|
||||||
|
/// No database file (the writer rebuilds it on start).
|
||||||
|
Missing,
|
||||||
|
/// The database differs from the journal: what differs.
|
||||||
|
Differs(String),
|
||||||
|
/// The database couldn't be read.
|
||||||
|
Unreadable(String),
|
||||||
|
}
|
||||||
|
|
||||||
/// A read-only, live-refreshable view of a data directory.
|
/// A read-only, live-refreshable view of a data directory.
|
||||||
pub struct Snapshot {
|
pub struct Snapshot {
|
||||||
data_dir: PathBuf,
|
data_dir: PathBuf,
|
||||||
@@ -168,6 +190,58 @@ impl Snapshot {
|
|||||||
query_attachments(&self.conn, entry_id)
|
query_attachments(&self.conn, entry_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Compare the writer's SQLite database (opened read-only) with this
|
||||||
|
/// snapshot's own projection of the journal, every table, row for row.
|
||||||
|
pub fn check_disk(&self) -> DiskCheck {
|
||||||
|
use rusqlite::OpenFlags;
|
||||||
|
let path = self.data_dir.join(DB_FILE);
|
||||||
|
if !path.exists() {
|
||||||
|
return DiskCheck::Missing;
|
||||||
|
}
|
||||||
|
let conn = match Connection::open_with_flags(
|
||||||
|
&path,
|
||||||
|
OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
|
||||||
|
) {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(e) => return DiskCheck::Unreadable(e.to_string()),
|
||||||
|
};
|
||||||
|
let applied: u64 = match conn.query_row(
|
||||||
|
"SELECT value FROM meta WHERE key='last_applied_seq'",
|
||||||
|
[],
|
||||||
|
|r| r.get::<_, String>(0),
|
||||||
|
) {
|
||||||
|
Ok(v) => v.parse().unwrap_or(0),
|
||||||
|
Err(e) => return DiskCheck::Unreadable(e.to_string()),
|
||||||
|
};
|
||||||
|
let journal = self.journal_seq();
|
||||||
|
if applied != journal {
|
||||||
|
return DiskCheck::Behind { applied, journal };
|
||||||
|
}
|
||||||
|
let (disk, ours) = match (projection_rows(&conn), projection_rows(&self.conn)) {
|
||||||
|
(Ok(d), Ok(o)) => (d, o),
|
||||||
|
(Err(e), _) | (_, Err(e)) => return DiskCheck::Unreadable(e.to_string()),
|
||||||
|
};
|
||||||
|
match compare_projections(&disk, &ours) {
|
||||||
|
Ok(()) => DiskCheck::Matches,
|
||||||
|
Err(StoreError::VerifyMismatch(why)) => DiskCheck::Differs(why),
|
||||||
|
Err(e) => DiskCheck::Unreadable(e.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Re-hash every stored file. Returns how many verified, and each
|
||||||
|
/// failure as (attachment id, why).
|
||||||
|
pub fn verify_blobs(&self) -> (u64, Vec<(u64, String)>) {
|
||||||
|
let mut ok = 0;
|
||||||
|
let mut bad = Vec::new();
|
||||||
|
for a in self.attachments(None).unwrap_or_default() {
|
||||||
|
match self.blobs.get(&a.blob_sha) {
|
||||||
|
Ok(_) => ok += 1,
|
||||||
|
Err(e) => bad.push((a.id, e.to_string())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(ok, bad)
|
||||||
|
}
|
||||||
|
|
||||||
/// An attachment's bytes, verified against its hash.
|
/// An attachment's bytes, verified against its hash.
|
||||||
pub fn attachment_bytes(&self, attachment_id: u64) -> Result<Vec<u8>, StoreError> {
|
pub fn attachment_bytes(&self, attachment_id: u64) -> Result<Vec<u8>, StoreError> {
|
||||||
read_attachment_bytes(&self.conn, &self.blobs, attachment_id)
|
read_attachment_bytes(&self.conn, &self.blobs, attachment_id)
|
||||||
|
|||||||
@@ -8,7 +8,9 @@
|
|||||||
|
|
||||||
use crate::blob::BlobStore;
|
use crate::blob::BlobStore;
|
||||||
use crate::error::StoreError;
|
use crate::error::StoreError;
|
||||||
use crate::journal::{JournalLine, JournalWriter, read_and_verify_journal};
|
use crate::journal::{
|
||||||
|
GENESIS_PREV, JournalLine, JournalWriter, read_and_verify_journal, sha256_hex,
|
||||||
|
};
|
||||||
use crate::schema;
|
use crate::schema;
|
||||||
use chrono::{Months, NaiveDate};
|
use chrono::{Months, NaiveDate};
|
||||||
use ledger_core::{
|
use ledger_core::{
|
||||||
@@ -2257,7 +2259,44 @@ impl Store {
|
|||||||
"payroll runs diverged between journal, live ledger, and sqlite projection".into(),
|
"payroll runs diverged between journal, live ledger, and sqlite projection".into(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
Ok(())
|
// Finally, the whole projection, not just what reports read: every
|
||||||
|
// table, row for row, against a fresh projection of the journal.
|
||||||
|
compare_projections(
|
||||||
|
&projection_rows(&self.conn)?,
|
||||||
|
&projection_rows(&fresh_projection(&lines)?)?,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`Self::verify`], and if the SQLite projection is what diverged,
|
||||||
|
/// heal it: keep the divergent file for forensics, rebuild from the
|
||||||
|
/// journal, and verify again. A journal that fails its own chain is
|
||||||
|
/// never "healed": that's an error for a human.
|
||||||
|
pub fn verify_and_heal(&mut self) -> Result<VerifyReport, StoreError> {
|
||||||
|
let healed = match self.verify() {
|
||||||
|
Ok(()) => None,
|
||||||
|
Err(StoreError::VerifyMismatch(why)) => {
|
||||||
|
let stamp = chrono::Utc::now().format("%Y%m%dT%H%M%SZ").to_string();
|
||||||
|
let keep = self.db_path.with_extension(format!("mismatch.{stamp}"));
|
||||||
|
std::fs::copy(&self.db_path, &keep)?;
|
||||||
|
eprintln!(
|
||||||
|
"wanderbooks: {why}; kept the old database as {} and rebuilt from the journal",
|
||||||
|
keep.display()
|
||||||
|
);
|
||||||
|
self.rebuild()?;
|
||||||
|
self.verify()?;
|
||||||
|
Some(why)
|
||||||
|
}
|
||||||
|
Err(e) => return Err(e),
|
||||||
|
};
|
||||||
|
let lines = read_and_verify_journal(self.journal.path())?;
|
||||||
|
Ok(VerifyReport {
|
||||||
|
events: lines.len() as u64,
|
||||||
|
head: lines
|
||||||
|
.last()
|
||||||
|
.map(|l| sha256_hex(l.to_canonical().as_bytes()))
|
||||||
|
.unwrap_or_else(|| GENESIS_PREV.to_string()),
|
||||||
|
healed,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Verify every blob's bytes against its SHA-256. Returns the count
|
/// Verify every blob's bytes against its SHA-256. Returns the count
|
||||||
@@ -2282,6 +2321,10 @@ impl Store {
|
|||||||
let old = std::mem::replace(&mut self.conn, Connection::open_in_memory()?);
|
let old = std::mem::replace(&mut self.conn, Connection::open_in_memory()?);
|
||||||
old.close().map_err(|(_, e)| StoreError::Rusqlite(e))?;
|
old.close().map_err(|(_, e)| StoreError::Rusqlite(e))?;
|
||||||
std::fs::remove_file(&self.db_path)?;
|
std::fs::remove_file(&self.db_path)?;
|
||||||
|
// WAL and SHM are derived state too; a stale WAL must not leak into
|
||||||
|
// the rebuilt database.
|
||||||
|
let _ = std::fs::remove_file(self.db_path.with_extension("db-wal"));
|
||||||
|
let _ = std::fs::remove_file(self.db_path.with_extension("db-shm"));
|
||||||
self.conn = Self::open_conn(&self.db_path)?;
|
self.conn = Self::open_conn(&self.db_path)?;
|
||||||
Self::migrate(&self.conn)?;
|
Self::migrate(&self.conn)?;
|
||||||
Self::write_meta(&self.conn, "base_currency", self.base_currency.code())?;
|
Self::write_meta(&self.conn, "base_currency", self.base_currency.code())?;
|
||||||
@@ -3774,6 +3817,93 @@ pub(crate) fn apply_line_to_sqlite(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What [`Store::verify_and_heal`] found.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub struct VerifyReport {
|
||||||
|
/// Journal events verified.
|
||||||
|
pub events: u64,
|
||||||
|
/// Hash of the last journal line: the verified head of the chain.
|
||||||
|
pub head: String,
|
||||||
|
/// Why the projection was rebuilt, if it was.
|
||||||
|
pub healed: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A projection of `lines` into a private in-memory database, built with
|
||||||
|
/// exactly the code the writer uses.
|
||||||
|
pub(crate) fn fresh_projection(lines: &[JournalLine]) -> Result<Connection, StoreError> {
|
||||||
|
let conn = Connection::open_in_memory()?;
|
||||||
|
Store::migrate(&conn)?;
|
||||||
|
for line in lines {
|
||||||
|
apply_line_to_sqlite(&conn, line)?;
|
||||||
|
}
|
||||||
|
Ok(conn)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every row of every projected table, as canonical strings, sorted: two
|
||||||
|
/// projections hold the same data exactly when these are equal. The `meta`
|
||||||
|
/// table (bookkeeping about the projection itself) is left out.
|
||||||
|
pub(crate) fn projection_rows(
|
||||||
|
conn: &Connection,
|
||||||
|
) -> Result<std::collections::BTreeMap<String, Vec<String>>, StoreError> {
|
||||||
|
use rusqlite::types::ValueRef;
|
||||||
|
let tables: Vec<String> = conn
|
||||||
|
.prepare(
|
||||||
|
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' AND name <> 'meta' ORDER BY name",
|
||||||
|
)?
|
||||||
|
.query_map([], |r| r.get(0))?
|
||||||
|
.collect::<Result<_, _>>()?;
|
||||||
|
let mut out = std::collections::BTreeMap::new();
|
||||||
|
for t in tables {
|
||||||
|
let mut stmt = conn.prepare(&format!("SELECT * FROM \"{t}\""))?;
|
||||||
|
let n = stmt.column_count();
|
||||||
|
let mut rows: Vec<String> = stmt
|
||||||
|
.query_map([], |r| {
|
||||||
|
let mut s = String::new();
|
||||||
|
for i in 0..n {
|
||||||
|
let v = match r.get_ref(i)? {
|
||||||
|
ValueRef::Null => "∅".to_string(),
|
||||||
|
ValueRef::Integer(x) => format!("i{x}"),
|
||||||
|
ValueRef::Real(x) => format!("r{x}"),
|
||||||
|
ValueRef::Text(b) => format!("t{}", String::from_utf8_lossy(b)),
|
||||||
|
ValueRef::Blob(b) => format!("b{}", sha256_hex(b)),
|
||||||
|
};
|
||||||
|
s.push_str(&v);
|
||||||
|
s.push('\u{1f}');
|
||||||
|
}
|
||||||
|
Ok(s)
|
||||||
|
})?
|
||||||
|
.collect::<Result<_, _>>()?;
|
||||||
|
rows.sort();
|
||||||
|
out.insert(t, rows);
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compare two projections; name the first table that differs.
|
||||||
|
pub(crate) fn compare_projections(
|
||||||
|
have: &std::collections::BTreeMap<String, Vec<String>>,
|
||||||
|
want: &std::collections::BTreeMap<String, Vec<String>>,
|
||||||
|
) -> Result<(), StoreError> {
|
||||||
|
for (table, rows) in want {
|
||||||
|
match have.get(table) {
|
||||||
|
None => {
|
||||||
|
return Err(StoreError::VerifyMismatch(format!(
|
||||||
|
"sqlite projection is missing table {table}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Some(h) if h != rows => {
|
||||||
|
let missing = rows.iter().filter(|r| !h.contains(r)).count();
|
||||||
|
let extra = h.iter().filter(|r| !rows.contains(r)).count();
|
||||||
|
return Err(StoreError::VerifyMismatch(format!(
|
||||||
|
"sqlite projection differs from the journal in table {table}: {missing} rows missing, {extra} unexpected"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Attachments in the projection, optionally only one entry's, by id.
|
/// Attachments in the projection, optionally only one entry's, by id.
|
||||||
pub(crate) fn query_attachments(
|
pub(crate) fn query_attachments(
|
||||||
conn: &Connection,
|
conn: &Connection,
|
||||||
|
|||||||
@@ -143,3 +143,45 @@ fn walk(dir: &std::path::Path) -> Vec<(String, u64)> {
|
|||||||
}
|
}
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_snapshot_checks_the_writers_database_and_files() {
|
||||||
|
use ledger_store::DiskCheck;
|
||||||
|
let dir = test_dir("disk");
|
||||||
|
let mut store = Store::create(&dir, Currency::USD).unwrap();
|
||||||
|
let cash = store
|
||||||
|
.add_account("1000", "Cash", AccountType::Asset, None, false)
|
||||||
|
.unwrap();
|
||||||
|
let equity = store
|
||||||
|
.add_account("3000", "Equity", AccountType::Equity, None, false)
|
||||||
|
.unwrap();
|
||||||
|
let e = post(&mut store, 1, cash, equity, 100);
|
||||||
|
store
|
||||||
|
.attach_receipt(Some(e), "r.png", "image/png", b"receipt-bytes", None)
|
||||||
|
.unwrap();
|
||||||
|
drop(store);
|
||||||
|
let snap = Snapshot::load(&dir).unwrap();
|
||||||
|
assert_eq!(snap.check_disk(), DiskCheck::Matches);
|
||||||
|
assert_eq!(snap.verify_blobs(), (1, vec![]));
|
||||||
|
// Someone edits the database behind the journal's back.
|
||||||
|
let conn = rusqlite::Connection::open(dir.join(ledger_store::DB_FILE)).unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE accounts SET name = 'Petty cash' WHERE code = '1000'",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
drop(conn);
|
||||||
|
match snap.check_disk() {
|
||||||
|
DiskCheck::Differs(why) => assert!(why.contains("accounts"), "{why}"),
|
||||||
|
other => panic!("drift not seen: {other:?}"),
|
||||||
|
}
|
||||||
|
// A damaged file is caught too.
|
||||||
|
let blob = walk(&dir.join("blobs"))
|
||||||
|
.into_iter()
|
||||||
|
.map(|(p, _)| p)
|
||||||
|
.find(|p| !p.ends_with(".tmp"))
|
||||||
|
.unwrap();
|
||||||
|
std::fs::write(&blob, b"tampered").unwrap();
|
||||||
|
let (ok, bad) = snap.verify_blobs();
|
||||||
|
assert_eq!((ok, bad.len()), (0, 1));
|
||||||
|
}
|
||||||
@@ -683,3 +683,47 @@ fn vendor_1099_tags_replay_and_project() {
|
|||||||
assert_eq!(form, "nec");
|
assert_eq!(form, "nec");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_whole_projection_is_checked_and_healed() {
|
||||||
|
let dir = test_dir("heal");
|
||||||
|
let mut store = build_books(&dir);
|
||||||
|
let e = store
|
||||||
|
.ledger()
|
||||||
|
.posted_entries()
|
||||||
|
.next()
|
||||||
|
.map(|e| e.id())
|
||||||
|
.unwrap();
|
||||||
|
store
|
||||||
|
.attach_receipt(Some(e), "r.png", "image/png", b"receipt", None)
|
||||||
|
.unwrap();
|
||||||
|
store.verify().unwrap();
|
||||||
|
drop(store);
|
||||||
|
// Drift no accounting report would notice: a receipt quietly unlinked.
|
||||||
|
let db = dir.join(ledger_store::DB_FILE);
|
||||||
|
let conn = rusqlite::Connection::open(&db).unwrap();
|
||||||
|
conn.execute("UPDATE attachments SET entry_id = NULL", [])
|
||||||
|
.unwrap();
|
||||||
|
drop(conn);
|
||||||
|
let mut store = Store::open(&dir).unwrap();
|
||||||
|
match store.verify() {
|
||||||
|
Err(StoreError::VerifyMismatch(why)) => assert!(why.contains("attachments"), "{why}"),
|
||||||
|
other => panic!("drift not caught: {other:?}"),
|
||||||
|
}
|
||||||
|
let report = store.verify_and_heal().unwrap();
|
||||||
|
assert!(report.healed.is_some(), "healed");
|
||||||
|
assert!(report.events > 0);
|
||||||
|
store.verify().unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
store.list_attachments(Some(e)).unwrap().len(),
|
||||||
|
1,
|
||||||
|
"link restored"
|
||||||
|
);
|
||||||
|
let kept = std::fs::read_dir(&dir)
|
||||||
|
.unwrap()
|
||||||
|
.filter_map(|d| d.ok())
|
||||||
|
.any(|d| d.file_name().to_string_lossy().contains(".mismatch."));
|
||||||
|
assert!(kept, "the divergent database is kept for forensics");
|
||||||
|
// A healthy store reports nothing to heal.
|
||||||
|
assert_eq!(store.verify_and_heal().unwrap().healed, None);
|
||||||
|
}
|
||||||
@@ -162,6 +162,17 @@ through the policy.
|
|||||||
- `books.run_recurring { through? }` → `{ drafts: [{ recurring_id, date,
|
- `books.run_recurring { through? }` → `{ drafts: [{ recurring_id, date,
|
||||||
entry_id }] }`: one draft per occurrence on or before `through`, once.
|
entry_id }] }`: one draft per occurrence on or before `through`, once.
|
||||||
|
|
||||||
|
## Tools — integrity
|
||||||
|
|
||||||
|
- `books.verify { blobs? }` → `{ status: "ok" | "healed", events, head,
|
||||||
|
healed?, blobs_verified?, checked }`. Verifies the journal's hash chain,
|
||||||
|
replays it against the live ledger, and compares every table of the
|
||||||
|
SQLite database row for row against a fresh projection of the journal.
|
||||||
|
A drifted database is rebuilt from the journal (the old file kept as
|
||||||
|
`wanderbooks.mismatch.<time>`). With `blobs` (default true), re-hashes
|
||||||
|
every stored file. The server runs the same check, and heals the same
|
||||||
|
way, every time it starts.
|
||||||
|
|
||||||
## Tools — payouts
|
## Tools — payouts
|
||||||
|
|
||||||
- `books.record_payout { date, description, deposit_account, deposit,
|
- `books.record_payout { date, description, deposit_account, deposit,
|
||||||
|
|||||||
+42
-3
@@ -3,23 +3,57 @@
|
|||||||
#
|
#
|
||||||
# curl -fsSL https://git.swaits.com/swaits/wanderbooks/raw/branch/main/install.sh | sh -s -- ~/Books/Acme
|
# curl -fsSL https://git.swaits.com/swaits/wanderbooks/raw/branch/main/install.sh | sh -s -- ~/Books/Acme
|
||||||
#
|
#
|
||||||
|
# Linux and macOS. On Windows, run it inside WSL2 (Ubuntu): the GUI opens as
|
||||||
|
# a normal window on Windows 11.
|
||||||
|
#
|
||||||
# Installs Rust if needed, then the wanderbooks CLI, MCP server, and GUI
|
# Installs Rust if needed, then the wanderbooks CLI, MCP server, and GUI
|
||||||
# (into ~/.cargo/bin), then — given a directory — makes your books there and
|
# (into ~/.cargo/bin), then — given a directory — makes your books there and
|
||||||
# starts the agent. Without a directory it just installs.
|
# starts the agent. Without a directory it just installs.
|
||||||
|
#
|
||||||
|
# WANDERBOOKS_REPO overrides where it installs from (a git URL).
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
REPO="https://git.swaits.com/swaits/wanderbooks"
|
REPO="${WANDERBOOKS_REPO:-https://git.swaits.com/swaits/wanderbooks}"
|
||||||
|
|
||||||
say() { printf '\033[1m%s\033[0m\n' "$*"; }
|
say() { printf '\033[1m%s\033[0m\n' "$*"; }
|
||||||
|
die() { printf '\033[1;31m%s\033[0m\n' "$*" >&2; exit 1; }
|
||||||
|
|
||||||
|
case "$(uname -s)" in
|
||||||
|
Linux) os=linux ;;
|
||||||
|
Darwin) os=macos ;;
|
||||||
|
MINGW* | MSYS* | CYGWIN*)
|
||||||
|
die "On Windows, install WSL2 (wsl --install), open Ubuntu, and run this there." ;;
|
||||||
|
*) die "Unsupported system: $(uname -s). wanderbooks runs on Linux and macOS (Windows via WSL2)." ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# A C compiler: SQLite and a few other pieces are compiled from source.
|
||||||
|
if ! command -v cc >/dev/null 2>&1 && ! command -v gcc >/dev/null 2>&1 && ! command -v clang >/dev/null 2>&1; then
|
||||||
|
if [ "$os" = macos ]; then
|
||||||
|
die "Needs Apple's command line tools. Run: xcode-select --install (then run this again)"
|
||||||
|
fi
|
||||||
|
hint="your distribution's C compiler package"
|
||||||
|
if command -v apt-get >/dev/null 2>&1; then hint="sudo apt-get install -y build-essential"
|
||||||
|
elif command -v dnf >/dev/null 2>&1; then hint="sudo dnf install -y gcc"
|
||||||
|
elif command -v pacman >/dev/null 2>&1; then hint="sudo pacman -S --needed base-devel"
|
||||||
|
elif command -v zypper >/dev/null 2>&1; then hint="sudo zypper install -y gcc"
|
||||||
|
elif command -v apk >/dev/null 2>&1; then hint="sudo apk add build-base"
|
||||||
|
fi
|
||||||
|
die "Needs a C compiler. Run: $hint (then run this again)"
|
||||||
|
fi
|
||||||
|
command -v git >/dev/null 2>&1 || die "Needs git. Install it, then run this again."
|
||||||
|
|
||||||
if ! command -v cargo >/dev/null 2>&1; then
|
if ! command -v cargo >/dev/null 2>&1; then
|
||||||
|
if [ -x "$HOME/.cargo/bin/cargo" ]; then
|
||||||
|
PATH="$HOME/.cargo/bin:$PATH"
|
||||||
|
else
|
||||||
say "Installing Rust (rustup)…"
|
say "Installing Rust (rustup)…"
|
||||||
curl -fsSL https://sh.rustup.rs | sh -s -- -y --profile minimal
|
curl -fsSL https://sh.rustup.rs | sh -s -- -y --profile minimal
|
||||||
# shellcheck disable=SC1091
|
# shellcheck disable=SC1091
|
||||||
. "$HOME/.cargo/env"
|
. "$HOME/.cargo/env"
|
||||||
fi
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
say "Installing wanderbooks (a few minutes, once)…"
|
say "Installing wanderbooks from $REPO (a few minutes, once)…"
|
||||||
cargo install --locked --git "$REPO" ledger-cli ledger-mcp ledger-gui
|
cargo install --locked --git "$REPO" ledger-cli ledger-mcp ledger-gui
|
||||||
|
|
||||||
if ! command -v just >/dev/null 2>&1; then
|
if ! command -v just >/dev/null 2>&1; then
|
||||||
@@ -28,9 +62,14 @@ if ! command -v just >/dev/null 2>&1; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
if ! command -v claude >/dev/null 2>&1; then
|
if ! command -v claude >/dev/null 2>&1; then
|
||||||
say "Claude Code isn't installed: see https://claude.com/claude-code"
|
say "Claude Code isn't installed yet: https://claude.com/claude-code"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
case ":$PATH:" in
|
||||||
|
*":$HOME/.cargo/bin:"*) ;;
|
||||||
|
*) say "Add ~/.cargo/bin to your PATH (rustup's installer usually does; open a new terminal)." ;;
|
||||||
|
esac
|
||||||
|
|
||||||
if [ $# -gt 0 ]; then
|
if [ $# -gt 0 ]; then
|
||||||
# Hand the terminal to the agent when there is one.
|
# Hand the terminal to the agent when there is one.
|
||||||
if [ -t 1 ] && [ -e /dev/tty ]; then
|
if [ -t 1 ] && [ -e /dev/tty ]; then
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# wanderbooks repo recipes. `just` lists them.
|
||||||
|
|
||||||
|
set shell := ["bash", "-cu"]
|
||||||
|
|
||||||
|
# List recipes
|
||||||
|
default:
|
||||||
|
@just --list
|
||||||
|
|
||||||
|
# Everything CI should check: format, clippy (zero warnings), tests
|
||||||
|
check:
|
||||||
|
cargo fmt --all --check
|
||||||
|
cargo clippy --workspace --all-targets -- -D warnings
|
||||||
|
cargo test --workspace
|
||||||
|
|
||||||
|
# Build the release binaries (CLI, MCP server, GUI)
|
||||||
|
build:
|
||||||
|
cargo build --release -p ledger-cli -p ledger-mcp -p ledger-gui
|
||||||
|
|
||||||
|
# Install the binaries from this checkout into ~/.cargo/bin
|
||||||
|
install:
|
||||||
|
for c in ledger-cli ledger-mcp ledger-gui; do cargo install --locked --path crates/$c; done
|
||||||
|
|
||||||
|
# Open the GUI on the demo company
|
||||||
|
demo:
|
||||||
|
cargo run --release -p ledger-gui -- --demo
|
||||||
|
|
||||||
|
# Regenerate the website's screenshots from the demo company
|
||||||
|
site-shots:
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
tmp=$(mktemp -d)
|
||||||
|
SHOT_DIR="$tmp/light" SHOT_ONLY=overview,entry,pnl SHOT_ENTRY=249 cargo test -p ledger-gui --test screens
|
||||||
|
SHOT_DIR="$tmp/dark" SHOT_DARK=1 SHOT_ONLY=overview cargo test -p ledger-gui --test screens
|
||||||
|
for f in overview entry pnl; do cp "$tmp/light/$f.png" site/assets/; done
|
||||||
|
cp "$tmp/dark/overview.png" site/assets/overview-dark.png
|
||||||
|
rm -rf "$tmp"
|
||||||
|
|
||||||
|
# Assemble the website in site/dist (index.html, install.sh, assets)
|
||||||
|
site:
|
||||||
|
rm -rf site/dist && mkdir -p site/dist
|
||||||
|
cp site/index.html install.sh site/dist/
|
||||||
|
cp -r site/assets site/dist/
|
||||||
|
|
||||||
|
# Publish the website: just publish user@host:/var/www/wanderbooks
|
||||||
|
publish dest: site
|
||||||
|
rsync -av --delete site/dist/ "{{dest}}"
|
||||||
@@ -274,3 +274,14 @@ interface is MCP, not a cloned GUI. The first storage + MCP slice is done
|
|||||||
.claude/settings.json (approved, no prompt), commits, and starts claude
|
.claude/settings.json (approved, no prompt), commits, and starts claude
|
||||||
with the onboarding prompt. `install.sh` does Rust + cargo install +
|
with the onboarding prompt. `install.sh` does Rust + cargo install +
|
||||||
`wanderbooks new` in one line. mise is no longer part of the scaffold.
|
`wanderbooks new` in one line. mise is no longer part of the scaffold.
|
||||||
|
- 2026-10-12: Integrity, end to end. Store::verify now also compares the
|
||||||
|
whole SQLite projection (every table, row for row) with a fresh
|
||||||
|
projection of the journal; verify_and_heal rebuilds a drifted database
|
||||||
|
(keeping the old file) and runs at every server start; books.verify
|
||||||
|
(65 tools) adds a re-hash of every blob. The GUI compares the server's
|
||||||
|
database (read-only) with its own projection and re-hashes each file
|
||||||
|
once, showing "Database matches journal · N files verified" and raising
|
||||||
|
problems in Needs attention. Also: Windows-safe blob writes (no
|
||||||
|
directory fsync off Unix), .exe-aware binary lookup, installer OS and
|
||||||
|
C-compiler checks with WANDERBOOKS_REPO, the website (site/, `just
|
||||||
|
site`, `just publish`), and a repo justfile (check, build, install, demo).
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 310 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 222 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 227 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 215 KiB |
+172
@@ -0,0 +1,172 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>wanderbooks — bookkeeping you talk to</title>
|
||||||
|
<meta name="description" content="Double-entry bookkeeping run by an AI bookkeeper. You talk; it keeps the books. You watch every number, live. Open source, your files, on your machine.">
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #f6f6f4; --surface: #fff; --border: #e4e4e0; --text: #1b1b1f;
|
||||||
|
--muted: #67676f; --faint: #9c9ca4; --accent: #4f46e5; --accent-soft: #eeeefd;
|
||||||
|
--code-bg: #1b1b1f; --code-text: #ececee; --positive: #15803d;
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root:not([data-theme="light"]) {
|
||||||
|
--bg: #111214; --surface: #191a1d; --border: #2a2b30; --text: #ececee;
|
||||||
|
--muted: #a3a3ac; --faint: #6e6e78; --accent: #8b93fa; --accent-soft: #242545;
|
||||||
|
--code-bg: #0b0b0d; --code-text: #ececee; --positive: #4ade80;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
html { -webkit-text-size-adjust: 100%; }
|
||||||
|
body {
|
||||||
|
margin: 0; background: var(--bg); color: var(--text);
|
||||||
|
font: 400 17px/1.6 Inter, system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||||
|
font-feature-settings: "tnum", "cv11";
|
||||||
|
}
|
||||||
|
a { color: var(--accent); text-decoration: none; }
|
||||||
|
a:hover { text-decoration: underline; }
|
||||||
|
.wrap { max-width: 1120px; margin: 0 auto; padding: 0 24px; }
|
||||||
|
header { display: flex; align-items: center; justify-content: space-between; padding: 22px 0; }
|
||||||
|
.brand { display: flex; align-items: center; gap: 10px; font-weight: 600; font-size: 17px; color: var(--text); }
|
||||||
|
.brand .mark { width: 28px; height: 28px; border-radius: 8px; background: var(--accent); color: #fff; display: grid; place-items: center; font-weight: 700; }
|
||||||
|
nav a { color: var(--muted); margin-left: 22px; font-size: 15px; }
|
||||||
|
.hero { padding: 56px 0 36px; max-width: 820px; }
|
||||||
|
h1 { font-size: clamp(38px, 6vw, 60px); line-height: 1.05; letter-spacing: -0.03em; margin: 0 0 20px; font-weight: 700; }
|
||||||
|
.lede { font-size: clamp(18px, 2.2vw, 21px); color: var(--muted); margin: 0 0 30px; max-width: 700px; }
|
||||||
|
.install { background: var(--code-bg); color: var(--code-text); border-radius: 12px; padding: 16px 18px; display: flex; gap: 14px; align-items: flex-start; font: 500 14.5px/1.55 ui-monospace, "SF Mono", Menlo, Consolas, monospace; }
|
||||||
|
.install code { flex: 1; min-width: 0; white-space: normal; overflow-wrap: anywhere; }
|
||||||
|
.install .prompt { color: #8b93fa; user-select: none; }
|
||||||
|
.copy { flex: none; background: #2a2b30; color: #ececee; border: 0; border-radius: 8px; padding: 7px 12px; font: 500 13px Inter, sans-serif; cursor: pointer; }
|
||||||
|
.copy:hover { background: #3a3b42; }
|
||||||
|
.note { color: var(--faint); font-size: 14px; margin-top: 12px; }
|
||||||
|
.shot { margin: 40px 0 8px; border-radius: 14px; border: 1px solid var(--border); overflow: hidden; box-shadow: 0 24px 60px -24px rgba(0,0,0,.25); background: var(--surface); }
|
||||||
|
.shot img { display: block; width: 100%; height: auto; }
|
||||||
|
.shot.dark { display: none; }
|
||||||
|
@media (prefers-color-scheme: dark) { .shot.light { display: none; } .shot.dark { display: block; } }
|
||||||
|
.caption { color: var(--faint); font-size: 14px; text-align: center; margin: 10px 0 0; }
|
||||||
|
section { padding: 64px 0 8px; }
|
||||||
|
h2 { font-size: clamp(26px, 3.4vw, 34px); letter-spacing: -0.02em; line-height: 1.15; margin: 0 0 14px; }
|
||||||
|
.sub { color: var(--muted); max-width: 720px; margin: 0 0 32px; }
|
||||||
|
.grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 18px; }
|
||||||
|
.card { background: var(--surface); border: 1px solid var(--border); border-radius: 14px; padding: 22px; }
|
||||||
|
.card h3 { margin: 0 0 8px; font-size: 18px; }
|
||||||
|
.card p { margin: 0; color: var(--muted); font-size: 15.5px; }
|
||||||
|
.card .k { color: var(--accent); font-weight: 600; font-size: 13px; letter-spacing: .04em; text-transform: uppercase; margin-bottom: 8px; }
|
||||||
|
.two { display: grid; grid-template-columns: 1fr 1fr; gap: 28px; align-items: center; }
|
||||||
|
.two .shot { margin: 0; }
|
||||||
|
ul.ticks { list-style: none; padding: 0; margin: 0; }
|
||||||
|
ul.ticks li { padding: 7px 0 7px 28px; position: relative; color: var(--muted); }
|
||||||
|
ul.ticks li b { color: var(--text); font-weight: 600; }
|
||||||
|
ul.ticks li::before { content: "✓"; position: absolute; left: 0; color: var(--positive); font-weight: 700; }
|
||||||
|
.platforms { display: grid; grid-template-columns: repeat(3, 1fr); gap: 18px; }
|
||||||
|
.platforms .card p code { font-size: 13.5px; }
|
||||||
|
code.inline { font: 500 14px ui-monospace, Menlo, Consolas, monospace; background: var(--accent-soft); padding: 2px 6px; border-radius: 6px; color: var(--text); }
|
||||||
|
footer { padding: 64px 0 48px; color: var(--faint); font-size: 14px; display: flex; justify-content: space-between; gap: 20px; flex-wrap: wrap; }
|
||||||
|
@media (max-width: 860px) {
|
||||||
|
.grid, .platforms, .two { grid-template-columns: 1fr; }
|
||||||
|
nav { display: none; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="wrap">
|
||||||
|
<header>
|
||||||
|
<a class="brand" href="#"><span class="mark">w</span>wanderbooks</a>
|
||||||
|
<nav>
|
||||||
|
<a href="#how">How it works</a>
|
||||||
|
<a href="#trust">Trust</a>
|
||||||
|
<a href="#install">Install</a>
|
||||||
|
<a href="https://git.swaits.com/swaits/wanderbooks">Source</a>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="hero">
|
||||||
|
<h1>Bookkeeping you talk to.</h1>
|
||||||
|
<p class="lede">wanderbooks is real double-entry accounting run by an AI bookkeeper. You tell it what happened and hand it receipts; it keeps the books, balanced to the cent. You watch every number, live, and click through any of them to the receipt behind it.</p>
|
||||||
|
<div class="install" id="install-box">
|
||||||
|
<span class="prompt">$</span>
|
||||||
|
<code id="cmd">curl -fsSL https://git.swaits.com/swaits/wanderbooks/raw/branch/main/install.sh | sh -s -- ~/Books/YourCompany</code>
|
||||||
|
<button class="copy" id="copy">Copy</button>
|
||||||
|
</div>
|
||||||
|
<p class="note">One line: installs everything, makes your books, and starts your bookkeeper. Linux and macOS; Windows via WSL2.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="shot light"><img src="assets/overview.png" alt="The wanderbooks overview: cash, what you owe, spending, revenue, runway, a cash chart, and what needs attention" width="1440" height="920"></div>
|
||||||
|
<div class="shot dark"><img src="assets/overview-dark.png" alt="The wanderbooks overview in dark mode" width="1440" height="920"></div>
|
||||||
|
<p class="caption">The overview of a demo company. It leads with the answer, then the evidence.</p>
|
||||||
|
|
||||||
|
<section id="how">
|
||||||
|
<h2>You talk. It keeps the books. You see everything.</h2>
|
||||||
|
<p class="sub">No forms, no categories to hunt for, no month-end scramble. The bookkeeper works through an accounting engine that refuses to be wrong, and the books are always open in front of you.</p>
|
||||||
|
<div class="grid">
|
||||||
|
<div class="card"><div class="k">Talk</div><h3>Your bookkeeper is an AI agent</h3><p>“Record the Apple developer fee, the receipt's in my email.” It drafts the entry, shows you the preview, attaches the receipt, and posts when you say so. Claude Code, via MCP.</p></div>
|
||||||
|
<div class="card"><div class="k">See</div><h3>A desktop view of everything</h3><p>Cash and runway, profit by month, every transaction with its rendered receipt, statements an accountant would sign off on. Changes the agent makes appear in milliseconds.</p></div>
|
||||||
|
<div class="card"><div class="k">Own</div><h3>Your books are files you keep</h3><p>An append-only journal, your receipts, and a config file, in a folder under git. No subscription, no lock-in, no cloud you don't control.</p></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<div class="two">
|
||||||
|
<div>
|
||||||
|
<h2>Every number goes all the way down.</h2>
|
||||||
|
<p class="sub">Click an amount on a statement and you get the transactions behind it, for exactly those dates. Click a transaction and you get its lines, the receipt itself, and every event in its history.</p>
|
||||||
|
<ul class="ticks">
|
||||||
|
<li><b>Statements an accountant expects:</b> profit and loss, balance sheet, cash flow, owners' equity, trial balance, general ledger.</li>
|
||||||
|
<li><b>By month, quarter, or year</b>, compared with the prior period, each line as a share of income.</li>
|
||||||
|
<li><b>Tax-ready:</b> tax categories by return (Schedule C, 1065, 1120-S, 1120), a 1099 worksheet, receivables and payables aging.</li>
|
||||||
|
<li><b>Real-business features:</b> invoices and bills, fixed assets and depreciation, prepaid expenses, recurring entries, platform payouts grossed up, classes, multi-currency lines.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="shot"><img src="assets/entry.png" alt="A transaction with its lines, its rendered receipt, and its history" width="1440" height="920"></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="trust">
|
||||||
|
<h2>Built so you can trust it.</h2>
|
||||||
|
<p class="sub">An AI is doing your books, so the books have to be impossible to quietly get wrong.</p>
|
||||||
|
<div class="grid">
|
||||||
|
<div class="card"><h3>The engine enforces accounting</h3><p>Every entry balances exactly; money is exact decimal, never floating point. Posted entries are permanent; corrections are reversing entries. Receipts are required over your threshold, enforced by the server.</p></div>
|
||||||
|
<div class="card"><h3>A tamper-evident journal</h3><p>Every change is an event, hash-chained to the one before it. The database is rebuilt from the journal and checked against it, table by table; every receipt is checked against its hash. The app shows you the result.</p></div>
|
||||||
|
<div class="card"><h3>You approve what matters</h3><p>The agent previews every posting before it happens. The desktop view can't change anything; when it spots a problem it hands you a prompt for the agent.</p></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="install">
|
||||||
|
<h2>Install</h2>
|
||||||
|
<p class="sub">The one line above does everything. It installs Rust if you don't have it, builds wanderbooks, makes your books folder with the bookkeeper wired in, and starts a session to set up your company.</p>
|
||||||
|
<div class="platforms">
|
||||||
|
<div class="card"><h3>Linux</h3><p>Needs a C compiler and git (<code class="inline">build-essential</code> on Debian and Ubuntu). The installer tells you if anything's missing.</p></div>
|
||||||
|
<div class="card"><h3>macOS</h3><p>Needs Apple's command line tools: <code class="inline">xcode-select --install</code>.</p></div>
|
||||||
|
<div class="card"><h3>Windows</h3><p>Run it inside WSL2 (<code class="inline">wsl --install</code>, then Ubuntu). On Windows 11 the desktop view opens as a normal window. Native Windows is on the way.</p></div>
|
||||||
|
</div>
|
||||||
|
<p class="note" style="margin-top:24px">Already installed? <code class="inline">wanderbooks new ~/Books/AnotherCompany</code>. Inside a books folder, <code class="inline">just gui</code> opens the desktop view and <code class="inline">just agent</code> starts your bookkeeper. You'll need <a href="https://claude.com/claude-code">Claude Code</a>.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
<span>wanderbooks is open source (MIT or Apache-2.0). It keeps books; it isn't tax or legal advice.</span>
|
||||||
|
<span><a href="https://git.swaits.com/swaits/wanderbooks">Source</a></span>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
// When served next to install.sh, point the one-liner at this site.
|
||||||
|
(function () {
|
||||||
|
var cmd = document.getElementById("cmd");
|
||||||
|
if (location.protocol === "https:") {
|
||||||
|
cmd.textContent = "curl -fsSL " + location.origin + "/install.sh | sh -s -- ~/Books/YourCompany";
|
||||||
|
}
|
||||||
|
document.getElementById("copy").addEventListener("click", function () {
|
||||||
|
var b = this;
|
||||||
|
navigator.clipboard.writeText(cmd.textContent).then(function () {
|
||||||
|
b.textContent = "Copied";
|
||||||
|
setTimeout(function () { b.textContent = "Copy"; }, 1500);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in new issue
Block a user