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
4
.gitignore
vendored
4
.gitignore
vendored
@ -57,4 +57,6 @@ Journal.DevTool/scripts/__pycache__/
|
|||||||
.sdt/
|
.sdt/
|
||||||
devtool.backup.json
|
devtool.backup.json
|
||||||
Journal.App/node_modules.old/
|
Journal.App/node_modules.old/
|
||||||
scripts/__pycache__/
|
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"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cfg_aliases"
|
||||||
|
version = "0.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "chrono"
|
name = "chrono"
|
||||||
version = "0.4.44"
|
version = "0.4.44"
|
||||||
@ -1402,8 +1408,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
|
"js-sys",
|
||||||
"libc",
|
"libc",
|
||||||
"wasi 0.11.1+wasi-snapshot-preview1",
|
"wasi 0.11.1+wasi-snapshot-preview1",
|
||||||
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -1413,9 +1421,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
|
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
|
"js-sys",
|
||||||
"libc",
|
"libc",
|
||||||
"r-efi",
|
"r-efi",
|
||||||
"wasip2",
|
"wasip2",
|
||||||
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -1702,6 +1712,23 @@ dependencies = [
|
|||||||
"want",
|
"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]]
|
[[package]]
|
||||||
name = "hyper-util"
|
name = "hyper-util"
|
||||||
version = "0.1.20"
|
version = "0.1.20"
|
||||||
@ -2020,6 +2047,7 @@ dependencies = [
|
|||||||
name = "journalapp"
|
name = "journalapp"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"reqwest 0.12.28",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tauri",
|
"tauri",
|
||||||
@ -2184,6 +2212,12 @@ version = "0.4.29"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "lru-slab"
|
||||||
|
version = "0.1.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mac"
|
name = "mac"
|
||||||
version = "0.1.1"
|
version = "0.1.1"
|
||||||
@ -3083,6 +3117,61 @@ dependencies = [
|
|||||||
"memchr",
|
"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]]
|
[[package]]
|
||||||
name = "quote"
|
name = "quote"
|
||||||
version = "1.0.44"
|
version = "1.0.44"
|
||||||
@ -3123,6 +3212,16 @@ dependencies = [
|
|||||||
"rand_core 0.6.4",
|
"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]]
|
[[package]]
|
||||||
name = "rand_chacha"
|
name = "rand_chacha"
|
||||||
version = "0.2.2"
|
version = "0.2.2"
|
||||||
@ -3143,6 +3242,16 @@ dependencies = [
|
|||||||
"rand_core 0.6.4",
|
"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]]
|
[[package]]
|
||||||
name = "rand_core"
|
name = "rand_core"
|
||||||
version = "0.5.1"
|
version = "0.5.1"
|
||||||
@ -3161,6 +3270,15 @@ dependencies = [
|
|||||||
"getrandom 0.2.17",
|
"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]]
|
[[package]]
|
||||||
name = "rand_hc"
|
name = "rand_hc"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
@ -3254,6 +3372,44 @@ version = "0.8.10"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
|
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]]
|
[[package]]
|
||||||
name = "reqwest"
|
name = "reqwest"
|
||||||
version = "0.13.2"
|
version = "0.13.2"
|
||||||
@ -3312,6 +3468,20 @@ dependencies = [
|
|||||||
"windows-sys 0.60.2",
|
"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]]
|
[[package]]
|
||||||
name = "rustc-hash"
|
name = "rustc-hash"
|
||||||
version = "2.1.1"
|
version = "2.1.1"
|
||||||
@ -3340,12 +3510,53 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"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]]
|
[[package]]
|
||||||
name = "rustversion"
|
name = "rustversion"
|
||||||
version = "1.0.22"
|
version = "1.0.22"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
|
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ryu"
|
||||||
|
version = "1.0.23"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "same-file"
|
name = "same-file"
|
||||||
version = "1.0.6"
|
version = "1.0.6"
|
||||||
@ -3535,6 +3746,18 @@ dependencies = [
|
|||||||
"serde_core",
|
"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]]
|
[[package]]
|
||||||
name = "serde_with"
|
name = "serde_with"
|
||||||
version = "3.17.0"
|
version = "3.17.0"
|
||||||
@ -3750,6 +3973,12 @@ version = "0.11.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "subtle"
|
||||||
|
version = "2.6.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "swift-rs"
|
name = "swift-rs"
|
||||||
version = "1.0.7"
|
version = "1.0.7"
|
||||||
@ -3903,7 +4132,7 @@ dependencies = [
|
|||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"plist",
|
"plist",
|
||||||
"raw-window-handle",
|
"raw-window-handle",
|
||||||
"reqwest",
|
"reqwest 0.13.2",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"serde_repr",
|
"serde_repr",
|
||||||
@ -4288,6 +4517,21 @@ dependencies = [
|
|||||||
"zerovec",
|
"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]]
|
[[package]]
|
||||||
name = "tokio"
|
name = "tokio"
|
||||||
version = "1.49.0"
|
version = "1.49.0"
|
||||||
@ -4303,6 +4547,16 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"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]]
|
[[package]]
|
||||||
name = "tokio-util"
|
name = "tokio-util"
|
||||||
version = "0.7.18"
|
version = "0.7.18"
|
||||||
@ -4598,6 +4852,12 @@ version = "0.2.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "untrusted"
|
||||||
|
version = "0.9.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "url"
|
name = "url"
|
||||||
version = "2.5.8"
|
version = "2.5.8"
|
||||||
@ -4850,6 +5110,16 @@ dependencies = [
|
|||||||
"wasm-bindgen",
|
"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]]
|
[[package]]
|
||||||
name = "webkit2gtk"
|
name = "webkit2gtk"
|
||||||
version = "2.0.2"
|
version = "2.0.2"
|
||||||
@ -4894,6 +5164,15 @@ dependencies = [
|
|||||||
"system-deps",
|
"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]]
|
[[package]]
|
||||||
name = "webview2-com"
|
name = "webview2-com"
|
||||||
version = "0.38.2"
|
version = "0.38.2"
|
||||||
@ -5153,6 +5432,15 @@ dependencies = [
|
|||||||
"windows-targets 0.42.2",
|
"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]]
|
[[package]]
|
||||||
name = "windows-sys"
|
name = "windows-sys"
|
||||||
version = "0.59.0"
|
version = "0.59.0"
|
||||||
@ -5697,6 +5985,12 @@ dependencies = [
|
|||||||
"synstructure",
|
"synstructure",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zeroize"
|
||||||
|
version = "1.8.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zerotrie"
|
name = "zerotrie"
|
||||||
version = "0.2.3"
|
version = "0.2.3"
|
||||||
|
|||||||
@ -25,3 +25,4 @@ serde = { version = "1", features = ["derive"] }
|
|||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
tokio = { version = "1", features = ["process", "io-util", "sync", "time"] }
|
tokio = { version = "1", features = ["process", "io-util", "sync", "time"] }
|
||||||
tauri-plugin-mic-recorder = "2.0.0"
|
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_SETTINGS_TAGS: &[&str] = &["Personal", "Work", "Ideas", "Journal"];
|
||||||
const DEFAULT_FRAGMENT_TYPES: &[&str] = &["Quote", "Snippet", "Reference"];
|
const DEFAULT_FRAGMENT_TYPES: &[&str] = &["Quote", "Snippet", "Reference"];
|
||||||
const DEFAULT_STARTUP_VIEW: &str = "entries";
|
const DEFAULT_STARTUP_VIEW: &str = "entries";
|
||||||
|
|
||||||
#[derive(Deserialize, Serialize)]
|
#[derive(Deserialize, Serialize)]
|
||||||
struct AppSettings {
|
struct AppSettings {
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[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}"))
|
fs::write(path, json).map_err(|e| format!("Failed to save settings: {e}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn auto_detect_root() -> Result<PathBuf, String> {
|
fn candidate_roots(root: &Path) -> Vec<PathBuf> {
|
||||||
let mut current =
|
let mut candidates = Vec::new();
|
||||||
env::current_dir().map_err(|err| format!("Unable to read current dir: {err}"))?;
|
let push_unique = |value: PathBuf, items: &mut Vec<PathBuf>| {
|
||||||
loop {
|
if !items.iter().any(|existing| existing == &value) {
|
||||||
if current.join("Journal.Sidecar").exists() {
|
items.push(value);
|
||||||
return Ok(current);
|
|
||||||
}
|
}
|
||||||
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> {
|
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());
|
return Ok(root.to_path_buf());
|
||||||
}
|
}
|
||||||
|
|
||||||
let root_exe_path = root.join(exe_name);
|
let direct = root.join(exe_name);
|
||||||
if root_exe_path.exists() {
|
if direct.exists() {
|
||||||
return Ok(root_exe_path);
|
return Ok(direct);
|
||||||
}
|
}
|
||||||
|
|
||||||
let tauri_bin_sidecar_path = root
|
let tauri_bin_sidecar_path = root
|
||||||
@ -324,6 +383,37 @@ fn find_sidecar_executable(search_root: &Path, exe_name: &str) -> Option<PathBuf
|
|||||||
None
|
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(
|
async fn send_with_managed_sidecar(
|
||||||
state: &SidecarState,
|
state: &SidecarState,
|
||||||
input_line: &str,
|
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]
|
#[tauri::command]
|
||||||
async fn set_sidecar_root(
|
async fn set_sidecar_root(
|
||||||
state: tauri::State<'_, SidecarState>,
|
state: tauri::State<'_, SidecarState>,
|
||||||
@ -442,7 +590,6 @@ async fn set_sidecar_root(
|
|||||||
let mut guard = state.process.lock().await;
|
let mut guard = state.process.lock().await;
|
||||||
guard.take();
|
guard.take();
|
||||||
}
|
}
|
||||||
|
|
||||||
let is_custom = new_override.is_some();
|
let is_custom = new_override.is_some();
|
||||||
*state.root_override.lock().await = new_override.clone();
|
*state.root_override.lock().await = new_override.clone();
|
||||||
|
|
||||||
@ -670,6 +817,8 @@ pub fn run() {
|
|||||||
speech_stop,
|
speech_stop,
|
||||||
speech_cleanup_probe,
|
speech_cleanup_probe,
|
||||||
get_sidecar_root,
|
get_sidecar_root,
|
||||||
|
get_gateway_root_status,
|
||||||
|
adopt_sidecar_root_for_gateway,
|
||||||
set_sidecar_root,
|
set_sidecar_root,
|
||||||
get_ui_settings,
|
get_ui_settings,
|
||||||
set_ui_settings,
|
set_ui_settings,
|
||||||
@ -679,7 +828,7 @@ pub fn run() {
|
|||||||
fs::create_dir_all(&config_dir).ok();
|
fs::create_dir_all(&config_dir).ok();
|
||||||
let config_path = config_dir.join("settings.json");
|
let config_path = config_dir.join("settings.json");
|
||||||
let settings = load_settings(&config_path);
|
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 {
|
app.manage(SidecarState {
|
||||||
process: Mutex::new(None),
|
process: Mutex::new(None),
|
||||||
|
|||||||
@ -7,8 +7,11 @@
|
|||||||
rel="stylesheet"
|
rel="stylesheet"
|
||||||
href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200"
|
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" />
|
<link rel="stylesheet" href="style.css?v=20260328b" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta
|
||||||
|
name="viewport"
|
||||||
|
content="width=device-width, initial-scale=1, viewport-fit=cover"
|
||||||
|
/>
|
||||||
<title>Journal</title>
|
<title>Journal</title>
|
||||||
%sveltekit.head%
|
%sveltekit.head%
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
@ -1,4 +1,8 @@
|
|||||||
import { invoke } from "$lib/runtime/invoke";
|
import { invoke } from "$lib/runtime/invoke";
|
||||||
|
import {
|
||||||
|
clearVaultSession,
|
||||||
|
requestVaultUnlock,
|
||||||
|
} from "$lib/stores/session";
|
||||||
import type { BackendCommand, BackendResponse } from "./types";
|
import type { BackendCommand, BackendResponse } from "./types";
|
||||||
|
|
||||||
function newCorrelationId(): string {
|
function newCorrelationId(): string {
|
||||||
@ -7,8 +11,13 @@ function newCorrelationId(): string {
|
|||||||
|
|
||||||
type SendCommandOptions = {
|
type SendCommandOptions = {
|
||||||
keepalive?: boolean;
|
keepalive?: boolean;
|
||||||
|
unlockRetryAttempted?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function isDatabaseLockedError(message: string): boolean {
|
||||||
|
return message.toLowerCase().includes("database is locked");
|
||||||
|
}
|
||||||
|
|
||||||
export async function sendCommand<T>(
|
export async function sendCommand<T>(
|
||||||
command: BackendCommand,
|
command: BackendCommand,
|
||||||
options: SendCommandOptions = {},
|
options: SendCommandOptions = {},
|
||||||
@ -23,7 +32,22 @@ export async function sendCommand<T>(
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
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;
|
return response.data;
|
||||||
|
|||||||
@ -162,4 +162,29 @@
|
|||||||
background: var(--surface-3);
|
background: var(--surface-3);
|
||||||
color: var(--text-primary);
|
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>
|
</style>
|
||||||
|
|||||||
@ -35,6 +35,8 @@
|
|||||||
export let calendarError = "";
|
export let calendarError = "";
|
||||||
export let previewOnly = true;
|
export let previewOnly = true;
|
||||||
export let onForceSave: () => Promise<void> | void = () => {};
|
export let onForceSave: () => Promise<void> | void = () => {};
|
||||||
|
export let onRequestEdit: () => void = () => {};
|
||||||
|
export let onRequestPreview: () => void = () => {};
|
||||||
|
|
||||||
type CalendarCard = {
|
type CalendarCard = {
|
||||||
id: string;
|
id: string;
|
||||||
@ -203,6 +205,8 @@
|
|||||||
{onForceSave}
|
{onForceSave}
|
||||||
{onOpenDocument}
|
{onOpenDocument}
|
||||||
{previewOnly}
|
{previewOnly}
|
||||||
|
{onRequestEdit}
|
||||||
|
{onRequestPreview}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
</main>
|
</main>
|
||||||
@ -377,4 +381,28 @@
|
|||||||
border-color: color-mix(in srgb, #f2c266 40%, var(--border-soft) 60%);
|
border-color: color-mix(in srgb, #f2c266 40%, var(--border-soft) 60%);
|
||||||
color: #f4d690;
|
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>
|
</style>
|
||||||
|
|||||||
@ -191,4 +191,44 @@
|
|||||||
height: 40px;
|
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>
|
</style>
|
||||||
|
|||||||
@ -1818,7 +1818,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 980px) {
|
@media (max-width: 1100px) {
|
||||||
.side-panel {
|
.side-panel {
|
||||||
padding: 12px 10px;
|
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 {
|
.calendar-control-row {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -29,6 +29,8 @@
|
|||||||
export let openDocumentContent = "";
|
export let openDocumentContent = "";
|
||||||
export let onDocumentContentChange: (content: string) => void = () => {};
|
export let onDocumentContentChange: (content: string) => void = () => {};
|
||||||
export let onForceSave: () => Promise<void> | void = () => {};
|
export let onForceSave: () => Promise<void> | void = () => {};
|
||||||
|
export let onRequestEdit: () => void = () => {};
|
||||||
|
export let onRequestPreview: () => void = () => {};
|
||||||
export let onOpenDocument: (doc: {
|
export let onOpenDocument: (doc: {
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
@ -677,6 +679,33 @@
|
|||||||
|
|
||||||
<header class="editor-header">
|
<header class="editor-header">
|
||||||
<h1>{editorTitle}</h1>
|
<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>
|
</header>
|
||||||
|
|
||||||
<section class="editor-surface" class:preview-only={previewOnly}>
|
<section class="editor-surface" class:preview-only={previewOnly}>
|
||||||
@ -862,6 +891,34 @@
|
|||||||
color: var(--text-primary);
|
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 {
|
.editor-surface {
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@ -1109,6 +1166,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 980px) {
|
@media (max-width: 980px) {
|
||||||
|
.editor-header {
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-header-actions {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.editor-workspace {
|
.editor-workspace {
|
||||||
padding: 4px 8px 10px;
|
padding: 4px 8px 10px;
|
||||||
}
|
}
|
||||||
@ -1131,4 +1196,14 @@
|
|||||||
font-size: 0.89rem;
|
font-size: 0.89rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 820px) {
|
||||||
|
.editor-mode-btn span:last-child {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-mode-btn {
|
||||||
|
padding: 7px;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -88,8 +88,9 @@ async function fetchJson<T>(
|
|||||||
path: string,
|
path: string,
|
||||||
init: RequestInit = {},
|
init: RequestInit = {},
|
||||||
options: FetchJsonOptions = {},
|
options: FetchJsonOptions = {},
|
||||||
|
apiBase?: string,
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const response = await fetch(`${normalizedApiBase()}${path}`, {
|
const response = await fetch(`${apiBase ?? normalizedApiBase()}${path}`, {
|
||||||
...init,
|
...init,
|
||||||
keepalive: options.keepalive === true,
|
keepalive: options.keepalive === true,
|
||||||
headers: {
|
headers: {
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { writable, get } from "svelte/store";
|
|||||||
|
|
||||||
const _password = writable<string | null>(null);
|
const _password = writable<string | null>(null);
|
||||||
const _unlocked = writable(false);
|
const _unlocked = writable(false);
|
||||||
|
let _unlockHandler: (() => Promise<boolean>) | null = null;
|
||||||
|
|
||||||
export const vaultUnlocked = { subscribe: _unlocked.subscribe };
|
export const vaultUnlocked = { subscribe: _unlocked.subscribe };
|
||||||
|
|
||||||
@ -23,6 +24,20 @@ export function clearVaultSession(): void {
|
|||||||
_unlocked.set(false);
|
_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;
|
let _flushCallback: (() => Promise<void>) | null = null;
|
||||||
|
|
||||||
export function setFlushCallback(fn: () => Promise<void>): void {
|
export function setFlushCallback(fn: () => Promise<void>): void {
|
||||||
|
|||||||
@ -31,8 +31,10 @@
|
|||||||
type StartupView,
|
type StartupView,
|
||||||
} from "$lib/stores/settings";
|
} from "$lib/stores/settings";
|
||||||
import {
|
import {
|
||||||
|
clearVaultSession,
|
||||||
isVaultReady,
|
isVaultReady,
|
||||||
setFlushCallback,
|
setFlushCallback,
|
||||||
|
setUnlockHandler,
|
||||||
setVaultSession,
|
setVaultSession,
|
||||||
} from "$lib/stores/session";
|
} from "$lib/stores/session";
|
||||||
import { deleteTodoListByStoreId, hydrateTodos } from "$lib/stores/todos";
|
import { deleteTodoListByStoreId, hydrateTodos } from "$lib/stores/todos";
|
||||||
@ -64,6 +66,7 @@
|
|||||||
|
|
||||||
let selectedSection = "entries";
|
let selectedSection = "entries";
|
||||||
let panelOpen = true;
|
let panelOpen = true;
|
||||||
|
let isPhoneLayout = false;
|
||||||
let editMode = false;
|
let editMode = false;
|
||||||
let activeDocumentId = initialEntry?.id ?? "entries/daily-notes";
|
let activeDocumentId = initialEntry?.id ?? "entries/daily-notes";
|
||||||
let activeDocumentLabel = initialEntry?.label ?? "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) {
|
async function bootstrapFragmentsWithUnlock(maxAttempts = 3) {
|
||||||
if (fragmentBootstrapInFlight) return;
|
if (fragmentBootstrapInFlight) return;
|
||||||
|
|
||||||
@ -342,31 +370,20 @@
|
|||||||
|
|
||||||
fragmentBootstrapInFlight = true;
|
fragmentBootstrapInFlight = true;
|
||||||
try {
|
try {
|
||||||
let attempts = 0;
|
const unlocked = await ensureVaultUnlocked(maxAttempts);
|
||||||
while (attempts < maxAttempts) {
|
if (!unlocked) return;
|
||||||
try {
|
|
||||||
const password = await requestVaultPassword();
|
|
||||||
if (!password) return;
|
|
||||||
|
|
||||||
await unlockVaultWorkspace(password);
|
await hydrateEntries();
|
||||||
setVaultSession(password);
|
templateRefreshToken += 1;
|
||||||
|
const firstEntry = getDefaultEntry(get(entriesStore));
|
||||||
await hydrateEntries();
|
if (firstEntry && activeDocumentId === "entries/daily-notes") {
|
||||||
templateRefreshToken += 1;
|
await handleOpenDocument(firstEntry);
|
||||||
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 hydrateFragments();
|
||||||
|
await hydrateLists().catch(() => {});
|
||||||
|
await hydrateTodos().catch(() => {});
|
||||||
|
return;
|
||||||
} finally {
|
} finally {
|
||||||
fragmentBootstrapInFlight = false;
|
fragmentBootstrapInFlight = false;
|
||||||
}
|
}
|
||||||
@ -574,6 +591,9 @@
|
|||||||
pruneDocumentCache([resolvedDoc.id]);
|
pruneDocumentCache([resolvedDoc.id]);
|
||||||
activeDocumentId = resolvedDoc.id;
|
activeDocumentId = resolvedDoc.id;
|
||||||
activeDocumentLabel = resolvedDoc.label;
|
activeDocumentLabel = resolvedDoc.label;
|
||||||
|
if (isPhoneLayout) {
|
||||||
|
panelOpen = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleLinkedBack() {
|
async function handleLinkedBack() {
|
||||||
@ -599,6 +619,15 @@
|
|||||||
await saveCurrentDocument();
|
await saveCurrentDocument();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleEnterEditMode() {
|
||||||
|
if (!activeDocumentId) return;
|
||||||
|
editMode = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleExitEditMode() {
|
||||||
|
editMode = false;
|
||||||
|
}
|
||||||
|
|
||||||
function handleDeleteDocument(id: string) {
|
function handleDeleteDocument(id: string) {
|
||||||
const { [id]: _, ...remaining } = openDocuments;
|
const { [id]: _, ...remaining } = openDocuments;
|
||||||
openDocuments = remaining;
|
openDocuments = remaining;
|
||||||
@ -664,7 +693,17 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
|
updateResponsiveState();
|
||||||
|
const handleResize = () => {
|
||||||
|
const wasPhoneLayout = isPhoneLayout;
|
||||||
|
updateResponsiveState();
|
||||||
|
if (!wasPhoneLayout && isPhoneLayout && activeDocumentId) {
|
||||||
|
panelOpen = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener("resize", handleResize);
|
||||||
setFlushCallback(saveCurrentDocument);
|
setFlushCallback(saveCurrentDocument);
|
||||||
|
setUnlockHandler(() => ensureVaultUnlocked());
|
||||||
void (async () => {
|
void (async () => {
|
||||||
await hydrateUiSettings();
|
await hydrateUiSettings();
|
||||||
const startupSection = resolveStartupSection(
|
const startupSection = resolveStartupSection(
|
||||||
@ -675,7 +714,15 @@
|
|||||||
);
|
);
|
||||||
applyStartupSection(sectionFromQuery ?? startupSection);
|
applyStartupSection(sectionFromQuery ?? startupSection);
|
||||||
await bootstrapFragmentsWithUnlock();
|
await bootstrapFragmentsWithUnlock();
|
||||||
|
if (isPhoneLayout && activeDocumentId) {
|
||||||
|
panelOpen = false;
|
||||||
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("resize", handleResize);
|
||||||
|
setUnlockHandler(null);
|
||||||
|
};
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@ -709,6 +756,8 @@
|
|||||||
calendarError={calendarPanelState.error}
|
calendarError={calendarPanelState.error}
|
||||||
previewOnly={!editMode}
|
previewOnly={!editMode}
|
||||||
onForceSave={handleForceSave}
|
onForceSave={handleForceSave}
|
||||||
|
onRequestEdit={handleEnterEditMode}
|
||||||
|
onRequestPreview={handleExitEditMode}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -38,6 +38,14 @@
|
|||||||
let sidecarRootIsCustom = false;
|
let sidecarRootIsCustom = false;
|
||||||
let sidecarRootError = "";
|
let sidecarRootError = "";
|
||||||
let sidecarBrowseBusy = false;
|
let sidecarBrowseBusy = false;
|
||||||
|
let gatewayRootBusy = false;
|
||||||
|
let gatewayRootError = "";
|
||||||
|
let gatewayRootStatus: {
|
||||||
|
authoritativeRoot: string;
|
||||||
|
gatewayConfigPath: string;
|
||||||
|
configuredRoot?: string | null;
|
||||||
|
needsAdoption: boolean;
|
||||||
|
} | null = null;
|
||||||
let returnSection = "entries";
|
let returnSection = "entries";
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
@ -61,6 +69,8 @@
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
sidecarRootError = String(e);
|
sidecarRootError = String(e);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await refreshGatewayRootStatus();
|
||||||
});
|
});
|
||||||
|
|
||||||
async function saveSidecarRoot() {
|
async function saveSidecarRoot() {
|
||||||
@ -71,6 +81,7 @@
|
|||||||
});
|
});
|
||||||
sidecarRoot = result.root;
|
sidecarRoot = result.root;
|
||||||
sidecarRootIsCustom = result.isCustom;
|
sidecarRootIsCustom = result.isCustom;
|
||||||
|
await refreshGatewayRootStatus();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
sidecarRootError = String(e);
|
sidecarRootError = String(e);
|
||||||
}
|
}
|
||||||
@ -82,6 +93,7 @@
|
|||||||
const result: any = await invoke("set_sidecar_root", { path: "" });
|
const result: any = await invoke("set_sidecar_root", { path: "" });
|
||||||
sidecarRoot = result.root;
|
sidecarRoot = result.root;
|
||||||
sidecarRootIsCustom = result.isCustom;
|
sidecarRootIsCustom = result.isCustom;
|
||||||
|
await refreshGatewayRootStatus();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
sidecarRootError = String(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: {
|
function showModal(options: {
|
||||||
action: "logout-confirm" | "logout-info";
|
action: "logout-confirm" | "logout-info";
|
||||||
title: string;
|
title: string;
|
||||||
@ -477,6 +521,87 @@
|
|||||||
<p class="error-text">{sidecarRootError}</p>
|
<p class="error-text">{sidecarRootError}</p>
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
</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>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -92,25 +92,70 @@ select {
|
|||||||
min-height: 100dvh;
|
min-height: 100dvh;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 72px 300px minmax(0, 1fr);
|
grid-template-columns: 72px 300px minmax(0, 1fr);
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-shell.panel-closed {
|
.app-shell.panel-closed {
|
||||||
grid-template-columns: 72px minmax(0, 1fr);
|
grid-template-columns: 72px minmax(0, 1fr);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 980px) {
|
@media (max-width: 1100px) {
|
||||||
.app-shell {
|
.app-shell {
|
||||||
grid-template-columns: 64px minmax(0, 1fr);
|
grid-template-columns: 64px 280px minmax(0, 1fr);
|
||||||
grid-template-rows: 280px minmax(0, 1fr);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-shell:not(.panel-closed) > .side-panel {
|
.app-shell.panel-closed {
|
||||||
grid-column: 2;
|
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;
|
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-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;
|
return _connection;
|
||||||
|
|
||||||
_connection = _database.OpenEncryptedConnection(_password);
|
_connection = _database.OpenEncryptedConnection(_password);
|
||||||
_database.EnsureSchema(_connection);
|
_database.EnsureSchemaReady(_connection);
|
||||||
return _connection;
|
return _connection;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,6 +11,7 @@ public interface IJournalDatabaseService
|
|||||||
IReadOnlyDictionary<string, string> GetSchemaStatements();
|
IReadOnlyDictionary<string, string> GetSchemaStatements();
|
||||||
SqliteConnection OpenEncryptedConnection(string password);
|
SqliteConnection OpenEncryptedConnection(string password);
|
||||||
void EnsureSchema(SqliteConnection connection);
|
void EnsureSchema(SqliteConnection connection);
|
||||||
|
void EnsureSchemaReady(SqliteConnection connection);
|
||||||
JournalDatabaseStatus GetStatus(string password);
|
JournalDatabaseStatus GetStatus(string password);
|
||||||
JournalDatabaseHydrationResult HydrateWorkspace(string password);
|
JournalDatabaseHydrationResult HydrateWorkspace(string password);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -170,7 +170,7 @@ public sealed class JournalDatabaseService(IJournalConfigService config) : IJour
|
|||||||
public JournalDatabaseHydrationResult HydrateWorkspace(string password)
|
public JournalDatabaseHydrationResult HydrateWorkspace(string password)
|
||||||
{
|
{
|
||||||
using var connection = OpenEncryptedConnection(password);
|
using var connection = OpenEncryptedConnection(password);
|
||||||
EnsureSchema(connection);
|
EnsureSchemaReady(connection);
|
||||||
var runtimeReady = HasRequiredTables(connection);
|
var runtimeReady = HasRequiredTables(connection);
|
||||||
|
|
||||||
var entryDocumentsProcessed = CountEntryDocuments(connection);
|
var entryDocumentsProcessed = CountEntryDocuments(connection);
|
||||||
@ -206,7 +206,8 @@ public sealed class JournalDatabaseService(IJournalConfigService config) : IJour
|
|||||||
|
|
||||||
EnsureSqliteInitialized();
|
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();
|
connection.Open();
|
||||||
|
|
||||||
using var keyCmd = connection.CreateCommand();
|
using var keyCmd = connection.CreateCommand();
|
||||||
@ -217,6 +218,10 @@ public sealed class JournalDatabaseService(IJournalConfigService config) : IJour
|
|||||||
verifyCmd.CommandText = "SELECT count(*) FROM sqlite_master;";
|
verifyCmd.CommandText = "SELECT count(*) FROM sqlite_master;";
|
||||||
_ = verifyCmd.ExecuteScalar();
|
_ = verifyCmd.ExecuteScalar();
|
||||||
|
|
||||||
|
using var busyCmd = connection.CreateCommand();
|
||||||
|
busyCmd.CommandText = "PRAGMA busy_timeout = 5000;";
|
||||||
|
busyCmd.ExecuteNonQuery();
|
||||||
|
|
||||||
return connection;
|
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)
|
private static bool HasRequiredTables(SqliteConnection connection)
|
||||||
{
|
{
|
||||||
var existing = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
var existing = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
@ -250,7 +263,7 @@ public sealed class JournalDatabaseService(IJournalConfigService config) : IJour
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
using var connection = OpenEncryptedConnection(password);
|
using var connection = OpenEncryptedConnection(password);
|
||||||
EnsureSchema(connection);
|
EnsureSchemaReady(connection);
|
||||||
var ready = HasRequiredTables(connection);
|
var ready = HasRequiredTables(connection);
|
||||||
return ready
|
return ready
|
||||||
? (true, "SQLCipher runtime is available and schema tables are present.")
|
? (true, "SQLCipher runtime is available and schema tables are present.")
|
||||||
|
|||||||
@ -97,9 +97,15 @@ public class VaultStorageService(IVaultCryptoService crypto, IJournalDatabaseSer
|
|||||||
if (string.IsNullOrWhiteSpace(dbFileName))
|
if (string.IsNullOrWhiteSpace(dbFileName))
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
|
var targetPath = Path.Combine(dataDirectory, dbFileName);
|
||||||
|
if (File.Exists(targetPath))
|
||||||
|
{
|
||||||
|
anyRestored = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
var encrypted = File.ReadAllBytes(vaultFile);
|
var encrypted = File.ReadAllBytes(vaultFile);
|
||||||
var dbBytes = _crypto.DecryptData(encrypted, password);
|
var dbBytes = _crypto.DecryptData(encrypted, password);
|
||||||
var targetPath = Path.Combine(dataDirectory, dbFileName);
|
|
||||||
File.WriteAllBytes(targetPath, dbBytes);
|
File.WriteAllBytes(targetPath, dbBytes);
|
||||||
anyRestored = true;
|
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 System.Text.Json.Serialization;
|
||||||
using Journal.AI;
|
using Journal.AI;
|
||||||
using Journal.Core;
|
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;
|
using Microsoft.Extensions.FileProviders;
|
||||||
|
|
||||||
var gatewayJsonOptions = new JsonSerializerOptions
|
if (TryHandlePasswordHashCommand(args))
|
||||||
|
return;
|
||||||
|
|
||||||
|
var builder = WebApplication.CreateBuilder(new WebApplicationOptions
|
||||||
{
|
{
|
||||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
Args = args,
|
||||||
PropertyNameCaseInsensitive = true,
|
ContentRootPath = AppContext.BaseDirectory
|
||||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
});
|
||||||
};
|
|
||||||
|
// 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);
|
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.AddFragmentServices();
|
||||||
builder.Services.AddLlamaSharpServices();
|
builder.Services.AddLlamaSharpServices();
|
||||||
builder.Services.AddSingleton<Entry>();
|
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.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 =>
|
builder.Services.AddCors(options =>
|
||||||
{
|
{
|
||||||
options.AddPolicy("GatewayCors", policy =>
|
options.AddPolicy("GatewayCors", policy =>
|
||||||
{
|
{
|
||||||
policy.AllowAnyOrigin()
|
var allowedOrigins = securitySettings.GetSection("AllowedOrigins").Get<string[]>() ?? Array.Empty<string>();
|
||||||
.AllowAnyHeader()
|
if (allowedOrigins.Length > 0)
|
||||||
.AllowAnyMethod();
|
{
|
||||||
|
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.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
|
||||||
options.SerializerOptions.PropertyNameCaseInsensitive = true;
|
options.SerializerOptions.PropertyNameCaseInsensitive = true;
|
||||||
|
options.SerializerOptions.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull;
|
||||||
});
|
});
|
||||||
|
|
||||||
var app = builder.Build();
|
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.UseCors("GatewayCors");
|
||||||
|
app.UseAuthentication();
|
||||||
|
app.UseAuthorization();
|
||||||
|
|
||||||
app.MapGet("/api/health", () => Results.Ok(new
|
app.Use(async (context, next) =>
|
||||||
{
|
{
|
||||||
ok = true,
|
var path = context.Request.Path;
|
||||||
service = "Journal.WebGateway"
|
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
|
if (isProtectedUiPath && context.User.Identity?.IsAuthenticated != true)
|
||||||
{
|
|
||||||
distPath = webUiState.DistPath,
|
|
||||||
exists = webUiState.Exists
|
|
||||||
}));
|
|
||||||
|
|
||||||
app.MapPost("/api/command", async (CommandEnvelope? command, Entry entry) =>
|
|
||||||
{
|
|
||||||
if (command is null || string.IsNullOrWhiteSpace(command.Action))
|
|
||||||
{
|
{
|
||||||
return Results.Content(ErrorResponse("Missing action"), "application/json");
|
await context.ChallengeAsync(CookieAuthenticationDefaults.AuthenticationScheme);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var inputJson = JsonSerializer.Serialize(command, gatewayJsonOptions);
|
if (isAnonymousApiPath)
|
||||||
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
|
|
||||||
{
|
{
|
||||||
root,
|
await next();
|
||||||
isCustom
|
return;
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
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.");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
rootState.Set(path);
|
await next();
|
||||||
var (root, isCustom) = rootState.Get();
|
|
||||||
Environment.SetEnvironmentVariable("JOURNAL_PROJECT_ROOT", root);
|
|
||||||
return Results.Ok(new
|
|
||||||
{
|
|
||||||
root,
|
|
||||||
isCustom
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
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);
|
await next();
|
||||||
var indexPath = Path.Combine(webDistPath, "index.html");
|
|
||||||
|
|
||||||
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,
|
context.Response.Redirect("/gateway/login");
|
||||||
RequestPath = ""
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.UseStaticFiles(new StaticFileOptions
|
app.Use(async (context, next) =>
|
||||||
{
|
|
||||||
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.MapGet("/", () => Results.Ok(new
|
if (securitySettings.GetValue<bool>("EnableAuditLogging"))
|
||||||
{
|
{
|
||||||
name = "Journal.WebGateway",
|
var logger = context.RequestServices.GetRequiredService<ILogger<Program>>();
|
||||||
status = "ok",
|
var ip = context.Connection.RemoteIpAddress?.ToString() ?? "unknown";
|
||||||
uiAvailable = false,
|
var userAgent = context.Request.Headers["User-Agent"].ToString();
|
||||||
message = "No built web UI found. Build Journal.App with ./scripts/publish-app.ps1 -Target web.",
|
var user = context.User.Identity?.Name ?? "Anonymous";
|
||||||
expectedDist = webDistPath
|
|
||||||
}));
|
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();
|
app.Run();
|
||||||
|
|
||||||
string ResolveRepoRoot()
|
static bool TryHandlePasswordHashCommand(string[] args)
|
||||||
{
|
{
|
||||||
var fromEnv = Environment.GetEnvironmentVariable("JOURNAL_PROJECT_ROOT");
|
if (args.Length == 0 || !string.Equals(args[0], "hash-password", StringComparison.OrdinalIgnoreCase))
|
||||||
if (!string.IsNullOrWhiteSpace(fromEnv) && Directory.Exists(fromEnv))
|
return false;
|
||||||
{
|
|
||||||
return Path.GetFullPath(fromEnv);
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (var start in new[] { Directory.GetCurrentDirectory(), AppContext.BaseDirectory })
|
string password;
|
||||||
|
if (args.Length > 1)
|
||||||
{
|
{
|
||||||
var resolved = FindRepoRoot(start);
|
password = args[1];
|
||||||
if (resolved is not null)
|
}
|
||||||
|
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")) ||
|
var key = Console.ReadKey(intercept: true);
|
||||||
Directory.Exists(Path.Combine(cursor, "Journal.Sidecar")) ||
|
if (key.Key == ConsoleKey.Enter)
|
||||||
Directory.Exists(Path.Combine(cursor, "Journal.Core")))
|
|
||||||
{
|
{
|
||||||
return cursor;
|
Console.WriteLine();
|
||||||
|
return new string([.. buffer]);
|
||||||
}
|
}
|
||||||
|
|
||||||
var parent = Directory.GetParent(cursor);
|
if (key.Key == ConsoleKey.Backspace)
|
||||||
if (parent is null || string.Equals(parent.FullName, cursor, StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
{
|
||||||
return null;
|
if (buffer.Count > 0)
|
||||||
|
buffer.RemoveAt(buffer.Count - 1);
|
||||||
|
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
cursor = parent.FullName;
|
if (!char.IsControl(key.KeyChar))
|
||||||
}
|
buffer.Add(key.KeyChar);
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
## 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 |
|
| 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
|
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)
|
### 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
|
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:
|
Run the gateway:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
dotnet run --project Journal.WebGateway
|
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:
|
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.
|
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
|
### Endpoints
|
||||||
|
|
||||||
| Method | Path | Description |
|
| 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` |
|
| `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/web/status` | Reports web dist path and whether UI is available |
|
||||||
| `GET` | `/api/sidecar/root` | Returns current project root (auto-detected or custom) |
|
| `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) |
|
| `GET` | `/*` | Serves built SvelteKit UI from `wwwroot` (SPA fallback) |
|
||||||
|
|
||||||
### Web UI Resolution
|
### 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
|
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)
|
## 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 |
|
| `sidecar_command` | Forward a `CommandEnvelope` to `Journal.Sidecar` and return parsed JSON |
|
||||||
| `get_sidecar_root` | Get the current resolved sidecar root path |
|
| `get_sidecar_root` | Get the current resolved sidecar root path |
|
||||||
| `set_sidecar_root` | Override sidecar root path (saves to `settings.json`, restarts sidecar) |
|
| `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` |
|
| `get_ui_settings` | Load tag/fragment-type settings from `settings.json` |
|
||||||
| `set_ui_settings` | Persist tag/fragment-type settings |
|
| `set_ui_settings` | Persist tag/fragment-type settings |
|
||||||
| `shutdown` | Stop the sidecar and exit the app |
|
| `shutdown` | Stop the sidecar and exit the app |
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user