Add gateway root adoption and mobile polish
- Add Tauri commands to inspect and adopt the gateway repo root - Retry locked vault commands by prompting for unlock - Improve mobile layout, editor mode toggles, and settings UI
This commit is contained in:
parent
4fb83a003e
commit
7562cf6fad
2
.gitignore
vendored
2
.gitignore
vendored
@ -58,3 +58,5 @@ Journal.DevTool/scripts/__pycache__/
|
||||
devtool.backup.json
|
||||
Journal.App/node_modules.old/
|
||||
scripts/__pycache__/
|
||||
Journal.WebGateway/cookies.txt
|
||||
output.7z
|
||||
|
||||
296
Journal.App/src-tauri/Cargo.lock
generated
296
Journal.App/src-tauri/Cargo.lock
generated
@ -530,6 +530,12 @@ version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "cfg_aliases"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
||||
|
||||
[[package]]
|
||||
name = "chrono"
|
||||
version = "0.4.44"
|
||||
@ -1402,8 +1408,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"wasi 0.11.1+wasi-snapshot-preview1",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -1413,9 +1421,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"r-efi",
|
||||
"wasip2",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -1702,6 +1712,23 @@ dependencies = [
|
||||
"want",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-rustls"
|
||||
version = "0.27.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58"
|
||||
dependencies = [
|
||||
"http",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tower-service",
|
||||
"webpki-roots",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-util"
|
||||
version = "0.1.20"
|
||||
@ -2020,6 +2047,7 @@ dependencies = [
|
||||
name = "journalapp"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"reqwest 0.12.28",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
@ -2184,6 +2212,12 @@ version = "0.4.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
||||
|
||||
[[package]]
|
||||
name = "lru-slab"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
||||
|
||||
[[package]]
|
||||
name = "mac"
|
||||
version = "0.1.1"
|
||||
@ -3083,6 +3117,61 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn"
|
||||
version = "0.11.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"cfg_aliases",
|
||||
"pin-project-lite",
|
||||
"quinn-proto",
|
||||
"quinn-udp",
|
||||
"rustc-hash",
|
||||
"rustls",
|
||||
"socket2",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn-proto"
|
||||
version = "0.11.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"getrandom 0.3.4",
|
||||
"lru-slab",
|
||||
"rand 0.9.2",
|
||||
"ring",
|
||||
"rustc-hash",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"slab",
|
||||
"thiserror 2.0.18",
|
||||
"tinyvec",
|
||||
"tracing",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn-udp"
|
||||
version = "0.5.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd"
|
||||
dependencies = [
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"socket2",
|
||||
"tracing",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.44"
|
||||
@ -3123,6 +3212,16 @@ dependencies = [
|
||||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.9.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
|
||||
dependencies = [
|
||||
"rand_chacha 0.9.0",
|
||||
"rand_core 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_chacha"
|
||||
version = "0.2.2"
|
||||
@ -3143,6 +3242,16 @@ dependencies = [
|
||||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_chacha"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
|
||||
dependencies = [
|
||||
"ppv-lite86",
|
||||
"rand_core 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.5.1"
|
||||
@ -3161,6 +3270,15 @@ dependencies = [
|
||||
"getrandom 0.2.17",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
|
||||
dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_hc"
|
||||
version = "0.2.0"
|
||||
@ -3254,6 +3372,44 @@ version = "0.8.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
|
||||
|
||||
[[package]]
|
||||
name = "reqwest"
|
||||
version = "0.12.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-rustls",
|
||||
"hyper-util",
|
||||
"js-sys",
|
||||
"log",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"quinn",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_urlencoded",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tower",
|
||||
"tower-http",
|
||||
"tower-service",
|
||||
"url",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
"webpki-roots",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "reqwest"
|
||||
version = "0.13.2"
|
||||
@ -3312,6 +3468,20 @@ dependencies = [
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ring"
|
||||
version = "0.17.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"cfg-if",
|
||||
"getrandom 0.2.17",
|
||||
"libc",
|
||||
"untrusted",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustc-hash"
|
||||
version = "2.1.1"
|
||||
@ -3340,12 +3510,53 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.23.37"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"rustls-webpki",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-pki-types"
|
||||
version = "1.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd"
|
||||
dependencies = [
|
||||
"web-time",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.103.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef"
|
||||
dependencies = [
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"untrusted",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustversion"
|
||||
version = "1.0.22"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
|
||||
|
||||
[[package]]
|
||||
name = "ryu"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
|
||||
|
||||
[[package]]
|
||||
name = "same-file"
|
||||
version = "1.0.6"
|
||||
@ -3535,6 +3746,18 @@ dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_urlencoded"
|
||||
version = "0.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
|
||||
dependencies = [
|
||||
"form_urlencoded",
|
||||
"itoa",
|
||||
"ryu",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_with"
|
||||
version = "3.17.0"
|
||||
@ -3750,6 +3973,12 @@ version = "0.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||
|
||||
[[package]]
|
||||
name = "subtle"
|
||||
version = "2.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||
|
||||
[[package]]
|
||||
name = "swift-rs"
|
||||
version = "1.0.7"
|
||||
@ -3903,7 +4132,7 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
"plist",
|
||||
"raw-window-handle",
|
||||
"reqwest",
|
||||
"reqwest 0.13.2",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_repr",
|
||||
@ -4288,6 +4517,21 @@ dependencies = [
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tinyvec"
|
||||
version = "1.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3"
|
||||
dependencies = [
|
||||
"tinyvec_macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tinyvec_macros"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
|
||||
|
||||
[[package]]
|
||||
name = "tokio"
|
||||
version = "1.49.0"
|
||||
@ -4303,6 +4547,16 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-rustls"
|
||||
version = "0.26.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
|
||||
dependencies = [
|
||||
"rustls",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-util"
|
||||
version = "0.7.18"
|
||||
@ -4598,6 +4852,12 @@ version = "0.2.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
||||
|
||||
[[package]]
|
||||
name = "untrusted"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
|
||||
|
||||
[[package]]
|
||||
name = "url"
|
||||
version = "2.5.8"
|
||||
@ -4850,6 +5110,16 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "web-time"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webkit2gtk"
|
||||
version = "2.0.2"
|
||||
@ -4894,6 +5164,15 @@ dependencies = [
|
||||
"system-deps",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "1.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webview2-com"
|
||||
version = "0.38.2"
|
||||
@ -5153,6 +5432,15 @@ dependencies = [
|
||||
"windows-targets 0.42.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.52.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
|
||||
dependencies = [
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.59.0"
|
||||
@ -5697,6 +5985,12 @@ dependencies = [
|
||||
"synstructure",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zeroize"
|
||||
version = "1.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
|
||||
|
||||
[[package]]
|
||||
name = "zerotrie"
|
||||
version = "0.2.3"
|
||||
|
||||
@ -25,3 +25,4 @@ serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tokio = { version = "1", features = ["process", "io-util", "sync", "time"] }
|
||||
tauri-plugin-mic-recorder = "2.0.0"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
||||
|
||||
@ -29,7 +29,6 @@ struct CommandEnvelope {
|
||||
const DEFAULT_SETTINGS_TAGS: &[&str] = &["Personal", "Work", "Ideas", "Journal"];
|
||||
const DEFAULT_FRAGMENT_TYPES: &[&str] = &["Quote", "Snippet", "Reference"];
|
||||
const DEFAULT_STARTUP_VIEW: &str = "entries";
|
||||
|
||||
#[derive(Deserialize, Serialize)]
|
||||
struct AppSettings {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
@ -213,17 +212,77 @@ fn save_settings(path: &Path, settings: &AppSettings) -> Result<(), String> {
|
||||
fs::write(path, json).map_err(|e| format!("Failed to save settings: {e}"))
|
||||
}
|
||||
|
||||
fn auto_detect_root() -> Result<PathBuf, String> {
|
||||
let mut current =
|
||||
env::current_dir().map_err(|err| format!("Unable to read current dir: {err}"))?;
|
||||
loop {
|
||||
if current.join("Journal.Sidecar").exists() {
|
||||
return Ok(current);
|
||||
fn candidate_roots(root: &Path) -> Vec<PathBuf> {
|
||||
let mut candidates = Vec::new();
|
||||
let push_unique = |value: PathBuf, items: &mut Vec<PathBuf>| {
|
||||
if !items.iter().any(|existing| existing == &value) {
|
||||
items.push(value);
|
||||
}
|
||||
if !current.pop() {
|
||||
return Err("Unable to locate repository root containing Journal.Sidecar.".to_string());
|
||||
};
|
||||
|
||||
push_unique(root.to_path_buf(), &mut candidates);
|
||||
|
||||
if let Some(name) = root.file_name().and_then(|v| v.to_str()) {
|
||||
if name.eq_ignore_ascii_case("output") {
|
||||
if let Some(parent) = root.parent() {
|
||||
push_unique(parent.to_path_buf(), &mut candidates);
|
||||
}
|
||||
} else if name.eq_ignore_ascii_case("webgateway") {
|
||||
if let Some(parent) = root.parent() {
|
||||
push_unique(parent.to_path_buf(), &mut candidates);
|
||||
}
|
||||
if let Some(parent) = root.parent().and_then(|v| v.parent()) {
|
||||
push_unique(parent.to_path_buf(), &mut candidates);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
candidates
|
||||
}
|
||||
|
||||
fn detect_root_from(start: &Path) -> Option<PathBuf> {
|
||||
let mut current = start.to_path_buf();
|
||||
loop {
|
||||
let has_repo_markers = current.join("Journal.Sidecar").exists()
|
||||
|| current.join("Journal.Core").exists()
|
||||
|| current.join("Journal.slnx").exists()
|
||||
|| current.join("Journal.Sidecar.exe").exists()
|
||||
|| current.join("Journal.Sidecar").is_file();
|
||||
if has_repo_markers {
|
||||
return Some(current);
|
||||
}
|
||||
|
||||
if !current.pop() {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn auto_detect_root() -> Result<PathBuf, String> {
|
||||
if let Some(env_root) = env::var_os("JOURNAL_PROJECT_ROOT") {
|
||||
let env_path = PathBuf::from(env_root);
|
||||
if env_path.exists() {
|
||||
return Ok(env_path);
|
||||
}
|
||||
}
|
||||
|
||||
let mut starts = Vec::new();
|
||||
if let Ok(current) = env::current_dir() {
|
||||
starts.push(current);
|
||||
}
|
||||
if let Ok(exe) = env::current_exe() {
|
||||
if let Some(parent) = exe.parent() {
|
||||
starts.push(parent.to_path_buf());
|
||||
}
|
||||
}
|
||||
|
||||
for start in starts {
|
||||
if let Some(root) = detect_root_from(&start) {
|
||||
return Ok(root);
|
||||
}
|
||||
}
|
||||
|
||||
Err("Unable to locate repository root containing Journal.Sidecar.".to_string())
|
||||
}
|
||||
|
||||
fn effective_root(root_override: &Option<PathBuf>) -> Result<PathBuf, String> {
|
||||
@ -243,9 +302,9 @@ fn resolve_sidecar_path(root: &Path, resource_dir: Option<&Path>) -> Result<Path
|
||||
return Ok(root.to_path_buf());
|
||||
}
|
||||
|
||||
let root_exe_path = root.join(exe_name);
|
||||
if root_exe_path.exists() {
|
||||
return Ok(root_exe_path);
|
||||
let direct = root.join(exe_name);
|
||||
if direct.exists() {
|
||||
return Ok(direct);
|
||||
}
|
||||
|
||||
let tauri_bin_sidecar_path = root
|
||||
@ -324,6 +383,37 @@ fn find_sidecar_executable(search_root: &Path, exe_name: &str) -> Option<PathBuf
|
||||
None
|
||||
}
|
||||
|
||||
fn resolve_gateway_appsettings_path(root: &Path) -> Result<PathBuf, String> {
|
||||
for candidate_root in candidate_roots(root) {
|
||||
let candidates = [
|
||||
candidate_root.join("webgateway").join("appsettings.json"),
|
||||
candidate_root.join("output").join("webgateway").join("appsettings.json"),
|
||||
candidate_root.join("Journal.WebGateway").join("appsettings.json"),
|
||||
];
|
||||
|
||||
for candidate in candidates {
|
||||
if candidate.exists() {
|
||||
return Ok(candidate);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(format!(
|
||||
"Gateway appsettings.json not found near {}.",
|
||||
root.display()
|
||||
))
|
||||
}
|
||||
|
||||
fn read_gateway_repo_root(config_path: &Path) -> Option<String> {
|
||||
let json = fs::read_to_string(config_path).ok()?;
|
||||
let value = serde_json::from_str::<Value>(&json).ok()?;
|
||||
value
|
||||
.get("GatewaySettings")
|
||||
.and_then(|section| section.get("RepoRoot"))
|
||||
.and_then(|node| node.as_str())
|
||||
.map(|value| value.to_string())
|
||||
}
|
||||
|
||||
async fn send_with_managed_sidecar(
|
||||
state: &SidecarState,
|
||||
input_line: &str,
|
||||
@ -417,6 +507,64 @@ async fn get_sidecar_root(state: tauri::State<'_, SidecarState>) -> Result<Value
|
||||
}))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn get_gateway_root_status(
|
||||
state: tauri::State<'_, SidecarState>,
|
||||
) -> Result<Value, String> {
|
||||
let root_override = state.root_override.lock().await.clone();
|
||||
let root = effective_root(&root_override)?;
|
||||
let config_path = resolve_gateway_appsettings_path(&root)?;
|
||||
let configured_root = read_gateway_repo_root(&config_path);
|
||||
let authoritative_root = root.to_string_lossy().into_owned();
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"authoritativeRoot": authoritative_root,
|
||||
"gatewayConfigPath": config_path.to_string_lossy(),
|
||||
"configuredRoot": configured_root,
|
||||
"needsAdoption": configured_root.as_deref() != Some(root.to_string_lossy().as_ref())
|
||||
}))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn adopt_sidecar_root_for_gateway(
|
||||
state: tauri::State<'_, SidecarState>,
|
||||
) -> Result<Value, String> {
|
||||
let root_override = state.root_override.lock().await.clone();
|
||||
let root = effective_root(&root_override)?;
|
||||
let config_path = resolve_gateway_appsettings_path(&root)?;
|
||||
let previous_root = read_gateway_repo_root(&config_path);
|
||||
|
||||
let json = fs::read_to_string(&config_path)
|
||||
.map_err(|err| format!("Failed to read gateway appsettings: {err}"))?;
|
||||
let mut value = serde_json::from_str::<Value>(&json)
|
||||
.map_err(|err| format!("Invalid gateway appsettings JSON: {err}"))?;
|
||||
|
||||
let root_value = Value::String(root.to_string_lossy().into_owned());
|
||||
let object = value
|
||||
.as_object_mut()
|
||||
.ok_or_else(|| "Gateway appsettings root must be a JSON object.".to_string())?;
|
||||
let gateway_settings = object
|
||||
.entry("GatewaySettings")
|
||||
.or_insert_with(|| Value::Object(serde_json::Map::new()));
|
||||
let settings_object = gateway_settings
|
||||
.as_object_mut()
|
||||
.ok_or_else(|| "GatewaySettings must be a JSON object.".to_string())?;
|
||||
settings_object.insert("RepoRoot".to_string(), root_value);
|
||||
|
||||
let updated = serde_json::to_string_pretty(&value)
|
||||
.map_err(|err| format!("Failed to serialize gateway appsettings: {err}"))?;
|
||||
fs::write(&config_path, updated)
|
||||
.map_err(|err| format!("Failed to write gateway appsettings: {err}"))?;
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"authoritativeRoot": root.to_string_lossy(),
|
||||
"gatewayConfigPath": config_path.to_string_lossy(),
|
||||
"previousRoot": previous_root,
|
||||
"configuredRoot": root.to_string_lossy(),
|
||||
"needsAdoption": false
|
||||
}))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn set_sidecar_root(
|
||||
state: tauri::State<'_, SidecarState>,
|
||||
@ -442,7 +590,6 @@ async fn set_sidecar_root(
|
||||
let mut guard = state.process.lock().await;
|
||||
guard.take();
|
||||
}
|
||||
|
||||
let is_custom = new_override.is_some();
|
||||
*state.root_override.lock().await = new_override.clone();
|
||||
|
||||
@ -670,6 +817,8 @@ pub fn run() {
|
||||
speech_stop,
|
||||
speech_cleanup_probe,
|
||||
get_sidecar_root,
|
||||
get_gateway_root_status,
|
||||
adopt_sidecar_root_for_gateway,
|
||||
set_sidecar_root,
|
||||
get_ui_settings,
|
||||
set_ui_settings,
|
||||
@ -679,7 +828,7 @@ pub fn run() {
|
||||
fs::create_dir_all(&config_dir).ok();
|
||||
let config_path = config_dir.join("settings.json");
|
||||
let settings = load_settings(&config_path);
|
||||
let root_override = settings.sidecar_root.map(PathBuf::from);
|
||||
let root_override = settings.sidecar_root.as_ref().map(PathBuf::from);
|
||||
|
||||
app.manage(SidecarState {
|
||||
process: Mutex::new(None),
|
||||
|
||||
@ -7,8 +7,11 @@
|
||||
rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200"
|
||||
/>
|
||||
<link rel="stylesheet" href="style.css" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="stylesheet" href="style.css?v=20260328b" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1, viewport-fit=cover"
|
||||
/>
|
||||
<title>Journal</title>
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
|
||||
@ -1,4 +1,8 @@
|
||||
import { invoke } from "$lib/runtime/invoke";
|
||||
import {
|
||||
clearVaultSession,
|
||||
requestVaultUnlock,
|
||||
} from "$lib/stores/session";
|
||||
import type { BackendCommand, BackendResponse } from "./types";
|
||||
|
||||
function newCorrelationId(): string {
|
||||
@ -7,8 +11,13 @@ function newCorrelationId(): string {
|
||||
|
||||
type SendCommandOptions = {
|
||||
keepalive?: boolean;
|
||||
unlockRetryAttempted?: boolean;
|
||||
};
|
||||
|
||||
function isDatabaseLockedError(message: string): boolean {
|
||||
return message.toLowerCase().includes("database is locked");
|
||||
}
|
||||
|
||||
export async function sendCommand<T>(
|
||||
command: BackendCommand,
|
||||
options: SendCommandOptions = {},
|
||||
@ -23,7 +32,22 @@ export async function sendCommand<T>(
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(response.error || "Backend command failed");
|
||||
const errorMessage = response.error || "Backend command failed";
|
||||
if (
|
||||
!options.unlockRetryAttempted &&
|
||||
isDatabaseLockedError(errorMessage)
|
||||
) {
|
||||
clearVaultSession();
|
||||
const unlocked = await requestVaultUnlock();
|
||||
if (unlocked) {
|
||||
return sendCommand<T>(command, {
|
||||
...options,
|
||||
unlockRetryAttempted: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
return response.data;
|
||||
|
||||
@ -162,4 +162,29 @@
|
||||
background: var(--surface-3);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
@media (max-width: 820px) {
|
||||
.modal-backdrop {
|
||||
align-items: end;
|
||||
padding: 12px 12px calc(12px + env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
|
||||
.modal {
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
border-radius: 16px;
|
||||
padding: 16px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
flex-direction: column-reverse;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.modal-actions button {
|
||||
width: 100%;
|
||||
min-height: 42px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -35,6 +35,8 @@
|
||||
export let calendarError = "";
|
||||
export let previewOnly = true;
|
||||
export let onForceSave: () => Promise<void> | void = () => {};
|
||||
export let onRequestEdit: () => void = () => {};
|
||||
export let onRequestPreview: () => void = () => {};
|
||||
|
||||
type CalendarCard = {
|
||||
id: string;
|
||||
@ -203,6 +205,8 @@
|
||||
{onForceSave}
|
||||
{onOpenDocument}
|
||||
{previewOnly}
|
||||
{onRequestEdit}
|
||||
{onRequestPreview}
|
||||
/>
|
||||
{/if}
|
||||
</main>
|
||||
@ -377,4 +381,28 @@
|
||||
border-color: color-mix(in srgb, #f2c266 40%, var(--border-soft) 60%);
|
||||
color: #f4d690;
|
||||
}
|
||||
|
||||
@media (max-width: 820px) {
|
||||
.editor-panel {
|
||||
padding: 12px 12px calc(18px + env(safe-area-inset-bottom, 0px));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.calendar-main {
|
||||
padding: 2px 0 12px;
|
||||
}
|
||||
|
||||
.calendar-item-head {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.calendar-item-head h3 {
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.calendar-date {
|
||||
white-space: normal;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -191,4 +191,44 @@
|
||||
height: 40px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 820px) {
|
||||
.navbar {
|
||||
gap: 10px;
|
||||
padding: calc(10px + env(safe-area-inset-top, 0px)) 6px 10px;
|
||||
}
|
||||
|
||||
.navbar-header {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.nav-groups {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.nav-group {
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.nav-button,
|
||||
.settings-chip {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 9px;
|
||||
}
|
||||
|
||||
.settings-chip {
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.nav-tooltip {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.nav-button .material-symbols-outlined,
|
||||
.settings-chip .material-symbols-outlined {
|
||||
font-size: 1.08rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -1818,7 +1818,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
@media (max-width: 1100px) {
|
||||
.side-panel {
|
||||
padding: 12px 10px;
|
||||
}
|
||||
@ -1833,7 +1833,31 @@
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
@media (max-width: 820px) {
|
||||
.side-panel {
|
||||
padding: 12px 12px calc(14px + env(safe-area-inset-bottom, 0px));
|
||||
gap: 10px;
|
||||
overflow: auto;
|
||||
border-right: 0;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 5;
|
||||
padding-bottom: 6px;
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
color-mix(in srgb, var(--surface-2) 96%, transparent 4%) 0%,
|
||||
color-mix(in srgb, var(--surface-2) 80%, transparent 20%) 100%
|
||||
);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.panel-list {
|
||||
padding-bottom: 6px;
|
||||
}
|
||||
|
||||
.calendar-control-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
@ -29,6 +29,8 @@
|
||||
export let openDocumentContent = "";
|
||||
export let onDocumentContentChange: (content: string) => void = () => {};
|
||||
export let onForceSave: () => Promise<void> | void = () => {};
|
||||
export let onRequestEdit: () => void = () => {};
|
||||
export let onRequestPreview: () => void = () => {};
|
||||
export let onOpenDocument: (doc: {
|
||||
id: string;
|
||||
label: string;
|
||||
@ -677,6 +679,33 @@
|
||||
|
||||
<header class="editor-header">
|
||||
<h1>{editorTitle}</h1>
|
||||
{#if openDocumentId}
|
||||
<div class="editor-header-actions">
|
||||
{#if previewOnly}
|
||||
<button
|
||||
type="button"
|
||||
class="editor-mode-btn"
|
||||
on:click={onRequestEdit}
|
||||
aria-label="Edit document"
|
||||
>
|
||||
<span class="material-symbols-outlined" aria-hidden="true">edit</span>
|
||||
<span>Edit</span>
|
||||
</button>
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
class="editor-mode-btn"
|
||||
on:click={onRequestPreview}
|
||||
aria-label="Preview document"
|
||||
>
|
||||
<span class="material-symbols-outlined" aria-hidden="true"
|
||||
>visibility</span
|
||||
>
|
||||
<span>Preview</span>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
<section class="editor-surface" class:preview-only={previewOnly}>
|
||||
@ -862,6 +891,34 @@
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.editor-header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.editor-mode-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: 8px;
|
||||
background: color-mix(in srgb, var(--surface-1) 92%, var(--bg-editor) 8%);
|
||||
color: var(--text-primary);
|
||||
padding: 7px 10px;
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.editor-mode-btn:hover {
|
||||
background: var(--bg-hover);
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
|
||||
.editor-mode-btn .material-symbols-outlined {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.editor-surface {
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
@ -1109,6 +1166,14 @@
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.editor-header {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.editor-header-actions {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.editor-workspace {
|
||||
padding: 4px 8px 10px;
|
||||
}
|
||||
@ -1131,4 +1196,14 @@
|
||||
font-size: 0.89rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 820px) {
|
||||
.editor-mode-btn span:last-child {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.editor-mode-btn {
|
||||
padding: 7px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -88,8 +88,9 @@ async function fetchJson<T>(
|
||||
path: string,
|
||||
init: RequestInit = {},
|
||||
options: FetchJsonOptions = {},
|
||||
apiBase?: string,
|
||||
): Promise<T> {
|
||||
const response = await fetch(`${normalizedApiBase()}${path}`, {
|
||||
const response = await fetch(`${apiBase ?? normalizedApiBase()}${path}`, {
|
||||
...init,
|
||||
keepalive: options.keepalive === true,
|
||||
headers: {
|
||||
|
||||
@ -2,6 +2,7 @@ import { writable, get } from "svelte/store";
|
||||
|
||||
const _password = writable<string | null>(null);
|
||||
const _unlocked = writable(false);
|
||||
let _unlockHandler: (() => Promise<boolean>) | null = null;
|
||||
|
||||
export const vaultUnlocked = { subscribe: _unlocked.subscribe };
|
||||
|
||||
@ -23,6 +24,20 @@ export function clearVaultSession(): void {
|
||||
_unlocked.set(false);
|
||||
}
|
||||
|
||||
export function setUnlockHandler(
|
||||
fn: (() => Promise<boolean>) | null,
|
||||
): void {
|
||||
_unlockHandler = fn;
|
||||
}
|
||||
|
||||
export async function requestVaultUnlock(): Promise<boolean> {
|
||||
if (!_unlockHandler) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return _unlockHandler();
|
||||
}
|
||||
|
||||
let _flushCallback: (() => Promise<void>) | null = null;
|
||||
|
||||
export function setFlushCallback(fn: () => Promise<void>): void {
|
||||
|
||||
@ -31,8 +31,10 @@
|
||||
type StartupView,
|
||||
} from "$lib/stores/settings";
|
||||
import {
|
||||
clearVaultSession,
|
||||
isVaultReady,
|
||||
setFlushCallback,
|
||||
setUnlockHandler,
|
||||
setVaultSession,
|
||||
} from "$lib/stores/session";
|
||||
import { deleteTodoListByStoreId, hydrateTodos } from "$lib/stores/todos";
|
||||
@ -64,6 +66,7 @@
|
||||
|
||||
let selectedSection = "entries";
|
||||
let panelOpen = true;
|
||||
let isPhoneLayout = false;
|
||||
let editMode = false;
|
||||
let activeDocumentId = initialEntry?.id ?? "entries/daily-notes";
|
||||
let activeDocumentLabel = initialEntry?.label ?? "Daily Notes";
|
||||
@ -320,6 +323,31 @@
|
||||
);
|
||||
}
|
||||
|
||||
function updateResponsiveState() {
|
||||
if (typeof window === "undefined") return;
|
||||
isPhoneLayout = window.innerWidth <= 820;
|
||||
}
|
||||
|
||||
async function ensureVaultUnlocked(maxAttempts = 3): Promise<boolean> {
|
||||
let attempts = 0;
|
||||
while (attempts < maxAttempts) {
|
||||
try {
|
||||
const password = await requestVaultPassword();
|
||||
if (!password) return false;
|
||||
|
||||
await unlockVaultWorkspace(password);
|
||||
setVaultSession(password);
|
||||
return true;
|
||||
} catch (error) {
|
||||
clearVaultSession();
|
||||
if (!isLockedError(error)) throw error;
|
||||
attempts += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
async function bootstrapFragmentsWithUnlock(maxAttempts = 3) {
|
||||
if (fragmentBootstrapInFlight) return;
|
||||
|
||||
@ -342,31 +370,20 @@
|
||||
|
||||
fragmentBootstrapInFlight = true;
|
||||
try {
|
||||
let attempts = 0;
|
||||
while (attempts < maxAttempts) {
|
||||
try {
|
||||
const password = await requestVaultPassword();
|
||||
if (!password) return;
|
||||
const unlocked = await ensureVaultUnlocked(maxAttempts);
|
||||
if (!unlocked) return;
|
||||
|
||||
await unlockVaultWorkspace(password);
|
||||
setVaultSession(password);
|
||||
|
||||
await hydrateEntries();
|
||||
templateRefreshToken += 1;
|
||||
const firstEntry = getDefaultEntry(get(entriesStore));
|
||||
if (firstEntry && activeDocumentId === "entries/daily-notes") {
|
||||
await handleOpenDocument(firstEntry);
|
||||
}
|
||||
|
||||
await hydrateFragments();
|
||||
await hydrateLists().catch(() => {});
|
||||
await hydrateTodos().catch(() => {});
|
||||
return;
|
||||
} catch (error) {
|
||||
if (!isLockedError(error)) return;
|
||||
attempts += 1;
|
||||
}
|
||||
await hydrateEntries();
|
||||
templateRefreshToken += 1;
|
||||
const firstEntry = getDefaultEntry(get(entriesStore));
|
||||
if (firstEntry && activeDocumentId === "entries/daily-notes") {
|
||||
await handleOpenDocument(firstEntry);
|
||||
}
|
||||
|
||||
await hydrateFragments();
|
||||
await hydrateLists().catch(() => {});
|
||||
await hydrateTodos().catch(() => {});
|
||||
return;
|
||||
} finally {
|
||||
fragmentBootstrapInFlight = false;
|
||||
}
|
||||
@ -574,6 +591,9 @@
|
||||
pruneDocumentCache([resolvedDoc.id]);
|
||||
activeDocumentId = resolvedDoc.id;
|
||||
activeDocumentLabel = resolvedDoc.label;
|
||||
if (isPhoneLayout) {
|
||||
panelOpen = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLinkedBack() {
|
||||
@ -599,6 +619,15 @@
|
||||
await saveCurrentDocument();
|
||||
}
|
||||
|
||||
function handleEnterEditMode() {
|
||||
if (!activeDocumentId) return;
|
||||
editMode = true;
|
||||
}
|
||||
|
||||
function handleExitEditMode() {
|
||||
editMode = false;
|
||||
}
|
||||
|
||||
function handleDeleteDocument(id: string) {
|
||||
const { [id]: _, ...remaining } = openDocuments;
|
||||
openDocuments = remaining;
|
||||
@ -664,7 +693,17 @@
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
updateResponsiveState();
|
||||
const handleResize = () => {
|
||||
const wasPhoneLayout = isPhoneLayout;
|
||||
updateResponsiveState();
|
||||
if (!wasPhoneLayout && isPhoneLayout && activeDocumentId) {
|
||||
panelOpen = false;
|
||||
}
|
||||
};
|
||||
window.addEventListener("resize", handleResize);
|
||||
setFlushCallback(saveCurrentDocument);
|
||||
setUnlockHandler(() => ensureVaultUnlocked());
|
||||
void (async () => {
|
||||
await hydrateUiSettings();
|
||||
const startupSection = resolveStartupSection(
|
||||
@ -675,7 +714,15 @@
|
||||
);
|
||||
applyStartupSection(sectionFromQuery ?? startupSection);
|
||||
await bootstrapFragmentsWithUnlock();
|
||||
if (isPhoneLayout && activeDocumentId) {
|
||||
panelOpen = false;
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("resize", handleResize);
|
||||
setUnlockHandler(null);
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
@ -709,6 +756,8 @@
|
||||
calendarError={calendarPanelState.error}
|
||||
previewOnly={!editMode}
|
||||
onForceSave={handleForceSave}
|
||||
onRequestEdit={handleEnterEditMode}
|
||||
onRequestPreview={handleExitEditMode}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@ -38,6 +38,14 @@
|
||||
let sidecarRootIsCustom = false;
|
||||
let sidecarRootError = "";
|
||||
let sidecarBrowseBusy = false;
|
||||
let gatewayRootBusy = false;
|
||||
let gatewayRootError = "";
|
||||
let gatewayRootStatus: {
|
||||
authoritativeRoot: string;
|
||||
gatewayConfigPath: string;
|
||||
configuredRoot?: string | null;
|
||||
needsAdoption: boolean;
|
||||
} | null = null;
|
||||
let returnSection = "entries";
|
||||
|
||||
onMount(async () => {
|
||||
@ -61,6 +69,8 @@
|
||||
} catch (e) {
|
||||
sidecarRootError = String(e);
|
||||
}
|
||||
|
||||
await refreshGatewayRootStatus();
|
||||
});
|
||||
|
||||
async function saveSidecarRoot() {
|
||||
@ -71,6 +81,7 @@
|
||||
});
|
||||
sidecarRoot = result.root;
|
||||
sidecarRootIsCustom = result.isCustom;
|
||||
await refreshGatewayRootStatus();
|
||||
} catch (e) {
|
||||
sidecarRootError = String(e);
|
||||
}
|
||||
@ -82,6 +93,7 @@
|
||||
const result: any = await invoke("set_sidecar_root", { path: "" });
|
||||
sidecarRoot = result.root;
|
||||
sidecarRootIsCustom = result.isCustom;
|
||||
await refreshGatewayRootStatus();
|
||||
} catch (e) {
|
||||
sidecarRootError = String(e);
|
||||
}
|
||||
@ -112,6 +124,38 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshGatewayRootStatus() {
|
||||
gatewayRootError = "";
|
||||
gatewayRootStatus = null;
|
||||
if (!isTauriRuntime()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
gatewayRootStatus = await invoke<any>("get_gateway_root_status");
|
||||
} catch (e) {
|
||||
gatewayRootError = String(e);
|
||||
}
|
||||
}
|
||||
|
||||
async function adoptGatewayRoot() {
|
||||
gatewayRootError = "";
|
||||
if (!isTauriRuntime()) {
|
||||
gatewayRootError =
|
||||
"Gateway root adoption is only available in the desktop app.";
|
||||
return;
|
||||
}
|
||||
|
||||
gatewayRootBusy = true;
|
||||
try {
|
||||
gatewayRootStatus = await invoke<any>("adopt_sidecar_root_for_gateway");
|
||||
} catch (e) {
|
||||
gatewayRootError = String(e);
|
||||
} finally {
|
||||
gatewayRootBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
function showModal(options: {
|
||||
action: "logout-confirm" | "logout-info";
|
||||
title: string;
|
||||
@ -477,6 +521,87 @@
|
||||
<p class="error-text">{sidecarRootError}</p>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section class="route-card">
|
||||
<div class="card-head">
|
||||
<h2 class="card-title">
|
||||
<span class="material-symbols-outlined" aria-hidden="true"
|
||||
>publish</span
|
||||
>
|
||||
Gateway Root
|
||||
</h2>
|
||||
<p class="section-copy">
|
||||
Published WebGateway builds should point at one authoritative root.
|
||||
Use this one-time adopt action to align the packaged gateway with
|
||||
the desktop app.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{#if isTauriRuntime()}
|
||||
<div class="row-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="ghost-btn"
|
||||
on:click={refreshGatewayRootStatus}
|
||||
disabled={gatewayRootBusy}
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="secondary-btn"
|
||||
on:click={adoptGatewayRoot}
|
||||
disabled={gatewayRootBusy}
|
||||
>
|
||||
{gatewayRootBusy ? "Adopting..." : "Adopt Current Root"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if gatewayRootStatus}
|
||||
<label>
|
||||
Desktop authoritative root
|
||||
<input
|
||||
type="text"
|
||||
value={gatewayRootStatus.authoritativeRoot}
|
||||
readonly
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Gateway config path
|
||||
<input
|
||||
type="text"
|
||||
value={gatewayRootStatus.gatewayConfigPath}
|
||||
readonly
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Gateway configured root
|
||||
<input
|
||||
type="text"
|
||||
value={gatewayRootStatus.configuredRoot ?? "(not set)"}
|
||||
readonly
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Status
|
||||
<input
|
||||
type="text"
|
||||
value={gatewayRootStatus.needsAdoption ? "Needs adoption" : "Aligned"}
|
||||
readonly
|
||||
/>
|
||||
</label>
|
||||
{/if}
|
||||
{:else}
|
||||
<p class="section-copy">
|
||||
Configure `GatewaySettings:RepoRoot` or `JOURNAL_PROJECT_ROOT`
|
||||
before starting the published gateway.
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#if gatewayRootError}
|
||||
<p class="error-text">{gatewayRootError}</p>
|
||||
{/if}
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@ -92,25 +92,70 @@ select {
|
||||
min-height: 100dvh;
|
||||
display: grid;
|
||||
grid-template-columns: 72px 300px minmax(0, 1fr);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.app-shell.panel-closed {
|
||||
grid-template-columns: 72px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
@media (max-width: 1100px) {
|
||||
.app-shell {
|
||||
grid-template-columns: 64px minmax(0, 1fr);
|
||||
grid-template-rows: 280px minmax(0, 1fr);
|
||||
grid-template-columns: 64px 280px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.app-shell:not(.panel-closed) > .side-panel {
|
||||
grid-column: 2;
|
||||
.app-shell.panel-closed {
|
||||
grid-template-columns: 64px minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 820px) {
|
||||
body {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
min-height: 100dvh;
|
||||
min-height: 100svh;
|
||||
grid-template-columns: 56px minmax(0, 1fr);
|
||||
grid-template-rows: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.app-shell.panel-closed {
|
||||
grid-template-columns: 56px minmax(0, 1fr);
|
||||
grid-template-rows: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.app-shell > .navbar {
|
||||
grid-column: 1;
|
||||
grid-row: 1;
|
||||
}
|
||||
|
||||
.app-shell:not(.panel-closed) > .editor-panel {
|
||||
.app-shell > .side-panel,
|
||||
.app-shell > .editor-panel,
|
||||
.app-shell > .route-view {
|
||||
grid-column: 2;
|
||||
grid-row: 2;
|
||||
grid-row: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.app-shell > .side-panel {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 56px;
|
||||
width: min(360px, calc(100vw - 56px));
|
||||
z-index: 40;
|
||||
box-shadow: 18px 0 40px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.app-shell:not(.panel-closed) > .side-panel {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.app-shell > .editor-panel,
|
||||
.app-shell > .route-view {
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@ -61,7 +61,7 @@ public sealed class DatabaseSessionService(IJournalDatabaseService database) : I
|
||||
return _connection;
|
||||
|
||||
_connection = _database.OpenEncryptedConnection(_password);
|
||||
_database.EnsureSchema(_connection);
|
||||
_database.EnsureSchemaReady(_connection);
|
||||
return _connection;
|
||||
}
|
||||
}
|
||||
|
||||
@ -11,6 +11,7 @@ public interface IJournalDatabaseService
|
||||
IReadOnlyDictionary<string, string> GetSchemaStatements();
|
||||
SqliteConnection OpenEncryptedConnection(string password);
|
||||
void EnsureSchema(SqliteConnection connection);
|
||||
void EnsureSchemaReady(SqliteConnection connection);
|
||||
JournalDatabaseStatus GetStatus(string password);
|
||||
JournalDatabaseHydrationResult HydrateWorkspace(string password);
|
||||
}
|
||||
|
||||
@ -170,7 +170,7 @@ public sealed class JournalDatabaseService(IJournalConfigService config) : IJour
|
||||
public JournalDatabaseHydrationResult HydrateWorkspace(string password)
|
||||
{
|
||||
using var connection = OpenEncryptedConnection(password);
|
||||
EnsureSchema(connection);
|
||||
EnsureSchemaReady(connection);
|
||||
var runtimeReady = HasRequiredTables(connection);
|
||||
|
||||
var entryDocumentsProcessed = CountEntryDocuments(connection);
|
||||
@ -206,7 +206,8 @@ public sealed class JournalDatabaseService(IJournalConfigService config) : IJour
|
||||
|
||||
EnsureSqliteInitialized();
|
||||
|
||||
var connection = new SqliteConnection($"Data Source={GetDatabasePath()};Mode=ReadWriteCreate;Pooling=False");
|
||||
var connection = new SqliteConnection(
|
||||
$"Data Source={GetDatabasePath()};Mode=ReadWriteCreate;Pooling=False;Default Timeout=5");
|
||||
connection.Open();
|
||||
|
||||
using var keyCmd = connection.CreateCommand();
|
||||
@ -217,6 +218,10 @@ public sealed class JournalDatabaseService(IJournalConfigService config) : IJour
|
||||
verifyCmd.CommandText = "SELECT count(*) FROM sqlite_master;";
|
||||
_ = verifyCmd.ExecuteScalar();
|
||||
|
||||
using var busyCmd = connection.CreateCommand();
|
||||
busyCmd.CommandText = "PRAGMA busy_timeout = 5000;";
|
||||
busyCmd.ExecuteNonQuery();
|
||||
|
||||
return connection;
|
||||
}
|
||||
|
||||
@ -230,6 +235,14 @@ public sealed class JournalDatabaseService(IJournalConfigService config) : IJour
|
||||
}
|
||||
}
|
||||
|
||||
public void EnsureSchemaReady(SqliteConnection connection)
|
||||
{
|
||||
if (HasRequiredTables(connection))
|
||||
return;
|
||||
|
||||
EnsureSchema(connection);
|
||||
}
|
||||
|
||||
private static bool HasRequiredTables(SqliteConnection connection)
|
||||
{
|
||||
var existing = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
@ -250,7 +263,7 @@ public sealed class JournalDatabaseService(IJournalConfigService config) : IJour
|
||||
try
|
||||
{
|
||||
using var connection = OpenEncryptedConnection(password);
|
||||
EnsureSchema(connection);
|
||||
EnsureSchemaReady(connection);
|
||||
var ready = HasRequiredTables(connection);
|
||||
return ready
|
||||
? (true, "SQLCipher runtime is available and schema tables are present.")
|
||||
|
||||
@ -97,9 +97,15 @@ public class VaultStorageService(IVaultCryptoService crypto, IJournalDatabaseSer
|
||||
if (string.IsNullOrWhiteSpace(dbFileName))
|
||||
continue;
|
||||
|
||||
var targetPath = Path.Combine(dataDirectory, dbFileName);
|
||||
if (File.Exists(targetPath))
|
||||
{
|
||||
anyRestored = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
var encrypted = File.ReadAllBytes(vaultFile);
|
||||
var dbBytes = _crypto.DecryptData(encrypted, password);
|
||||
var targetPath = Path.Combine(dataDirectory, dbFileName);
|
||||
File.WriteAllBytes(targetPath, dbBytes);
|
||||
anyRestored = true;
|
||||
}
|
||||
|
||||
178
Journal.WebGateway/Extensions/EndpointExtensions.cs
Normal file
178
Journal.WebGateway/Extensions/EndpointExtensions.cs
Normal file
@ -0,0 +1,178 @@
|
||||
using System.Text.Json;
|
||||
using Journal.Core;
|
||||
using Journal.Core.Services.Config;
|
||||
using Journal.Core.Services.Database;
|
||||
using Journal.WebGateway.Infrastructure;
|
||||
using Journal.WebGateway.Security;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.Extensions.FileProviders;
|
||||
|
||||
namespace Journal.WebGateway.Extensions;
|
||||
|
||||
public static class EndpointExtensions
|
||||
{
|
||||
public static void MapGatewayEndpoints(this IEndpointRouteBuilder app, string webDistPath)
|
||||
{
|
||||
MapAuthEndpoints(app);
|
||||
MapApiEndpoints(app);
|
||||
MapStaticFiles(app, webDistPath);
|
||||
}
|
||||
|
||||
private static void MapAuthEndpoints(IEndpointRouteBuilder app)
|
||||
{
|
||||
app.MapGet("/gateway/login", [AllowAnonymous] async (HttpContext context) =>
|
||||
{
|
||||
context.Response.ContentType = "text/html; charset=utf-8";
|
||||
await context.Response.WriteAsync(LoginPage.GetHtml());
|
||||
});
|
||||
|
||||
app.MapPost("/gateway/login", [AllowAnonymous] async (HttpContext context, IConfiguration config, ILogger<Program> logger) =>
|
||||
{
|
||||
var form = await context.Request.ReadFormAsync();
|
||||
var password = form["password"].ToString();
|
||||
var configuredHash = config.GetValue<string>("Security:AccessPasswordHash");
|
||||
var ip = context.Connection.RemoteIpAddress?.ToString() ?? "unknown";
|
||||
|
||||
var timestamp = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(configuredHash))
|
||||
{
|
||||
logger.LogError("[{Timestamp}] Gateway login rejected because Security:AccessPasswordHash is not configured.", timestamp);
|
||||
return Results.Redirect("/gateway/login?error=config");
|
||||
}
|
||||
|
||||
if (GatewayPasswordHasher.VerifyPassword(password, configuredHash))
|
||||
{
|
||||
logger.LogInformation("[{Timestamp}] Audit: Successful login from {IP}", timestamp, ip);
|
||||
var claims = new[] { new System.Security.Claims.Claim(System.Security.Claims.ClaimTypes.Name, "WebUser") };
|
||||
var identity = new System.Security.Claims.ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
|
||||
var principal = new System.Security.Claims.ClaimsPrincipal(identity);
|
||||
|
||||
await context.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, principal);
|
||||
return Results.Redirect("/");
|
||||
}
|
||||
|
||||
logger.LogWarning("[{Timestamp}] Audit: FAILED login attempt from {IP}", timestamp, ip);
|
||||
return Results.Redirect("/gateway/login?error=invalid");
|
||||
});
|
||||
|
||||
app.MapGet("/gateway/logout", async (HttpContext context) =>
|
||||
{
|
||||
await context.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
|
||||
return Results.Redirect("/gateway/login");
|
||||
});
|
||||
}
|
||||
|
||||
private static void MapApiEndpoints(IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/api");
|
||||
|
||||
group.MapGet("/health", [AllowAnonymous] () => Results.Ok(new
|
||||
{
|
||||
ok = true,
|
||||
service = "Journal.WebGateway"
|
||||
}));
|
||||
|
||||
group.MapGet("/web/status", (WebUiState webUiState) => Results.Ok(new
|
||||
{
|
||||
distPath = webUiState.DistPath,
|
||||
exists = webUiState.Exists
|
||||
}));
|
||||
|
||||
group.MapPost("/command", async (CommandEnvelope? command, Entry entry, ILogger<Program> logger) =>
|
||||
{
|
||||
if (command is null || string.IsNullOrWhiteSpace(command.Action))
|
||||
{
|
||||
return Results.Json(new { ok = false, error = "Missing action" }, statusCode: 400);
|
||||
}
|
||||
|
||||
var options = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
|
||||
var inputJson = JsonSerializer.Serialize(command, options);
|
||||
|
||||
logger.LogInformation("Executing command: {Action}", command.Action);
|
||||
var responseJson = await entry.HandleCommandAsync(inputJson);
|
||||
|
||||
return Results.Content(responseJson, "application/json");
|
||||
});
|
||||
|
||||
group.MapGet("/sidecar/root", (SidecarRootState rootState) =>
|
||||
{
|
||||
var (root, isCustom) = rootState.Get();
|
||||
return Results.Ok(new { root, isCustom });
|
||||
});
|
||||
|
||||
group.MapPost("/sidecar/root", () =>
|
||||
{
|
||||
return Results.BadRequest(new
|
||||
{
|
||||
ok = false,
|
||||
error = "WebGateway root is startup-only. Configure GatewaySettings:RepoRoot or JOURNAL_PROJECT_ROOT before launch."
|
||||
});
|
||||
});
|
||||
|
||||
group.MapGet("/runtime/diagnostics", (
|
||||
SidecarRootState rootState,
|
||||
IJournalConfigService configService,
|
||||
IJournalDatabaseService databaseService,
|
||||
HttpContext context) =>
|
||||
{
|
||||
var (root, isCustom) = rootState.Get();
|
||||
var config = configService.Current;
|
||||
var gatewayUrl = $"{context.Request.Scheme}://{context.Request.Host}";
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
backendMode = "webgateway",
|
||||
root,
|
||||
isCustomRoot = isCustom,
|
||||
vaultDirectory = config.VaultDirectory,
|
||||
databasePath = databaseService.GetDatabasePath(),
|
||||
sidecarPath = "Not used by WebGateway",
|
||||
gatewayPath = Environment.ProcessPath ?? AppContext.BaseDirectory,
|
||||
gatewayUrl
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private static void MapStaticFiles(IEndpointRouteBuilder app, string webDistPath)
|
||||
{
|
||||
if (!Directory.Exists(webDistPath) || !File.Exists(Path.Combine(webDistPath, "index.html")))
|
||||
{
|
||||
app.MapGet("/", () => Results.Ok(new
|
||||
{
|
||||
name = "Journal.WebGateway",
|
||||
status = "ok",
|
||||
uiAvailable = false,
|
||||
message = "No built web UI found. Build Journal.App with ./scripts/publish-app.ps1 -Target web.",
|
||||
expectedDist = webDistPath
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
var fileProvider = new PhysicalFileProvider(webDistPath);
|
||||
var indexPath = Path.Combine(webDistPath, "index.html");
|
||||
|
||||
// Note: Generic static file middleware should be in Program.cs
|
||||
// but the specific root/fallback routes can be here.
|
||||
|
||||
app.MapGet("/", async (HttpContext context) =>
|
||||
{
|
||||
context.Response.ContentType = "text/html; charset=utf-8";
|
||||
await context.Response.SendFileAsync(indexPath);
|
||||
});
|
||||
|
||||
app.MapFallback(async (HttpContext context) =>
|
||||
{
|
||||
if (context.Request.Path.StartsWithSegments("/api"))
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status404NotFound;
|
||||
return;
|
||||
}
|
||||
|
||||
context.Response.ContentType = "text/html; charset=utf-8";
|
||||
await context.Response.SendFileAsync(indexPath);
|
||||
});
|
||||
}
|
||||
}
|
||||
117
Journal.WebGateway/Infrastructure/LoginPage.cs
Normal file
117
Journal.WebGateway/Infrastructure/LoginPage.cs
Normal file
@ -0,0 +1,117 @@
|
||||
namespace Journal.WebGateway.Infrastructure;
|
||||
|
||||
public static class LoginPage
|
||||
{
|
||||
public static string GetHtml() => @"
|
||||
<!DOCTYPE html>
|
||||
<html lang=""en"">
|
||||
<head>
|
||||
<meta charset=""UTF-8"">
|
||||
<meta name=""viewport"" content=""width=device-width, initial-scale=1.0"">
|
||||
<title>Journal Gateway | Login</title>
|
||||
<link href=""https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap"" rel=""stylesheet"">
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0a0a0b;
|
||||
--surface: #141416;
|
||||
--primary: #6366f1;
|
||||
--primary-hover: #4f46e5;
|
||||
--text: #ffffff;
|
||||
--text-dim: #9ca3af;
|
||||
--error: #ef4444;
|
||||
}
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body {
|
||||
font-family: 'Inter', sans-serif;
|
||||
background-color: var(--bg);
|
||||
color: var(--text);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
.login-card {
|
||||
background: var(--surface);
|
||||
padding: 2.5rem;
|
||||
border-radius: 1rem;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
text-align: center;
|
||||
animation: fadeIn 0.5s ease-out;
|
||||
}
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
h1 { font-size: 1.5rem; margin-bottom: 0.5rem; font-weight: 600; letter-spacing: -0.025em; }
|
||||
p { color: var(--text-dim); margin-bottom: 2rem; font-size: 0.875rem; }
|
||||
.form-group { text-align: left; margin-bottom: 1.5rem; }
|
||||
label { display: block; font-size: 0.75rem; font-weight: 500; color: var(--text-dim); margin-bottom: 0.5rem; text-transform: uppercase; letter-spacing: 0.05em; }
|
||||
input {
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
background: #000;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 0.5rem;
|
||||
color: var(--text);
|
||||
font-size: 1rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
input:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.2);
|
||||
}
|
||||
button {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
button:hover { background: var(--primary-hover); }
|
||||
.error-msg {
|
||||
color: var(--error);
|
||||
font-size: 0.75rem;
|
||||
margin-top: 1rem;
|
||||
padding: 0.5rem;
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class=""login-card"">
|
||||
<h1>Journal Gateway</h1>
|
||||
<p>Enter your access password to continue.</p>
|
||||
<form action=""/gateway/login"" method=""POST"">
|
||||
<div class=""form-group"">
|
||||
<label for=""password"">Password</label>
|
||||
<input type=""password"" id=""password"" name=""password"" required autofocus placeholder=""••••••••"">
|
||||
</div>
|
||||
<button type=""submit"">Sign In</button>
|
||||
<div id=""error"" class=""error-msg"" style=""display:none""></div>
|
||||
</form>
|
||||
</div>
|
||||
<script>
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const errorEl = document.getElementById('error');
|
||||
if (urlParams.has('error')) {
|
||||
const error = urlParams.get('error');
|
||||
errorEl.textContent = error === 'config'
|
||||
? 'Gateway password hash is not configured. Set Security:AccessPasswordHash before signing in.'
|
||||
: 'Invalid password. Please try again.';
|
||||
errorEl.style.display = 'block';
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>";
|
||||
}
|
||||
75
Journal.WebGateway/Infrastructure/PathResolver.cs
Normal file
75
Journal.WebGateway/Infrastructure/PathResolver.cs
Normal file
@ -0,0 +1,75 @@
|
||||
namespace Journal.WebGateway.Infrastructure;
|
||||
|
||||
public sealed record RepoRootResolution(string Root, bool IsCustom, string Source);
|
||||
|
||||
public static class PathResolver
|
||||
{
|
||||
public static RepoRootResolution ResolveRepoRoot(IConfiguration config)
|
||||
{
|
||||
var fromConfig = config.GetValue<string>("GatewaySettings:RepoRoot");
|
||||
if (!string.IsNullOrWhiteSpace(fromConfig) && Directory.Exists(fromConfig))
|
||||
return new RepoRootResolution(Path.GetFullPath(fromConfig), true, "config");
|
||||
|
||||
var fromEnv = Environment.GetEnvironmentVariable("JOURNAL_PROJECT_ROOT");
|
||||
if (!string.IsNullOrWhiteSpace(fromEnv) && Directory.Exists(fromEnv))
|
||||
return new RepoRootResolution(Path.GetFullPath(fromEnv), true, "env");
|
||||
|
||||
if (IsPublishedLayout())
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Published WebGateway requires an explicit root. Set GatewaySettings:RepoRoot or JOURNAL_PROJECT_ROOT before launch.");
|
||||
}
|
||||
|
||||
foreach (var start in new[] { Directory.GetCurrentDirectory(), AppContext.BaseDirectory })
|
||||
{
|
||||
var resolved = FindRepoRoot(start);
|
||||
if (resolved is not null)
|
||||
return new RepoRootResolution(resolved, false, "auto");
|
||||
}
|
||||
|
||||
return new RepoRootResolution(Path.GetFullPath(Directory.GetCurrentDirectory()), false, "cwd");
|
||||
}
|
||||
|
||||
private static string? FindRepoRoot(string start)
|
||||
{
|
||||
var cursor = Path.GetFullPath(start);
|
||||
while (!string.IsNullOrWhiteSpace(cursor))
|
||||
{
|
||||
if (File.Exists(Path.Combine(cursor, "Journal.slnx")) ||
|
||||
Directory.Exists(Path.Combine(cursor, "Journal.Sidecar")) ||
|
||||
File.Exists(Path.Combine(cursor, "Journal.Sidecar.exe")) ||
|
||||
File.Exists(Path.Combine(cursor, "Journal.Sidecar")) ||
|
||||
Directory.Exists(Path.Combine(cursor, "Journal.Core")))
|
||||
return cursor;
|
||||
|
||||
var parent = Directory.GetParent(cursor);
|
||||
if (parent is null || string.Equals(parent.FullName, cursor, StringComparison.OrdinalIgnoreCase))
|
||||
return null;
|
||||
|
||||
cursor = parent.FullName;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public static string ResolveWebDist(string repoRootPath, IConfiguration config)
|
||||
{
|
||||
var fromConfig = config.GetValue<string>("GatewaySettings:WebDist");
|
||||
if (!string.IsNullOrWhiteSpace(fromConfig)) return Path.GetFullPath(fromConfig);
|
||||
|
||||
var fromEnv = Environment.GetEnvironmentVariable("JOURNAL_WEB_DIST");
|
||||
if (!string.IsNullOrWhiteSpace(fromEnv)) return Path.GetFullPath(fromEnv);
|
||||
|
||||
var packagedWwwRoot = Path.Combine(AppContext.BaseDirectory, "wwwroot");
|
||||
if (Directory.Exists(packagedWwwRoot)) return packagedWwwRoot;
|
||||
|
||||
return Path.Combine(repoRootPath, "Journal.App", "build");
|
||||
}
|
||||
|
||||
private static bool IsPublishedLayout()
|
||||
{
|
||||
var baseDir = Path.GetFullPath(AppContext.BaseDirectory);
|
||||
return Directory.Exists(Path.Combine(baseDir, "wwwroot")) &&
|
||||
!File.Exists(Path.Combine(baseDir, "Journal.slnx")) &&
|
||||
!Directory.Exists(Path.Combine(baseDir, "Journal.WebGateway"));
|
||||
}
|
||||
}
|
||||
55
Journal.WebGateway/Infrastructure/States.cs
Normal file
55
Journal.WebGateway/Infrastructure/States.cs
Normal file
@ -0,0 +1,55 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Journal.WebGateway.Infrastructure;
|
||||
|
||||
public sealed class WebUiState(string distPath)
|
||||
{
|
||||
public string DistPath { get; } = distPath;
|
||||
public bool Exists => Directory.Exists(DistPath) && File.Exists(Path.Combine(DistPath, "index.html"));
|
||||
}
|
||||
|
||||
public sealed class SidecarRootState(string autoRoot)
|
||||
{
|
||||
private readonly object _sync = new();
|
||||
private string _fallbackRoot = autoRoot;
|
||||
private string _currentRoot = autoRoot;
|
||||
private bool _isCustom;
|
||||
|
||||
public (string Root, bool IsCustom) Get()
|
||||
{
|
||||
lock (_sync) return (_currentRoot, _isCustom);
|
||||
}
|
||||
|
||||
public void SetResolved(string path, bool isCustom)
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
_currentRoot = Path.GetFullPath(path.Trim());
|
||||
_isCustom = isCustom;
|
||||
if (!isCustom)
|
||||
_fallbackRoot = _currentRoot;
|
||||
}
|
||||
}
|
||||
|
||||
public void ResetToFallback()
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
_currentRoot = _fallbackRoot;
|
||||
_isCustom = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class SetSidecarRootRequest { public string? Path { get; set; } }
|
||||
|
||||
public sealed class CommandEnvelope
|
||||
{
|
||||
public string Action { get; set; } = "";
|
||||
public string? CorrelationId { get; set; }
|
||||
public string? Id { get; set; }
|
||||
public string? Type { get; set; }
|
||||
public string? Tag { get; set; }
|
||||
public JsonElement? Payload { get; set; }
|
||||
}
|
||||
@ -2,34 +2,83 @@ using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Journal.AI;
|
||||
using Journal.Core;
|
||||
using Journal.WebGateway.Extensions;
|
||||
using Journal.WebGateway.Infrastructure;
|
||||
using Journal.WebGateway.Security;
|
||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Diagnostics;
|
||||
using Microsoft.Extensions.FileProviders;
|
||||
|
||||
var gatewayJsonOptions = new JsonSerializerOptions
|
||||
if (TryHandlePasswordHashCommand(args))
|
||||
return;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(new WebApplicationOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
PropertyNameCaseInsensitive = true,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||
};
|
||||
Args = args,
|
||||
ContentRootPath = AppContext.BaseDirectory
|
||||
});
|
||||
|
||||
// Configuration
|
||||
var securitySettings = builder.Configuration.GetSection("Security");
|
||||
var repoRootResolution = PathResolver.ResolveRepoRoot(builder.Configuration);
|
||||
var repoRoot = repoRootResolution.Root;
|
||||
var webDistPath = PathResolver.ResolveWebDist(repoRoot, builder.Configuration);
|
||||
var port = builder.Configuration.GetValue<int?>("PORT")
|
||||
?? builder.Configuration.GetValue<int?>("GatewaySettings:Port")
|
||||
?? 5002;
|
||||
|
||||
var repoRoot = ResolveRepoRoot();
|
||||
Environment.SetEnvironmentVariable("JOURNAL_PROJECT_ROOT", repoRoot);
|
||||
var webDistPath = ResolveWebDist(repoRoot);
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
builder.WebHost.ConfigureKestrel(options => options.ListenAnyIP(port));
|
||||
|
||||
// Services
|
||||
builder.Services.AddFragmentServices();
|
||||
builder.Services.AddLlamaSharpServices();
|
||||
builder.Services.AddSingleton<Entry>();
|
||||
builder.Services.AddSingleton(new SidecarRootState(repoRoot));
|
||||
builder.Services.AddSingleton(new SidecarRootState(repoRootResolution.Root));
|
||||
builder.Services.AddSingleton(new WebUiState(webDistPath));
|
||||
|
||||
builder.Services.AddAuthentication(options => {
|
||||
options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
|
||||
options.DefaultAuthenticateScheme = CookieAuthenticationDefaults.AuthenticationScheme;
|
||||
options.DefaultChallengeScheme = CookieAuthenticationDefaults.AuthenticationScheme;
|
||||
})
|
||||
.AddCookie(CookieAuthenticationDefaults.AuthenticationScheme, options =>
|
||||
{
|
||||
options.LoginPath = "/gateway/login";
|
||||
options.LogoutPath = "/gateway/logout";
|
||||
options.Cookie.Name = "Journal.Gateway.Session";
|
||||
options.Cookie.HttpOnly = true;
|
||||
options.Cookie.SameSite = SameSiteMode.Strict;
|
||||
options.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest;
|
||||
options.ExpireTimeSpan = TimeSpan.FromHours(8);
|
||||
options.SlidingExpiration = true;
|
||||
})
|
||||
.AddScheme<AuthenticationSchemeOptions, ApiKeyAuthenticationHandler>("ApiKey", null);
|
||||
|
||||
builder.Services.AddAuthorization(options =>
|
||||
{
|
||||
options.FallbackPolicy = new AuthorizationPolicyBuilder()
|
||||
.AddAuthenticationSchemes(CookieAuthenticationDefaults.AuthenticationScheme, "ApiKey")
|
||||
.RequireAuthenticatedUser()
|
||||
.Build();
|
||||
});
|
||||
|
||||
builder.Services.AddCors(options =>
|
||||
{
|
||||
options.AddPolicy("GatewayCors", policy =>
|
||||
{
|
||||
policy.AllowAnyOrigin()
|
||||
.AllowAnyHeader()
|
||||
.AllowAnyMethod();
|
||||
var allowedOrigins = securitySettings.GetSection("AllowedOrigins").Get<string[]>() ?? Array.Empty<string>();
|
||||
if (allowedOrigins.Length > 0)
|
||||
{
|
||||
policy.WithOrigins(allowedOrigins).AllowAnyHeader().AllowAnyMethod().AllowCredentials();
|
||||
}
|
||||
else
|
||||
{
|
||||
policy.AllowAnyOrigin().AllowAnyHeader().AllowAnyMethod();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@ -37,228 +86,163 @@ builder.Services.ConfigureHttpJsonOptions(options =>
|
||||
{
|
||||
options.SerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
|
||||
options.SerializerOptions.PropertyNameCaseInsensitive = true;
|
||||
options.SerializerOptions.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull;
|
||||
});
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
app.Services.GetRequiredService<SidecarRootState>().SetResolved(repoRootResolution.Root, repoRootResolution.IsCustom);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(builder.Configuration.GetValue<string>("Security:AccessPasswordHash")))
|
||||
{
|
||||
app.Logger.LogWarning("Security:AccessPasswordHash is not configured. Browser login is disabled until a password hash is set.");
|
||||
}
|
||||
|
||||
app.Logger.LogInformation("Journal.WebGateway starting...");
|
||||
app.Logger.LogInformation("Content Root: {Path}", app.Environment.ContentRootPath);
|
||||
app.Logger.LogInformation("Web UI Dist: {Path}", webDistPath);
|
||||
|
||||
app.UseExceptionHandler(exceptionApp =>
|
||||
{
|
||||
exceptionApp.Run(async context =>
|
||||
{
|
||||
var logger = context.RequestServices.GetRequiredService<ILogger<Program>>();
|
||||
var feature = context.Features.Get<IExceptionHandlerPathFeature>();
|
||||
logger.LogError(feature?.Error, "Unhandled exception occurred.");
|
||||
context.Response.StatusCode = StatusCodes.Status500InternalServerError;
|
||||
context.Response.ContentType = "application/json";
|
||||
await context.Response.WriteAsJsonAsync(new { ok = false, error = "An internal error occurred." });
|
||||
});
|
||||
});
|
||||
|
||||
app.UseCors("GatewayCors");
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
|
||||
app.MapGet("/api/health", () => Results.Ok(new
|
||||
app.Use(async (context, next) =>
|
||||
{
|
||||
ok = true,
|
||||
service = "Journal.WebGateway"
|
||||
}));
|
||||
var path = context.Request.Path;
|
||||
var isGatewayAuthPath =
|
||||
path.StartsWithSegments("/gateway/login") ||
|
||||
path.StartsWithSegments("/gateway/logout");
|
||||
var isAnonymousApiPath =
|
||||
path.StartsWithSegments("/api/health") ||
|
||||
path.StartsWithSegments("/api/web/status");
|
||||
var isProtectedUiPath = !path.StartsWithSegments("/api") && !isGatewayAuthPath;
|
||||
|
||||
app.MapGet("/api/web/status", (WebUiState webUiState) => Results.Ok(new
|
||||
{
|
||||
distPath = webUiState.DistPath,
|
||||
exists = webUiState.Exists
|
||||
}));
|
||||
|
||||
app.MapPost("/api/command", async (CommandEnvelope? command, Entry entry) =>
|
||||
{
|
||||
if (command is null || string.IsNullOrWhiteSpace(command.Action))
|
||||
if (isProtectedUiPath && context.User.Identity?.IsAuthenticated != true)
|
||||
{
|
||||
return Results.Content(ErrorResponse("Missing action"), "application/json");
|
||||
await context.ChallengeAsync(CookieAuthenticationDefaults.AuthenticationScheme);
|
||||
return;
|
||||
}
|
||||
|
||||
var inputJson = JsonSerializer.Serialize(command, gatewayJsonOptions);
|
||||
var responseJson = await entry.HandleCommandAsync(inputJson);
|
||||
return Results.Content(responseJson, "application/json");
|
||||
});
|
||||
|
||||
app.MapGet("/api/sidecar/root", (SidecarRootState rootState) =>
|
||||
{
|
||||
var (root, isCustom) = rootState.Get();
|
||||
return Results.Ok(new
|
||||
if (isAnonymousApiPath)
|
||||
{
|
||||
root,
|
||||
isCustom
|
||||
});
|
||||
});
|
||||
|
||||
app.MapPost("/api/sidecar/root", (SetSidecarRootRequest? request, SidecarRootState rootState) =>
|
||||
{
|
||||
var path = request?.Path ?? "";
|
||||
if (!string.IsNullOrWhiteSpace(path) && !Directory.Exists(path))
|
||||
{
|
||||
return Results.BadRequest($"Directory '{path}' does not exist.");
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
|
||||
rootState.Set(path);
|
||||
var (root, isCustom) = rootState.Get();
|
||||
Environment.SetEnvironmentVariable("JOURNAL_PROJECT_ROOT", root);
|
||||
return Results.Ok(new
|
||||
{
|
||||
root,
|
||||
isCustom
|
||||
});
|
||||
await next();
|
||||
});
|
||||
|
||||
if (Directory.Exists(webDistPath) && File.Exists(Path.Combine(webDistPath, "index.html")))
|
||||
// Better handling for 401 challenges on browser requests
|
||||
app.Use(async (context, next) =>
|
||||
{
|
||||
var fileProvider = new PhysicalFileProvider(webDistPath);
|
||||
var indexPath = Path.Combine(webDistPath, "index.html");
|
||||
await next();
|
||||
|
||||
app.UseDefaultFiles(new DefaultFilesOptions
|
||||
if (context.Response.StatusCode == 401 &&
|
||||
!context.Response.HasStarted &&
|
||||
context.Request.Path != "/gateway/login" &&
|
||||
context.Request.Headers.Accept.ToString().Contains("text/html"))
|
||||
{
|
||||
FileProvider = fileProvider,
|
||||
RequestPath = ""
|
||||
});
|
||||
context.Response.Redirect("/gateway/login");
|
||||
}
|
||||
});
|
||||
|
||||
app.UseStaticFiles(new StaticFileOptions
|
||||
{
|
||||
FileProvider = fileProvider,
|
||||
RequestPath = ""
|
||||
});
|
||||
|
||||
app.MapGet("/", async context =>
|
||||
{
|
||||
context.Response.ContentType = "text/html; charset=utf-8";
|
||||
await context.Response.SendFileAsync(indexPath);
|
||||
});
|
||||
|
||||
app.MapFallback(async context =>
|
||||
{
|
||||
if (context.Request.Path.StartsWithSegments("/api"))
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status404NotFound;
|
||||
return;
|
||||
}
|
||||
|
||||
context.Response.ContentType = "text/html; charset=utf-8";
|
||||
await context.Response.SendFileAsync(indexPath);
|
||||
});
|
||||
}
|
||||
else
|
||||
app.Use(async (context, next) =>
|
||||
{
|
||||
app.MapGet("/", () => Results.Ok(new
|
||||
if (securitySettings.GetValue<bool>("EnableAuditLogging"))
|
||||
{
|
||||
name = "Journal.WebGateway",
|
||||
status = "ok",
|
||||
uiAvailable = false,
|
||||
message = "No built web UI found. Build Journal.App with ./scripts/publish-app.ps1 -Target web.",
|
||||
expectedDist = webDistPath
|
||||
}));
|
||||
var logger = context.RequestServices.GetRequiredService<ILogger<Program>>();
|
||||
var ip = context.Connection.RemoteIpAddress?.ToString() ?? "unknown";
|
||||
var userAgent = context.Request.Headers["User-Agent"].ToString();
|
||||
var user = context.User.Identity?.Name ?? "Anonymous";
|
||||
|
||||
var timestamp = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss");
|
||||
logger.LogInformation("[{Timestamp}] Audit: [{IP}] {Method} {Path} accessed by {User} ({UA})",
|
||||
timestamp, ip, context.Request.Method, context.Request.Path, user, userAgent);
|
||||
}
|
||||
await next();
|
||||
});
|
||||
|
||||
// Static Files Internal Middleware
|
||||
if (Directory.Exists(webDistPath))
|
||||
{
|
||||
app.UseDefaultFiles(new DefaultFilesOptions { FileProvider = new PhysicalFileProvider(webDistPath), RequestPath = "" });
|
||||
app.UseStaticFiles(new StaticFileOptions { FileProvider = new PhysicalFileProvider(webDistPath), RequestPath = "" });
|
||||
}
|
||||
|
||||
// Map specialized endpoints
|
||||
app.MapGatewayEndpoints(webDistPath);
|
||||
|
||||
app.Run();
|
||||
|
||||
string ResolveRepoRoot()
|
||||
static bool TryHandlePasswordHashCommand(string[] args)
|
||||
{
|
||||
var fromEnv = Environment.GetEnvironmentVariable("JOURNAL_PROJECT_ROOT");
|
||||
if (!string.IsNullOrWhiteSpace(fromEnv) && Directory.Exists(fromEnv))
|
||||
{
|
||||
return Path.GetFullPath(fromEnv);
|
||||
}
|
||||
if (args.Length == 0 || !string.Equals(args[0], "hash-password", StringComparison.OrdinalIgnoreCase))
|
||||
return false;
|
||||
|
||||
foreach (var start in new[] { Directory.GetCurrentDirectory(), AppContext.BaseDirectory })
|
||||
string password;
|
||||
if (args.Length > 1)
|
||||
{
|
||||
var resolved = FindRepoRoot(start);
|
||||
if (resolved is not null)
|
||||
password = args[1];
|
||||
}
|
||||
else
|
||||
{
|
||||
password = PromptForPassword("New gateway password: ");
|
||||
var confirm = PromptForPassword("Confirm gateway password: ");
|
||||
if (!string.Equals(password, confirm, StringComparison.Ordinal))
|
||||
{
|
||||
return resolved;
|
||||
Console.Error.WriteLine("Passwords did not match.");
|
||||
Environment.ExitCode = 1;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return Path.GetFullPath(Directory.GetCurrentDirectory());
|
||||
if (string.IsNullOrWhiteSpace(password))
|
||||
{
|
||||
Console.Error.WriteLine("Password cannot be empty.");
|
||||
Environment.ExitCode = 1;
|
||||
return true;
|
||||
}
|
||||
|
||||
Console.WriteLine(GatewayPasswordHasher.HashPassword(password));
|
||||
return true;
|
||||
}
|
||||
|
||||
string? FindRepoRoot(string start)
|
||||
static string PromptForPassword(string prompt)
|
||||
{
|
||||
var cursor = Path.GetFullPath(start);
|
||||
Console.Write(prompt);
|
||||
|
||||
while (!string.IsNullOrWhiteSpace(cursor))
|
||||
var buffer = new List<char>();
|
||||
while (true)
|
||||
{
|
||||
if (File.Exists(Path.Combine(cursor, "Journal.slnx")) ||
|
||||
Directory.Exists(Path.Combine(cursor, "Journal.Sidecar")) ||
|
||||
Directory.Exists(Path.Combine(cursor, "Journal.Core")))
|
||||
var key = Console.ReadKey(intercept: true);
|
||||
if (key.Key == ConsoleKey.Enter)
|
||||
{
|
||||
return cursor;
|
||||
Console.WriteLine();
|
||||
return new string([.. buffer]);
|
||||
}
|
||||
|
||||
var parent = Directory.GetParent(cursor);
|
||||
if (parent is null || string.Equals(parent.FullName, cursor, StringComparison.OrdinalIgnoreCase))
|
||||
if (key.Key == ConsoleKey.Backspace)
|
||||
{
|
||||
return null;
|
||||
if (buffer.Count > 0)
|
||||
buffer.RemoveAt(buffer.Count - 1);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
cursor = parent.FullName;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
string ResolveWebDist(string repoRootPath)
|
||||
{
|
||||
var fromEnv = Environment.GetEnvironmentVariable("JOURNAL_WEB_DIST");
|
||||
if (!string.IsNullOrWhiteSpace(fromEnv))
|
||||
{
|
||||
return Path.GetFullPath(fromEnv);
|
||||
}
|
||||
|
||||
var packagedWwwRoot = Path.Combine(AppContext.BaseDirectory, "wwwroot");
|
||||
if (Directory.Exists(packagedWwwRoot))
|
||||
{
|
||||
return packagedWwwRoot;
|
||||
}
|
||||
|
||||
return Path.Combine(repoRootPath, "Journal.App", "build");
|
||||
}
|
||||
|
||||
string ErrorResponse(string message)
|
||||
=> JsonSerializer.Serialize(new { ok = false, error = message }, gatewayJsonOptions);
|
||||
|
||||
sealed class WebUiState(string distPath)
|
||||
{
|
||||
public string DistPath { get; } = distPath;
|
||||
|
||||
public bool Exists => Directory.Exists(DistPath) && File.Exists(Path.Combine(DistPath, "index.html"));
|
||||
}
|
||||
|
||||
sealed class SidecarRootState(string autoRoot)
|
||||
{
|
||||
private readonly object _sync = new();
|
||||
private readonly string _autoRoot = autoRoot;
|
||||
private string _currentRoot = autoRoot;
|
||||
private bool _isCustom;
|
||||
|
||||
public (string Root, bool IsCustom) Get()
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
return (_currentRoot, _isCustom);
|
||||
}
|
||||
}
|
||||
|
||||
public void Set(string? path)
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
_currentRoot = _autoRoot;
|
||||
_isCustom = false;
|
||||
return;
|
||||
}
|
||||
|
||||
_currentRoot = Path.GetFullPath(path.Trim());
|
||||
_isCustom = true;
|
||||
}
|
||||
if (!char.IsControl(key.KeyChar))
|
||||
buffer.Add(key.KeyChar);
|
||||
}
|
||||
}
|
||||
|
||||
sealed class SetSidecarRootRequest
|
||||
{
|
||||
public string? Path { get; set; }
|
||||
}
|
||||
|
||||
sealed class CommandEnvelope
|
||||
{
|
||||
public string Action { get; set; } = "";
|
||||
public string? CorrelationId { get; set; }
|
||||
public string? Id { get; set; }
|
||||
public string? Type { get; set; }
|
||||
public string? Tag { get; set; }
|
||||
public JsonElement? Payload { get; set; }
|
||||
}
|
||||
|
||||
39
Journal.WebGateway/Security/ApiKeyAuthenticationHandler.cs
Normal file
39
Journal.WebGateway/Security/ApiKeyAuthenticationHandler.cs
Normal file
@ -0,0 +1,39 @@
|
||||
using System.Text.Encodings.Web;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
|
||||
namespace Journal.WebGateway.Security;
|
||||
|
||||
public class ApiKeyAuthenticationHandler(
|
||||
IOptionsMonitor<AuthenticationSchemeOptions> options,
|
||||
ILoggerFactory logger,
|
||||
UrlEncoder encoder,
|
||||
IConfiguration config)
|
||||
: AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder)
|
||||
{
|
||||
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
|
||||
{
|
||||
if (!Request.Headers.TryGetValue("X-API-KEY", out var extractedApiKey))
|
||||
return Task.FromResult(AuthenticateResult.NoResult()); // Fallback to other schemes (like Cookie)
|
||||
|
||||
var expectedApiKey = config.GetValue<string>("Security:ApiKey");
|
||||
if (string.Equals(expectedApiKey, extractedApiKey, StringComparison.Ordinal))
|
||||
{
|
||||
var claims = new[] { new System.Security.Claims.Claim(System.Security.Claims.ClaimTypes.Name, "ApiKeyUser") };
|
||||
var identity = new System.Security.Claims.ClaimsIdentity(claims, Scheme.Name);
|
||||
var principal = new System.Security.Claims.ClaimsPrincipal(identity);
|
||||
var ticket = new AuthenticationTicket(principal, Scheme.Name);
|
||||
|
||||
return Task.FromResult(AuthenticateResult.Success(ticket));
|
||||
}
|
||||
|
||||
return Task.FromResult(AuthenticateResult.Fail("Invalid API Key provided."));
|
||||
}
|
||||
|
||||
// Do NOT issue a 401 challenge from this handler.
|
||||
// Let the Cookie scheme handle it (which redirects to /gateway/login).
|
||||
protected override Task HandleChallengeAsync(AuthenticationProperties properties)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
69
Journal.WebGateway/Security/GatewayPasswordHasher.cs
Normal file
69
Journal.WebGateway/Security/GatewayPasswordHasher.cs
Normal file
@ -0,0 +1,69 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace Journal.WebGateway.Security;
|
||||
|
||||
public static class GatewayPasswordHasher
|
||||
{
|
||||
private const string AlgorithmName = "PBKDF2-SHA256";
|
||||
private const int SaltSize = 16;
|
||||
private const int KeySize = 32;
|
||||
private const int IterationCount = 600_000;
|
||||
|
||||
public static string HashPassword(string password)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(password);
|
||||
|
||||
var salt = RandomNumberGenerator.GetBytes(SaltSize);
|
||||
var key = Rfc2898DeriveBytes.Pbkdf2(
|
||||
Encoding.UTF8.GetBytes(password),
|
||||
salt,
|
||||
IterationCount,
|
||||
HashAlgorithmName.SHA256,
|
||||
KeySize);
|
||||
|
||||
return string.Join(
|
||||
'$',
|
||||
AlgorithmName,
|
||||
IterationCount.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
||||
Convert.ToBase64String(salt),
|
||||
Convert.ToBase64String(key));
|
||||
}
|
||||
|
||||
public static bool VerifyPassword(string password, string passwordHash)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(password) || string.IsNullOrWhiteSpace(passwordHash))
|
||||
return false;
|
||||
|
||||
var parts = passwordHash.Split('$', StringSplitOptions.None);
|
||||
if (parts.Length != 4)
|
||||
return false;
|
||||
|
||||
if (!string.Equals(parts[0], AlgorithmName, StringComparison.Ordinal))
|
||||
return false;
|
||||
|
||||
if (!int.TryParse(parts[1], out var iterations) || iterations <= 0)
|
||||
return false;
|
||||
|
||||
byte[] salt;
|
||||
byte[] expectedKey;
|
||||
try
|
||||
{
|
||||
salt = Convert.FromBase64String(parts[2]);
|
||||
expectedKey = Convert.FromBase64String(parts[3]);
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var actualKey = Rfc2898DeriveBytes.Pbkdf2(
|
||||
Encoding.UTF8.GetBytes(password),
|
||||
salt,
|
||||
iterations,
|
||||
HashAlgorithmName.SHA256,
|
||||
expectedKey.Length);
|
||||
|
||||
return CryptographicOperations.FixedTimeEquals(actualKey, expectedKey);
|
||||
}
|
||||
}
|
||||
24
Journal.WebGateway/appsettings.json
Normal file
24
Journal.WebGateway/appsettings.json
Normal file
@ -0,0 +1,24 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
"GatewaySettings": {
|
||||
"Port": 5180,
|
||||
"RepoRoot": null,
|
||||
"WebDist": null
|
||||
},
|
||||
"Description": "API key not fully implemented yet. It is not used for authentication.",
|
||||
"Security": {
|
||||
"ApiKey": "CHANGE-ME",
|
||||
"AccessPasswordHash": "",
|
||||
"AllowedOrigins": [
|
||||
"http://localhost:5180"
|
||||
],
|
||||
"EnforceHttps": false,
|
||||
"EnableAuditLogging": true
|
||||
}
|
||||
}
|
||||
53
README.md
53
README.md
@ -25,7 +25,7 @@ backend/ Monorepo root
|
||||
|
||||
## Deployment Modes
|
||||
|
||||
The backend can run in three modes depending on the surface wired to it:
|
||||
The supported primary workflow is the desktop app with the local sidecar. WebGateway is a separate secondary surface for browser/mobile access.
|
||||
|
||||
| Mode | Host | Frontend |
|
||||
|------|------|----------|
|
||||
@ -64,7 +64,7 @@ dotnet publish Journal.Sidecar/Journal.Sidecar.csproj -c Release -r win-x64 --se
|
||||
npm run tauri build -w Journal.App
|
||||
```
|
||||
|
||||
Tauri auto-detects `Journal.Sidecar.exe` in the repository. On first launch it walks up from the working directory to find `Journal.Sidecar/` and resolves the built executable.
|
||||
Tauri auto-detects `Journal.Sidecar.exe` in the repository. On first launch it walks up from the working directory to find `Journal.Sidecar/` and resolves the built executable. If you want the desktop app to use a different root, set it from Settings. That choice applies only to the desktop app.
|
||||
|
||||
### Option B — WebGateway Server (browser mode)
|
||||
|
||||
@ -75,13 +75,27 @@ npm run build -w Journal.App
|
||||
dotnet publish Journal.WebGateway/Journal.WebGateway.csproj -c Release -r win-x64
|
||||
```
|
||||
|
||||
Set the authoritative journal root explicitly before running a published gateway:
|
||||
|
||||
```powershell
|
||||
$env:JOURNAL_PROJECT_ROOT = "F:\path\to\journal-root"
|
||||
```
|
||||
|
||||
Run the gateway:
|
||||
|
||||
```powershell
|
||||
dotnet run --project Journal.WebGateway
|
||||
```
|
||||
|
||||
Open `http://localhost:5180` in your browser. The gateway automatically serves the SvelteKit build and proxies all `/api/command` calls to `Journal.Core`.
|
||||
Open `http://localhost:5180` in your browser. The gateway serves the SvelteKit build and proxies `/api/command` calls to `Journal.Core`.
|
||||
|
||||
Before browser login will work, generate a password hash and place it in `Journal.WebGateway/appsettings.json` under `Security:AccessPasswordHash`:
|
||||
|
||||
```powershell
|
||||
dotnet run --project Journal.WebGateway -- hash-password
|
||||
```
|
||||
|
||||
This prints a PBKDF2-SHA256 hash. Store the hash, not the plaintext password.
|
||||
|
||||
Quick health check:
|
||||
|
||||
@ -216,6 +230,28 @@ The `Journal.AI` project uses **LLamaSharp** for local LLM inference.
|
||||
|
||||
An ASP.NET Core minimal API that wraps `Journal.Core` for browser use.
|
||||
|
||||
### Gateway Authentication
|
||||
|
||||
- Browser access is protected by a cookie login page at `/gateway/login`.
|
||||
- The configured secret is `Security:AccessPasswordHash`, not a plaintext password.
|
||||
- Generate a hash with:
|
||||
|
||||
```powershell
|
||||
dotnet run --project Journal.WebGateway -- hash-password
|
||||
```
|
||||
|
||||
- Paste the resulting value into `Journal.WebGateway/appsettings.json` or set it via environment variable `Security__AccessPasswordHash`.
|
||||
- The vault password remains separate and is still entered when unlocking the encrypted workspace.
|
||||
- In published deployments, also set `GatewaySettings:RepoRoot` or `JOURNAL_PROJECT_ROOT`. Published WebGateway builds no longer guess the vault root at runtime.
|
||||
- If the gateway hits a locked workspace, the web UI prompts for the vault password and retries instead of leaving the raw lock error on screen.
|
||||
|
||||
### Authoritative Root
|
||||
|
||||
- Desktop and future mobile-native apps are the authoritative clients.
|
||||
- Published WebGateway should point at that same authoritative root, but it does not share the desktop process or unlock state.
|
||||
- In the desktop Settings screen, use `Adopt Current Root` under `Gateway Root` to write the current desktop root into the packaged gateway `appsettings.json` as a one-time alignment step.
|
||||
- For manual deployments, set `GatewaySettings:RepoRoot` in `output/webgateway/appsettings.json` or set `JOURNAL_PROJECT_ROOT` before launch.
|
||||
|
||||
### Endpoints
|
||||
|
||||
| Method | Path | Description |
|
||||
@ -224,7 +260,8 @@ An ASP.NET Core minimal API that wraps `Journal.Core` for browser use.
|
||||
| `POST` | `/api/command` | Send a JSON command to `Entry.HandleCommandAsync` |
|
||||
| `GET` | `/api/web/status` | Reports web dist path and whether UI is available |
|
||||
| `GET` | `/api/sidecar/root` | Returns current project root (auto-detected or custom) |
|
||||
| `POST` | `/api/sidecar/root` | Override project root at runtime |
|
||||
| `POST` | `/api/sidecar/root` | Disabled in WebGateway; root is startup-only |
|
||||
| `GET` | `/api/runtime/diagnostics` | Returns resolved root, vault path, DB path, and gateway path |
|
||||
| `GET` | `/*` | Serves built SvelteKit UI from `wwwroot` (SPA fallback) |
|
||||
|
||||
### Web UI Resolution
|
||||
@ -243,6 +280,13 @@ If no dist is found, `/` returns a JSON status message instead of the UI.
|
||||
dotnet run --project Journal.WebGateway
|
||||
```
|
||||
|
||||
For published output, configure the root before launch:
|
||||
|
||||
```powershell
|
||||
$env:JOURNAL_PROJECT_ROOT = "F:\path\to\journal-root"
|
||||
.\Journal.WebGateway.exe
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Journal.App (Tauri + SvelteKit)
|
||||
@ -271,6 +315,7 @@ Tauri commands exposed to the frontend:
|
||||
| `sidecar_command` | Forward a `CommandEnvelope` to `Journal.Sidecar` and return parsed JSON |
|
||||
| `get_sidecar_root` | Get the current resolved sidecar root path |
|
||||
| `set_sidecar_root` | Override sidecar root path (saves to `settings.json`, restarts sidecar) |
|
||||
| `get_runtime_diagnostics` | Inspect resolved root, vault/database paths, sidecar path, and gateway path/url |
|
||||
| `get_ui_settings` | Load tag/fragment-type settings from `settings.json` |
|
||||
| `set_ui_settings` | Persist tag/fragment-type settings |
|
||||
| `shutdown` | Stop the sidecar and exit the app |
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user