diff --git a/.gitignore b/.gitignore index 67e0a08..ca20e38 100644 --- a/.gitignore +++ b/.gitignore @@ -57,4 +57,6 @@ Journal.DevTool/scripts/__pycache__/ .sdt/ devtool.backup.json Journal.App/node_modules.old/ -scripts/__pycache__/ \ No newline at end of file +scripts/__pycache__/ +Journal.WebGateway/cookies.txt +output.7z diff --git a/Journal.App/src-tauri/Cargo.lock b/Journal.App/src-tauri/Cargo.lock index 95b410b..83c7556 100644 --- a/Journal.App/src-tauri/Cargo.lock +++ b/Journal.App/src-tauri/Cargo.lock @@ -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" diff --git a/Journal.App/src-tauri/Cargo.toml b/Journal.App/src-tauri/Cargo.toml index 1f39d74..11be1fa 100644 --- a/Journal.App/src-tauri/Cargo.toml +++ b/Journal.App/src-tauri/Cargo.toml @@ -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"] } diff --git a/Journal.App/src-tauri/src/lib.rs b/Journal.App/src-tauri/src/lib.rs index 8991a5e..c3230c3 100644 --- a/Journal.App/src-tauri/src/lib.rs +++ b/Journal.App/src-tauri/src/lib.rs @@ -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 { - 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 { + let mut candidates = Vec::new(); + let push_unique = |value: PathBuf, items: &mut Vec| { + 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 { + 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 { + 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) -> Result { @@ -243,9 +302,9 @@ fn resolve_sidecar_path(root: &Path, resource_dir: Option<&Path>) -> Result Option Result { + 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 { + let json = fs::read_to_string(config_path).ok()?; + let value = serde_json::from_str::(&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, +) -> Result { + 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 { + 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::(&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), diff --git a/Journal.App/src/app.html b/Journal.App/src/app.html index 5e2e21c..27ae448 100644 --- a/Journal.App/src/app.html +++ b/Journal.App/src/app.html @@ -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" /> - - + + Journal %sveltekit.head% diff --git a/Journal.App/src/lib/backend/client.ts b/Journal.App/src/lib/backend/client.ts index 81b5dc2..2229e37 100644 --- a/Journal.App/src/lib/backend/client.ts +++ b/Journal.App/src/lib/backend/client.ts @@ -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( command: BackendCommand, options: SendCommandOptions = {}, @@ -23,7 +32,22 @@ export async function sendCommand( }); 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(command, { + ...options, + unlockRetryAttempted: true, + }); + } + } + + throw new Error(errorMessage); } return response.data; diff --git a/Journal.App/src/lib/components/AppModal.svelte b/Journal.App/src/lib/components/AppModal.svelte index 240d830..8965dd5 100644 --- a/Journal.App/src/lib/components/AppModal.svelte +++ b/Journal.App/src/lib/components/AppModal.svelte @@ -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; + } + } diff --git a/Journal.App/src/lib/components/EditorPanel.svelte b/Journal.App/src/lib/components/EditorPanel.svelte index 7f714b8..bd9c654 100644 --- a/Journal.App/src/lib/components/EditorPanel.svelte +++ b/Journal.App/src/lib/components/EditorPanel.svelte @@ -35,6 +35,8 @@ export let calendarError = ""; export let previewOnly = true; export let onForceSave: () => Promise | void = () => {}; + export let onRequestEdit: () => void = () => {}; + export let onRequestPreview: () => void = () => {}; type CalendarCard = { id: string; @@ -203,6 +205,8 @@ {onForceSave} {onOpenDocument} {previewOnly} + {onRequestEdit} + {onRequestPreview} /> {/if} @@ -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; + } + } diff --git a/Journal.App/src/lib/components/Navbar.svelte b/Journal.App/src/lib/components/Navbar.svelte index b33bf12..88219cd 100644 --- a/Journal.App/src/lib/components/Navbar.svelte +++ b/Journal.App/src/lib/components/Navbar.svelte @@ -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; + } + } diff --git a/Journal.App/src/lib/components/SidePanel.svelte b/Journal.App/src/lib/components/SidePanel.svelte index 41378f3..db7f41b 100644 --- a/Journal.App/src/lib/components/SidePanel.svelte +++ b/Journal.App/src/lib/components/SidePanel.svelte @@ -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; } diff --git a/Journal.App/src/lib/components/editor/MarkdownEditor.svelte b/Journal.App/src/lib/components/editor/MarkdownEditor.svelte index c173155..5225c0e 100644 --- a/Journal.App/src/lib/components/editor/MarkdownEditor.svelte +++ b/Journal.App/src/lib/components/editor/MarkdownEditor.svelte @@ -29,6 +29,8 @@ export let openDocumentContent = ""; export let onDocumentContentChange: (content: string) => void = () => {}; export let onForceSave: () => Promise | void = () => {}; + export let onRequestEdit: () => void = () => {}; + export let onRequestPreview: () => void = () => {}; export let onOpenDocument: (doc: { id: string; label: string; @@ -677,6 +679,33 @@

{editorTitle}

+ {#if openDocumentId} +
+ {#if previewOnly} + + {:else} + + {/if} +
+ {/if}
@@ -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; + } + } diff --git a/Journal.App/src/lib/runtime/invoke.ts b/Journal.App/src/lib/runtime/invoke.ts index dbac2b1..062057e 100644 --- a/Journal.App/src/lib/runtime/invoke.ts +++ b/Journal.App/src/lib/runtime/invoke.ts @@ -88,8 +88,9 @@ async function fetchJson( path: string, init: RequestInit = {}, options: FetchJsonOptions = {}, + apiBase?: string, ): Promise { - const response = await fetch(`${normalizedApiBase()}${path}`, { + const response = await fetch(`${apiBase ?? normalizedApiBase()}${path}`, { ...init, keepalive: options.keepalive === true, headers: { diff --git a/Journal.App/src/lib/stores/session.ts b/Journal.App/src/lib/stores/session.ts index 0d5c803..4b232c9 100644 --- a/Journal.App/src/lib/stores/session.ts +++ b/Journal.App/src/lib/stores/session.ts @@ -2,6 +2,7 @@ import { writable, get } from "svelte/store"; const _password = writable(null); const _unlocked = writable(false); +let _unlockHandler: (() => Promise) | null = null; export const vaultUnlocked = { subscribe: _unlocked.subscribe }; @@ -23,6 +24,20 @@ export function clearVaultSession(): void { _unlocked.set(false); } +export function setUnlockHandler( + fn: (() => Promise) | null, +): void { + _unlockHandler = fn; +} + +export async function requestVaultUnlock(): Promise { + if (!_unlockHandler) { + return false; + } + + return _unlockHandler(); +} + let _flushCallback: (() => Promise) | null = null; export function setFlushCallback(fn: () => Promise): void { diff --git a/Journal.App/src/routes/+page.svelte b/Journal.App/src/routes/+page.svelte index 1b45696..3500229 100644 --- a/Journal.App/src/routes/+page.svelte +++ b/Journal.App/src/routes/+page.svelte @@ -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 { + 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); + }; }); @@ -709,6 +756,8 @@ calendarError={calendarPanelState.error} previewOnly={!editMode} onForceSave={handleForceSave} + onRequestEdit={handleEnterEditMode} + onRequestPreview={handleExitEditMode} /> diff --git a/Journal.App/src/routes/settings/+page.svelte b/Journal.App/src/routes/settings/+page.svelte index ad86073..9fa9332 100644 --- a/Journal.App/src/routes/settings/+page.svelte +++ b/Journal.App/src/routes/settings/+page.svelte @@ -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("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("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 @@

{sidecarRootError}

{/if}
+ +
+
+

+ + Gateway Root +

+

+ Published WebGateway builds should point at one authoritative root. + Use this one-time adopt action to align the packaged gateway with + the desktop app. +

+
+ + {#if isTauriRuntime()} +
+ + +
+ + {#if gatewayRootStatus} + + + + + {/if} + {:else} +

+ Configure `GatewaySettings:RepoRoot` or `JOURNAL_PROJECT_ROOT` + before starting the published gateway. +

+ {/if} + + {#if gatewayRootError} +

{gatewayRootError}

+ {/if} +
diff --git a/Journal.App/static/style.css b/Journal.App/static/style.css index 873272b..d1d2209 100644 --- a/Journal.App/static/style.css +++ b/Journal.App/static/style.css @@ -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; } } diff --git a/Journal.Core/Services/Database/DatabaseSessionService.cs b/Journal.Core/Services/Database/DatabaseSessionService.cs index 80ddee6..7473c02 100644 --- a/Journal.Core/Services/Database/DatabaseSessionService.cs +++ b/Journal.Core/Services/Database/DatabaseSessionService.cs @@ -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; } } diff --git a/Journal.Core/Services/Database/IJournalDatabaseService.cs b/Journal.Core/Services/Database/IJournalDatabaseService.cs index 77ef2e9..96b0d2b 100644 --- a/Journal.Core/Services/Database/IJournalDatabaseService.cs +++ b/Journal.Core/Services/Database/IJournalDatabaseService.cs @@ -11,6 +11,7 @@ public interface IJournalDatabaseService IReadOnlyDictionary GetSchemaStatements(); SqliteConnection OpenEncryptedConnection(string password); void EnsureSchema(SqliteConnection connection); + void EnsureSchemaReady(SqliteConnection connection); JournalDatabaseStatus GetStatus(string password); JournalDatabaseHydrationResult HydrateWorkspace(string password); } diff --git a/Journal.Core/Services/Database/JournalDatabaseService.cs b/Journal.Core/Services/Database/JournalDatabaseService.cs index b5eb46c..c99e7d9 100644 --- a/Journal.Core/Services/Database/JournalDatabaseService.cs +++ b/Journal.Core/Services/Database/JournalDatabaseService.cs @@ -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(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.") diff --git a/Journal.Core/Services/Vault/VaultStorageService.cs b/Journal.Core/Services/Vault/VaultStorageService.cs index a676a8a..74798e7 100644 --- a/Journal.Core/Services/Vault/VaultStorageService.cs +++ b/Journal.Core/Services/Vault/VaultStorageService.cs @@ -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; } diff --git a/Journal.WebGateway/Extensions/EndpointExtensions.cs b/Journal.WebGateway/Extensions/EndpointExtensions.cs new file mode 100644 index 0000000..632a7fb --- /dev/null +++ b/Journal.WebGateway/Extensions/EndpointExtensions.cs @@ -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 logger) => + { + var form = await context.Request.ReadFormAsync(); + var password = form["password"].ToString(); + var configuredHash = config.GetValue("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 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); + }); + } +} diff --git a/Journal.WebGateway/Infrastructure/LoginPage.cs b/Journal.WebGateway/Infrastructure/LoginPage.cs new file mode 100644 index 0000000..6b11be7 --- /dev/null +++ b/Journal.WebGateway/Infrastructure/LoginPage.cs @@ -0,0 +1,117 @@ +namespace Journal.WebGateway.Infrastructure; + +public static class LoginPage +{ + public static string GetHtml() => @" + + + + + + Journal Gateway | Login + + + + +
+

Journal Gateway

+

Enter your access password to continue.

+
+
+ + +
+ +
+
+
+ + +"; +} diff --git a/Journal.WebGateway/Infrastructure/PathResolver.cs b/Journal.WebGateway/Infrastructure/PathResolver.cs new file mode 100644 index 0000000..89fcb75 --- /dev/null +++ b/Journal.WebGateway/Infrastructure/PathResolver.cs @@ -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("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("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")); + } +} diff --git a/Journal.WebGateway/Infrastructure/States.cs b/Journal.WebGateway/Infrastructure/States.cs new file mode 100644 index 0000000..400116f --- /dev/null +++ b/Journal.WebGateway/Infrastructure/States.cs @@ -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; } +} diff --git a/Journal.WebGateway/Program.cs b/Journal.WebGateway/Program.cs index 4500445..0f274cf 100644 --- a/Journal.WebGateway/Program.cs +++ b/Journal.WebGateway/Program.cs @@ -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("PORT") + ?? builder.Configuration.GetValue("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(); -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("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() ?? Array.Empty(); + 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().SetResolved(repoRootResolution.Root, repoRootResolution.IsCustom); + +if (string.IsNullOrWhiteSpace(builder.Configuration.GetValue("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>(); + var feature = context.Features.Get(); + 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("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>(); + 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(); + 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; } -} diff --git a/Journal.WebGateway/Security/ApiKeyAuthenticationHandler.cs b/Journal.WebGateway/Security/ApiKeyAuthenticationHandler.cs new file mode 100644 index 0000000..531515e --- /dev/null +++ b/Journal.WebGateway/Security/ApiKeyAuthenticationHandler.cs @@ -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 options, + ILoggerFactory logger, + UrlEncoder encoder, + IConfiguration config) + : AuthenticationHandler(options, logger, encoder) +{ + protected override Task 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("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; + } +} diff --git a/Journal.WebGateway/Security/GatewayPasswordHasher.cs b/Journal.WebGateway/Security/GatewayPasswordHasher.cs new file mode 100644 index 0000000..dea852e --- /dev/null +++ b/Journal.WebGateway/Security/GatewayPasswordHasher.cs @@ -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); + } +} diff --git a/Journal.WebGateway/appsettings.json b/Journal.WebGateway/appsettings.json new file mode 100644 index 0000000..4eab69b --- /dev/null +++ b/Journal.WebGateway/appsettings.json @@ -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 + } +} diff --git a/README.md b/README.md index bd9da34..503afc6 100644 --- a/README.md +++ b/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 |