Compare commits
23
Commits
v0.5.0-rc2
..
v0.6.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f49698bb5c | ||
|
|
7069ca9db7 | ||
|
|
faf8fc0eda | ||
|
|
bf29ccab56 | ||
|
|
667503c8f6 | ||
|
|
83218ba602 | ||
|
|
58c0af6510 | ||
|
|
f4947489af | ||
|
|
2761863c14 | ||
|
|
a32c0fd7ca | ||
|
|
b03e0ea727 | ||
|
|
115f6e9a23 | ||
|
|
0295b4bd7c | ||
|
|
6a3c744667 | ||
|
|
19e48270ed | ||
|
|
133c707f27 | ||
|
|
496186e2af | ||
|
|
fe7e8efeef | ||
|
|
5193870c97 | ||
|
|
7fdc352547 | ||
|
|
49acf76cf6 | ||
|
|
c7e851000f | ||
|
|
c5f33e9713 |
No files matched your search
+1
-1
@@ -1,4 +1,4 @@
|
||||
/target
|
||||
/target/
|
||||
data/
|
||||
*.db
|
||||
*.db-wal
|
||||
|
||||
Generated
+2
-554
@@ -29,24 +29,6 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aligned"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ee4508988c62edf04abd8d92897fca0c2995d907ce1dfeaf369dac3716a40685"
|
||||
dependencies = [
|
||||
"as-slice",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aligned-vec"
|
||||
version = "0.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc890384c8602f339876ded803c97ad529f3842aba97f6392b3dba0dd171769b"
|
||||
dependencies = [
|
||||
"equator",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "android_system_properties"
|
||||
version = "0.1.5"
|
||||
@@ -121,17 +103,6 @@ dependencies = [
|
||||
"derive_arbitrary",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "arg_enum_proc_macro"
|
||||
version = "0.3.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "argon2"
|
||||
version = "0.5.3"
|
||||
@@ -144,21 +115,6 @@ dependencies = [
|
||||
"password-hash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "arrayvec"
|
||||
version = "0.7.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
|
||||
|
||||
[[package]]
|
||||
name = "as-slice"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "516b6b4f0e40d50dcda9365d53964ec74560ad4284da2e7fc97122cd83174516"
|
||||
dependencies = [
|
||||
"stable_deref_trait",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "askama"
|
||||
version = "0.12.1"
|
||||
@@ -200,7 +156,7 @@ version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "acb1161c6b64d1c3d83108213c2a2533a342ac225aabd0bda218278c2ddb00c0"
|
||||
dependencies = [
|
||||
"nom 7.1.3",
|
||||
"nom",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -226,49 +182,6 @@ version = "1.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
||||
|
||||
[[package]]
|
||||
name = "av-scenechange"
|
||||
version = "0.14.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0f321d77c20e19b92c39e7471cf986812cbb46659d2af674adc4331ef3f18394"
|
||||
dependencies = [
|
||||
"aligned",
|
||||
"anyhow",
|
||||
"arg_enum_proc_macro",
|
||||
"arrayvec",
|
||||
"log",
|
||||
"num-rational",
|
||||
"num-traits",
|
||||
"pastey",
|
||||
"rayon",
|
||||
"thiserror",
|
||||
"v_frame",
|
||||
"y4m",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "av1-grain"
|
||||
version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8cfddb07216410377231960af4fcab838eaa12e013417781b78bd95ee22077f8"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arrayvec",
|
||||
"log",
|
||||
"nom 8.0.0",
|
||||
"num-rational",
|
||||
"v_frame",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "avif-serialize"
|
||||
version = "0.8.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7178fe5f7d460b13895ebb9dcb28a3a6216d2df2574a0806cb51b555d297f38"
|
||||
dependencies = [
|
||||
"arrayvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "axum"
|
||||
version = "0.7.9"
|
||||
@@ -382,27 +295,12 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bit_field"
|
||||
version = "0.10.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
|
||||
|
||||
[[package]]
|
||||
name = "bitstream-io"
|
||||
version = "4.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7eff00be299a18769011411c9def0d827e8f2d7bf0c3dbf53633147a8867fd1f"
|
||||
dependencies = [
|
||||
"no_std_io2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "blake2"
|
||||
version = "0.10.6"
|
||||
@@ -421,12 +319,6 @@ dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "built"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c0e531d93d39c34eef561e929e8a7f86d77a5af08aac4f6d6e39976c51858e9"
|
||||
|
||||
[[package]]
|
||||
name = "bumpalo"
|
||||
version = "3.20.3"
|
||||
@@ -453,7 +345,7 @@ checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
|
||||
|
||||
[[package]]
|
||||
name = "bzod"
|
||||
version = "0.5.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"argon2",
|
||||
"askama",
|
||||
@@ -562,12 +454,6 @@ version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
|
||||
|
||||
[[package]]
|
||||
name = "color_quant"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
|
||||
|
||||
[[package]]
|
||||
name = "colorchoice"
|
||||
version = "1.0.5"
|
||||
@@ -627,37 +513,12 @@ dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-deque"
|
||||
version = "0.8.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51"
|
||||
dependencies = [
|
||||
"crossbeam-epoch",
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-epoch"
|
||||
version = "0.9.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
|
||||
dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-utils"
|
||||
version = "0.8.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
|
||||
|
||||
[[package]]
|
||||
name = "crunchy"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
|
||||
|
||||
[[package]]
|
||||
name = "crypto-common"
|
||||
version = "0.1.7"
|
||||
@@ -725,12 +586,6 @@ version = "0.15.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
|
||||
|
||||
[[package]]
|
||||
name = "either"
|
||||
version = "1.16.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e"
|
||||
|
||||
[[package]]
|
||||
name = "encoding_rs"
|
||||
version = "0.8.35"
|
||||
@@ -740,26 +595,6 @@ dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "equator"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4711b213838dfee0117e3be6ac926007d7f433d7bbe33595975d4190cb07e6fc"
|
||||
dependencies = [
|
||||
"equator-macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "equator-macro"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.2"
|
||||
@@ -776,21 +611,6 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "exr"
|
||||
version = "1.74.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4300e043a56aa2cb633c01af81ca8f699a321879a7854d3896a0ba89056363be"
|
||||
dependencies = [
|
||||
"bit_field",
|
||||
"half",
|
||||
"lebe",
|
||||
"miniz_oxide",
|
||||
"rayon-core",
|
||||
"smallvec",
|
||||
"zune-inflate",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fallible-iterator"
|
||||
version = "0.3.0"
|
||||
@@ -809,12 +629,6 @@ version = "2.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
|
||||
|
||||
[[package]]
|
||||
name = "fax"
|
||||
version = "0.2.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a"
|
||||
|
||||
[[package]]
|
||||
name = "fdeflate"
|
||||
version = "0.3.7"
|
||||
@@ -960,27 +774,6 @@ dependencies = [
|
||||
"wasip3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gif"
|
||||
version = "0.14.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159"
|
||||
dependencies = [
|
||||
"color_quant",
|
||||
"weezl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "half"
|
||||
version = "2.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"crunchy",
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.14.5"
|
||||
@@ -1281,38 +1074,11 @@ checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"byteorder-lite",
|
||||
"color_quant",
|
||||
"exr",
|
||||
"gif",
|
||||
"image-webp",
|
||||
"moxcms",
|
||||
"num-traits",
|
||||
"png",
|
||||
"qoi",
|
||||
"ravif",
|
||||
"rayon",
|
||||
"rgb",
|
||||
"tiff",
|
||||
"zune-core",
|
||||
"zune-jpeg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "image-webp"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3"
|
||||
dependencies = [
|
||||
"byteorder-lite",
|
||||
"quick-error",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "imgref"
|
||||
version = "1.12.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "89194689a993ab15268672e99e7b0e19da2da3268ac682e8f02d29d4d1434cd7"
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.14.0"
|
||||
@@ -1325,17 +1091,6 @@ dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "interpolate_name"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ipnet"
|
||||
version = "2.12.0"
|
||||
@@ -1348,15 +1103,6 @@ version = "1.70.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
|
||||
|
||||
[[package]]
|
||||
name = "itertools"
|
||||
version = "0.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285"
|
||||
dependencies = [
|
||||
"either",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
@@ -1396,28 +1142,12 @@ version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
|
||||
|
||||
[[package]]
|
||||
name = "lebe"
|
||||
version = "0.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8"
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.186"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
|
||||
|
||||
[[package]]
|
||||
name = "libfuzzer-sys"
|
||||
version = "0.4.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a9fd2f41a1cba099f79a0b6b6c35656cf7c03351a7bae8ff0f28f25270f929d2"
|
||||
dependencies = [
|
||||
"arbitrary",
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libm"
|
||||
version = "0.2.16"
|
||||
@@ -1468,15 +1198,6 @@ version = "0.4.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a"
|
||||
|
||||
[[package]]
|
||||
name = "loop9"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0fae87c125b03c1d2c0150c90365d7d6bcc53fb73a9acaef207d2d065860f062"
|
||||
dependencies = [
|
||||
"imgref",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lru-slab"
|
||||
version = "0.1.2"
|
||||
@@ -1498,16 +1219,6 @@ version = "0.7.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94"
|
||||
|
||||
[[package]]
|
||||
name = "maybe-rayon"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8ea1f30cedd69f0a2954655f7188c6a834246d2bcf1e315e2ac40c4b24dc9519"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"rayon",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.1"
|
||||
@@ -1584,21 +1295,6 @@ dependencies = [
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "new_debug_unreachable"
|
||||
version = "1.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
|
||||
|
||||
[[package]]
|
||||
name = "no_std_io2"
|
||||
version = "0.9.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "418abd1b6d34fbf6cae440dc874771b0525a604428704c76e48b29a5e67b8003"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nom"
|
||||
version = "7.1.3"
|
||||
@@ -1609,21 +1305,6 @@ dependencies = [
|
||||
"minimal-lexical",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nom"
|
||||
version = "8.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "noop_proc_macro"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8"
|
||||
|
||||
[[package]]
|
||||
name = "nu-ansi-term"
|
||||
version = "0.50.3"
|
||||
@@ -1633,53 +1314,12 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-bigint"
|
||||
version = "0.4.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9"
|
||||
dependencies = [
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-conv"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
|
||||
|
||||
[[package]]
|
||||
name = "num-derive"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-integer"
|
||||
version = "0.1.46"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-rational"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824"
|
||||
dependencies = [
|
||||
"num-bigint",
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-traits"
|
||||
version = "0.2.19"
|
||||
@@ -1735,18 +1375,6 @@ dependencies = [
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "paste"
|
||||
version = "1.0.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
|
||||
|
||||
[[package]]
|
||||
name = "pastey"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec"
|
||||
|
||||
[[package]]
|
||||
name = "percent-encoding"
|
||||
version = "2.3.2"
|
||||
@@ -1821,25 +1449,6 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "profiling"
|
||||
version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5"
|
||||
dependencies = [
|
||||
"profiling-procmacros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "profiling-procmacros"
|
||||
version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4488a4a36b9a4ba6b9334a32a39971f77c1436ec82c38707bce707699cc3bbcb"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "psl-types"
|
||||
version = "2.0.11"
|
||||
@@ -1862,15 +1471,6 @@ version = "0.1.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f"
|
||||
|
||||
[[package]]
|
||||
name = "qoi"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "qrcode"
|
||||
version = "0.14.1"
|
||||
@@ -1880,12 +1480,6 @@ dependencies = [
|
||||
"image",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quick-error"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
|
||||
|
||||
[[package]]
|
||||
name = "quinn"
|
||||
version = "0.11.9"
|
||||
@@ -2021,76 +1615,6 @@ dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rav1e"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "43b6dd56e85d9483277cde964fd1bdb0428de4fec5ebba7540995639a21cb32b"
|
||||
dependencies = [
|
||||
"aligned-vec",
|
||||
"arbitrary",
|
||||
"arg_enum_proc_macro",
|
||||
"arrayvec",
|
||||
"av-scenechange",
|
||||
"av1-grain",
|
||||
"bitstream-io",
|
||||
"built",
|
||||
"cfg-if",
|
||||
"interpolate_name",
|
||||
"itertools",
|
||||
"libc",
|
||||
"libfuzzer-sys",
|
||||
"log",
|
||||
"maybe-rayon",
|
||||
"new_debug_unreachable",
|
||||
"noop_proc_macro",
|
||||
"num-derive",
|
||||
"num-traits",
|
||||
"paste",
|
||||
"profiling",
|
||||
"rand 0.9.4",
|
||||
"rand_chacha 0.9.0",
|
||||
"simd_helpers",
|
||||
"thiserror",
|
||||
"v_frame",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ravif"
|
||||
version = "0.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e52310197d971b0f5be7fe6b57530dcd27beb35c1b013f29d66c1ad73fbbcc45"
|
||||
dependencies = [
|
||||
"avif-serialize",
|
||||
"imgref",
|
||||
"loop9",
|
||||
"quick-error",
|
||||
"rav1e",
|
||||
"rayon",
|
||||
"rgb",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rayon"
|
||||
version = "1.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d"
|
||||
dependencies = [
|
||||
"either",
|
||||
"rayon-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rayon-core"
|
||||
version = "1.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91"
|
||||
dependencies = [
|
||||
"crossbeam-deque",
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redox_syscall"
|
||||
version = "0.5.18"
|
||||
@@ -2157,12 +1681,6 @@ dependencies = [
|
||||
"webpki-roots",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rgb"
|
||||
version = "0.8.53"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4"
|
||||
|
||||
[[package]]
|
||||
name = "ring"
|
||||
version = "0.17.14"
|
||||
@@ -2386,15 +1904,6 @@ version = "0.3.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
|
||||
|
||||
[[package]]
|
||||
name = "simd_helpers"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "95890f873bec569a0362c235787f3aca6e1e887302ba4840839bcc6459c42da6"
|
||||
dependencies = [
|
||||
"quote",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "slab"
|
||||
version = "0.4.12"
|
||||
@@ -2512,20 +2021,6 @@ dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tiff"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52"
|
||||
dependencies = [
|
||||
"fax",
|
||||
"flate2",
|
||||
"half",
|
||||
"quick-error",
|
||||
"weezl",
|
||||
"zune-jpeg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "time"
|
||||
version = "0.3.47"
|
||||
@@ -2841,17 +2336,6 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "v_frame"
|
||||
version = "0.3.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "666b7727c8875d6ab5db9533418d7c764233ac9c0cff1d469aec8fa127597be2"
|
||||
dependencies = [
|
||||
"aligned-vec",
|
||||
"num-traits",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "valuable"
|
||||
version = "0.1.1"
|
||||
@@ -3021,12 +2505,6 @@ dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "weezl"
|
||||
version = "0.1.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88"
|
||||
|
||||
[[package]]
|
||||
name = "windows-core"
|
||||
version = "0.62.2"
|
||||
@@ -3361,12 +2839,6 @@ dependencies = [
|
||||
"rustix",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "y4m"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448"
|
||||
|
||||
[[package]]
|
||||
name = "yoke"
|
||||
version = "0.8.3"
|
||||
@@ -3532,27 +3004,3 @@ dependencies = [
|
||||
"cc",
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zune-core"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9"
|
||||
|
||||
[[package]]
|
||||
name = "zune-inflate"
|
||||
version = "0.2.54"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02"
|
||||
dependencies = [
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zune-jpeg"
|
||||
version = "0.5.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296"
|
||||
dependencies = [
|
||||
"zune-core",
|
||||
]
|
||||
+27
-2
@@ -1,8 +1,27 @@
|
||||
[package]
|
||||
name = "bzod"
|
||||
version = "0.5.0"
|
||||
description = "Self-hosted multi-user URL management, landing page and QR analytics platform"
|
||||
version = "0.6.0"
|
||||
edition = "2021"
|
||||
license = "MIT OR Apache-2.0"
|
||||
repository = "https://github.com/thakares/nx9-url-shortener"
|
||||
homepage = "https://bzo.in"
|
||||
documentation = "https://github.com/thakares/nx9-url-shortener"
|
||||
readme = "README.md"
|
||||
authors = ["Sunil P. Thakare"]
|
||||
|
||||
keywords = [
|
||||
"url-shortener",
|
||||
"landing-pages",
|
||||
"analytics",
|
||||
"qr-code",
|
||||
"self-hosted"
|
||||
]
|
||||
|
||||
categories = [
|
||||
"web-programming",
|
||||
"command-line-utilities"
|
||||
]
|
||||
|
||||
[dependencies]
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
@@ -28,7 +47,7 @@ hex = "0.4"
|
||||
time = "0.3"
|
||||
toml = "0.8"
|
||||
qrcode = "0.14"
|
||||
image = "0.25"
|
||||
image = { version = "0.25", default-features = false, features = ["png"] }
|
||||
zip = { version = "2.1", default-features = false, features = ["deflate"] }
|
||||
futures-util = "0.3"
|
||||
zstd = "0.13"
|
||||
@@ -36,3 +55,9 @@ zstd = "0.13"
|
||||
[lints.clippy]
|
||||
let_unit_value = "allow"
|
||||
useless_vec = "allow"
|
||||
|
||||
[profile.release]
|
||||
lto = true
|
||||
codegen-units = 1
|
||||
strip = true
|
||||
panic = "abort"
|
||||
@@ -4,6 +4,8 @@
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
BZOD_VERSION="0.6.0"
|
||||
|
||||
SERVICE_USER="bzod"
|
||||
INSTALL_PATH="/usr/local/bin/bzod"
|
||||
CONFIG_DIR="/etc/bzod"
|
||||
@@ -48,7 +50,7 @@ case $ARCH in
|
||||
esac
|
||||
|
||||
REPO="thakares/nx9-url-shortener"
|
||||
RELEASE_URL="https://github.com/${REPO}/releases/latest/download/${BINARY_NAME}"
|
||||
RELEASE_URL="https://github.com/${REPO}/releases/download/v${BZOD_VERSION}/${BINARY_NAME}"
|
||||
|
||||
TMP_BINARY=$(mktemp)
|
||||
|
||||
@@ -93,13 +95,24 @@ if [ ! -x "${INSTALL_PATH}" ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
"${INSTALL_PATH}" --version >/dev/null && echo -e "${GREEN}✓ Binary verified${NC}" || {
|
||||
"${INSTALL_PATH}" --version >/dev/null && echo -e "${GREEN}✓ Binary verified (--version)${NC}" || {
|
||||
echo -e "${RED}Binary verification failed${NC}"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Show installed version
|
||||
# Verify -V also works
|
||||
"${INSTALL_PATH}" -V >/dev/null && echo -e "${GREEN}✓ Binary verified (-V)${NC}" || {
|
||||
echo -e "${RED}Binary -V verification failed${NC}"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Show installed version and verify it matches requested version
|
||||
VERSION=$("${INSTALL_PATH}" --version 2>/dev/null | head -n1 || echo "unknown")
|
||||
EXPECTED_VERSION="bzod ${BZOD_VERSION}"
|
||||
if [ "${VERSION}" != "${EXPECTED_VERSION}" ]; then
|
||||
echo -e "${RED}Version mismatch: expected '${EXPECTED_VERSION}', got '${VERSION}'${NC}"
|
||||
exit 1
|
||||
fi
|
||||
echo -e "${GREEN}✓ Installed ${VERSION} (${ARCH})${NC}"
|
||||
|
||||
# 3. Create System User
|
||||
@@ -175,11 +188,23 @@ systemctl daemon-reload
|
||||
# 7. Initialize & Start
|
||||
echo -e "\n${BLUE}[7/8] Initializing and starting service...${NC}"
|
||||
|
||||
if [ ! -f "${DATA_DIR}/content.db" ] && [ ! -f "${DATA_DIR}/admin.db" ] && [ ! -f "${DATA_DIR}/analytics.db" ]; then
|
||||
runuser -u "${SERVICE_USER}" -- "${INSTALL_PATH}" init-db --data-dir "${DATA_DIR}"
|
||||
echo -e "${GREEN}✓ Databases initialized${NC}"
|
||||
else
|
||||
# Database creation and migration is handled automatically by 'bzod serve'
|
||||
if [ -f "${DATA_DIR}/admin/admin.db" ] || [ -f "${DATA_DIR}/admin.db" ]; then
|
||||
echo -e "${GREEN}✓ Existing database detected (upgrade mode)${NC}"
|
||||
|
||||
# Pre-upgrade: stop service and backup databases
|
||||
if systemctl is-active --quiet bzod 2>/dev/null; then
|
||||
echo -e "${BLUE} Stopping BZOD for safe database backup...${NC}"
|
||||
systemctl stop bzod
|
||||
fi
|
||||
|
||||
BACKUP_DIR="/var/lib/bzod/pre-upgrade-backup-v${BZOD_VERSION}"
|
||||
mkdir -p "${BACKUP_DIR}"
|
||||
cp -a "${DATA_DIR}" "${BACKUP_DIR}/data" 2>/dev/null || true
|
||||
cp "${ENV_FILE}" "${BACKUP_DIR}/bzod.env" 2>/dev/null || true
|
||||
echo -e "${GREEN} ✓ Pre-upgrade backup created at ${BACKUP_DIR}${NC}"
|
||||
else
|
||||
echo -e "${GREEN}✓ Fresh installation (databases will be created on first start)${NC}"
|
||||
fi
|
||||
|
||||
systemctl enable --now bzod
|
||||
|
||||
+4
-1
@@ -18,7 +18,7 @@ services:
|
||||
- PORT=8654
|
||||
- RUST_LOG=info
|
||||
hostname: bzod
|
||||
image: nx9-url-shortener:v0.4.0
|
||||
image: nx9-url-shortener:v0.6.0
|
||||
ports:
|
||||
- mode: ingress
|
||||
target: 8654
|
||||
@@ -39,6 +39,9 @@ services:
|
||||
- type: bind
|
||||
source: /DATA/AppData/bzod/www
|
||||
target: /app/www
|
||||
- type: bind
|
||||
source: /DATA/AppData/bzod/images
|
||||
target: /app/images
|
||||
devices: []
|
||||
cap_add: []
|
||||
networks:
|
||||
|
||||
@@ -0,0 +1,888 @@
|
||||
# BZOD Administrator Guide
|
||||
|
||||
Version: v0.6.0
|
||||
|
||||
---
|
||||
|
||||
# Introduction
|
||||
|
||||
This guide is intended for BZOD administrators responsible for operating, maintaining, and managing a BZOD instance.
|
||||
|
||||
It covers:
|
||||
|
||||
* Administrator authentication
|
||||
* User management
|
||||
* Quotas
|
||||
* Sessions
|
||||
* Moderation
|
||||
* Slug ownership
|
||||
* Analytics
|
||||
* Audit logs
|
||||
* Backup and recovery
|
||||
* Health monitoring
|
||||
* Operational best practices
|
||||
|
||||
---
|
||||
|
||||
# Administrator Role
|
||||
|
||||
Administrators have full platform control.
|
||||
|
||||
Administrative capabilities include:
|
||||
|
||||
* Create users
|
||||
* Modify users
|
||||
* Disable users
|
||||
* Delete users
|
||||
* Reset passwords
|
||||
* Manage quotas
|
||||
* Review analytics
|
||||
* Moderate content
|
||||
* Transfer slug ownership
|
||||
* Manage backups
|
||||
* Review audit logs
|
||||
* Monitor system health
|
||||
|
||||
Administrators cannot bypass audit logging.
|
||||
|
||||
All administrative actions are recorded.
|
||||
|
||||
---
|
||||
|
||||
# Login
|
||||
|
||||
Administrative login is available at:
|
||||
|
||||
```text
|
||||
/login
|
||||
```
|
||||
|
||||
Successful login redirects to:
|
||||
|
||||
```text
|
||||
/admin
|
||||
```
|
||||
|
||||
Authentication uses:
|
||||
|
||||
```text
|
||||
users.db
|
||||
```
|
||||
|
||||
Sessions are stored in:
|
||||
|
||||
```text
|
||||
users.db.sessions
|
||||
```
|
||||
|
||||
Cookie name:
|
||||
|
||||
```text
|
||||
bzod_session
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Administrative Dashboard
|
||||
|
||||
Route:
|
||||
|
||||
```text
|
||||
/admin
|
||||
```
|
||||
|
||||
The dashboard provides a high-level overview of platform activity.
|
||||
|
||||
Metrics include:
|
||||
|
||||
* Total Users
|
||||
* Active Users
|
||||
* Total URLs
|
||||
* Total Landing Pages
|
||||
* Active Sessions
|
||||
* API Tokens
|
||||
* Storage Usage
|
||||
* Moderation Events
|
||||
* Recent Audit Events
|
||||
|
||||
Quick actions include:
|
||||
|
||||
* Create User
|
||||
* View Sessions
|
||||
* View Audit Logs
|
||||
* Create Backup
|
||||
* Review Health Status
|
||||
|
||||
---
|
||||
|
||||
# User Management
|
||||
|
||||
## Users List
|
||||
|
||||
Route:
|
||||
|
||||
```text
|
||||
/admin/users
|
||||
```
|
||||
|
||||
Displays:
|
||||
|
||||
* User ID
|
||||
* Username
|
||||
* Status
|
||||
* Account Type
|
||||
* Creation Date
|
||||
|
||||
Available actions:
|
||||
|
||||
* View
|
||||
* Edit
|
||||
* Disable
|
||||
* Enable
|
||||
* Reset Password
|
||||
* Delete
|
||||
|
||||
---
|
||||
|
||||
## Create User
|
||||
|
||||
Route:
|
||||
|
||||
```text
|
||||
/admin/users/new
|
||||
```
|
||||
|
||||
Fields:
|
||||
|
||||
* Username
|
||||
* Password
|
||||
* Account Type
|
||||
* Quota Limits
|
||||
|
||||
Supported account types:
|
||||
|
||||
```text
|
||||
admin
|
||||
standard
|
||||
```
|
||||
|
||||
Reserved usernames cannot be used.
|
||||
|
||||
Examples:
|
||||
|
||||
```text
|
||||
admin
|
||||
legacy_admin
|
||||
system
|
||||
root
|
||||
administrator
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## User Detail Page
|
||||
|
||||
Route:
|
||||
|
||||
```text
|
||||
/admin/users/{id}
|
||||
```
|
||||
|
||||
Displays:
|
||||
|
||||
### Profile
|
||||
|
||||
* User ID
|
||||
* Username
|
||||
* Status
|
||||
* Account Type
|
||||
* Created Date
|
||||
|
||||
### Usage Statistics
|
||||
|
||||
* URL Count
|
||||
* Landing Page Count
|
||||
* Visit Count
|
||||
* Storage Usage
|
||||
* API Token Count
|
||||
* Active Sessions
|
||||
|
||||
### Quotas
|
||||
|
||||
* Maximum URLs
|
||||
* Maximum Pages
|
||||
* Maximum Storage
|
||||
* Maximum Tokens
|
||||
|
||||
### Sessions
|
||||
|
||||
List of active sessions.
|
||||
|
||||
### API Tokens
|
||||
|
||||
List of active tokens.
|
||||
|
||||
---
|
||||
|
||||
## Edit User
|
||||
|
||||
Route:
|
||||
|
||||
```text
|
||||
/admin/users/{id}/edit
|
||||
```
|
||||
|
||||
Administrators may:
|
||||
|
||||
* Change status
|
||||
* Change account type
|
||||
* Modify quotas
|
||||
|
||||
---
|
||||
|
||||
## Reset Password
|
||||
|
||||
Route:
|
||||
|
||||
```text
|
||||
/admin/users/{id}/password
|
||||
```
|
||||
|
||||
Creates a new password hash and invalidates existing sessions.
|
||||
|
||||
Audit event generated:
|
||||
|
||||
```text
|
||||
password_reset
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Disable User
|
||||
|
||||
Route:
|
||||
|
||||
```text
|
||||
/admin/users/{id}/disable
|
||||
```
|
||||
|
||||
Effects:
|
||||
|
||||
* User login disabled
|
||||
* Existing sessions revoked
|
||||
* API access denied
|
||||
|
||||
Audit event generated:
|
||||
|
||||
```text
|
||||
user_disabled
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Enable User
|
||||
|
||||
Route:
|
||||
|
||||
```text
|
||||
/admin/users/{id}/enable
|
||||
```
|
||||
|
||||
Restores account access.
|
||||
|
||||
Audit event generated:
|
||||
|
||||
```text
|
||||
user_enabled
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Delete User
|
||||
|
||||
Route:
|
||||
|
||||
```text
|
||||
/admin/users/{id}/delete
|
||||
```
|
||||
|
||||
Deletion performs:
|
||||
|
||||
1. Session revocation
|
||||
2. API token removal
|
||||
3. Content removal
|
||||
4. Analytics removal
|
||||
5. Slug release
|
||||
6. User database deletion
|
||||
|
||||
Audit event generated:
|
||||
|
||||
```text
|
||||
user_deleted
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Session Management
|
||||
|
||||
Route:
|
||||
|
||||
```text
|
||||
/admin/sessions
|
||||
```
|
||||
|
||||
Displays all active platform sessions.
|
||||
|
||||
Information displayed:
|
||||
|
||||
* User ID
|
||||
* Username
|
||||
* Session Identifier
|
||||
* Created Time
|
||||
* Expiry Time
|
||||
* IP Address
|
||||
* User Agent
|
||||
|
||||
---
|
||||
|
||||
## Revoke Session
|
||||
|
||||
Individual sessions can be revoked.
|
||||
|
||||
Effects:
|
||||
|
||||
* Session removed immediately
|
||||
* User forced to reauthenticate
|
||||
|
||||
---
|
||||
|
||||
## Revoke All Sessions
|
||||
|
||||
Administrators may invalidate all active sessions.
|
||||
|
||||
Useful after:
|
||||
|
||||
* Password compromise
|
||||
* Security incidents
|
||||
* Large configuration changes
|
||||
|
||||
---
|
||||
|
||||
# Quota Management
|
||||
|
||||
Route:
|
||||
|
||||
```text
|
||||
/admin/quotas
|
||||
```
|
||||
|
||||
Quotas limit user resource consumption.
|
||||
|
||||
Available limits:
|
||||
|
||||
```text
|
||||
max_urls
|
||||
max_pages
|
||||
max_storage_mb
|
||||
max_api_tokens
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Quota Reconciliation
|
||||
|
||||
Administrators can execute:
|
||||
|
||||
```text
|
||||
quota_reconcile
|
||||
```
|
||||
|
||||
Purpose:
|
||||
|
||||
* Detect counter drift
|
||||
* Recount resources
|
||||
* Repair quota usage
|
||||
|
||||
Common causes:
|
||||
|
||||
* Manual database modifications
|
||||
* Failed migrations
|
||||
* Interrupted operations
|
||||
|
||||
---
|
||||
|
||||
# Moderation
|
||||
|
||||
Route:
|
||||
|
||||
```text
|
||||
/admin/moderation
|
||||
```
|
||||
|
||||
Moderation allows administrators to manage abuse and policy violations.
|
||||
|
||||
---
|
||||
|
||||
## Flag Content
|
||||
|
||||
Marks content for review.
|
||||
|
||||
Audit event:
|
||||
|
||||
```text
|
||||
content_flagged
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Disable Content
|
||||
|
||||
Disabled content returns:
|
||||
|
||||
```http
|
||||
410 Gone
|
||||
```
|
||||
|
||||
Affected endpoints:
|
||||
|
||||
```text
|
||||
/{slug}
|
||||
/p/{slug}
|
||||
/api/qr/{slug}.png
|
||||
/api/qr/{slug}.svg
|
||||
```
|
||||
|
||||
Audit event:
|
||||
|
||||
```text
|
||||
content_disabled
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Enable Content
|
||||
|
||||
Restores functionality.
|
||||
|
||||
Audit event:
|
||||
|
||||
```text
|
||||
content_enabled
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Delete Content
|
||||
|
||||
Permanently removes content.
|
||||
|
||||
Audit event:
|
||||
|
||||
```text
|
||||
content_deleted
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Slug Management
|
||||
|
||||
Route:
|
||||
|
||||
```text
|
||||
/admin/slugs
|
||||
```
|
||||
|
||||
Displays platform-wide slug ownership.
|
||||
|
||||
Information includes:
|
||||
|
||||
* Slug
|
||||
* Owner
|
||||
* Type
|
||||
* Status
|
||||
* Creation Date
|
||||
|
||||
---
|
||||
|
||||
## Slug Types
|
||||
|
||||
Supported types:
|
||||
|
||||
```text
|
||||
url
|
||||
page
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Transfer Ownership
|
||||
|
||||
Administrators may transfer ownership.
|
||||
|
||||
Workflow:
|
||||
|
||||
1. Validate recipient quota.
|
||||
2. Copy content.
|
||||
3. Update ownership.
|
||||
4. Update global slug registry.
|
||||
5. Write audit record.
|
||||
|
||||
Audit event:
|
||||
|
||||
```text
|
||||
slug_transfer
|
||||
```
|
||||
|
||||
Analytics are preserved.
|
||||
|
||||
---
|
||||
|
||||
# Analytics
|
||||
|
||||
Administrators can access analytics for any managed resource.
|
||||
|
||||
---
|
||||
|
||||
## URL Analytics
|
||||
|
||||
Route:
|
||||
|
||||
```text
|
||||
/admin/analytics/url/{id}
|
||||
```
|
||||
|
||||
Displays:
|
||||
|
||||
* Total Visits
|
||||
* Unique Visitors
|
||||
* Referrers
|
||||
* Browsers
|
||||
* Countries
|
||||
* Visit Timeline
|
||||
|
||||
---
|
||||
|
||||
## Page Analytics
|
||||
|
||||
Route:
|
||||
|
||||
```text
|
||||
/admin/analytics/page/{id}
|
||||
```
|
||||
|
||||
Displays identical metrics for landing pages.
|
||||
|
||||
---
|
||||
|
||||
## User Analytics
|
||||
|
||||
Administrators can review user-level analytics.
|
||||
|
||||
Route:
|
||||
|
||||
```text
|
||||
/analytics
|
||||
```
|
||||
|
||||
Includes:
|
||||
|
||||
* Top Links
|
||||
* Top Pages
|
||||
* Referrers
|
||||
* Browsers
|
||||
* Countries
|
||||
* Recent Visits
|
||||
|
||||
---
|
||||
|
||||
# Audit Logs
|
||||
|
||||
Route:
|
||||
|
||||
```text
|
||||
/admin/audit
|
||||
```
|
||||
|
||||
All administrative actions are recorded.
|
||||
|
||||
Searchable event types include:
|
||||
|
||||
```text
|
||||
login
|
||||
logout
|
||||
failed_login
|
||||
user_created
|
||||
user_deleted
|
||||
user_disabled
|
||||
user_enabled
|
||||
password_reset
|
||||
quota_updated
|
||||
slug_transfer
|
||||
content_flagged
|
||||
content_disabled
|
||||
backup_created
|
||||
restore_executed
|
||||
```
|
||||
|
||||
Audit logs should be reviewed regularly.
|
||||
|
||||
---
|
||||
|
||||
# Backup Management
|
||||
|
||||
Route:
|
||||
|
||||
```text
|
||||
/admin/backups
|
||||
```
|
||||
|
||||
Provides web-based backup operations.
|
||||
|
||||
---
|
||||
|
||||
## Create Backup
|
||||
|
||||
Creates a platform snapshot.
|
||||
|
||||
Includes:
|
||||
|
||||
```text
|
||||
users.db
|
||||
system.db
|
||||
tenant databases
|
||||
```
|
||||
|
||||
Audit event:
|
||||
|
||||
```text
|
||||
backup_created
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Download Backup
|
||||
|
||||
Allows local storage of backup archives.
|
||||
|
||||
Recommended frequency:
|
||||
|
||||
```text
|
||||
Daily
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Restore Backup
|
||||
|
||||
Restores a selected backup archive.
|
||||
|
||||
Audit event:
|
||||
|
||||
```text
|
||||
restore_executed
|
||||
```
|
||||
|
||||
Always test restores before production use.
|
||||
|
||||
---
|
||||
|
||||
## Delete Backup
|
||||
|
||||
Removes backup archives from storage.
|
||||
|
||||
---
|
||||
|
||||
# Health Dashboard
|
||||
|
||||
Route:
|
||||
|
||||
```text
|
||||
/admin/health
|
||||
```
|
||||
|
||||
Provides operational diagnostics.
|
||||
|
||||
Displays:
|
||||
|
||||
* Database Status
|
||||
* WAL Status
|
||||
* Storage Utilization
|
||||
* Backup Status
|
||||
* Health Check Results
|
||||
* Quota Reconciliation Results
|
||||
|
||||
---
|
||||
|
||||
## Database Health
|
||||
|
||||
Checks:
|
||||
|
||||
```text
|
||||
users.db
|
||||
system.db
|
||||
content.db
|
||||
analytics.db
|
||||
```
|
||||
|
||||
Reports:
|
||||
|
||||
```text
|
||||
healthy
|
||||
warning
|
||||
error
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Storage Monitoring
|
||||
|
||||
Shows:
|
||||
|
||||
* Total Storage
|
||||
* Free Storage
|
||||
* Database Sizes
|
||||
* Backup Sizes
|
||||
|
||||
---
|
||||
|
||||
# Security Administration
|
||||
|
||||
## Password Policies
|
||||
|
||||
Recommendations:
|
||||
|
||||
* Minimum 12 characters
|
||||
* Unique passwords
|
||||
* Password manager usage
|
||||
|
||||
---
|
||||
|
||||
## Session Management
|
||||
|
||||
Recommended actions:
|
||||
|
||||
* Revoke old sessions
|
||||
* Review active sessions
|
||||
* Remove inactive users
|
||||
|
||||
---
|
||||
|
||||
## CSRF Protection
|
||||
|
||||
All administrative forms require valid CSRF tokens.
|
||||
|
||||
Invalid requests return:
|
||||
|
||||
```http
|
||||
403 Forbidden
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Audit Reviews
|
||||
|
||||
Recommended review schedule:
|
||||
|
||||
| Event Type | Frequency |
|
||||
| ----------------- | --------- |
|
||||
| Failed Logins | Daily |
|
||||
| User Creation | Weekly |
|
||||
| Slug Transfers | Weekly |
|
||||
| Backup Events | Daily |
|
||||
| Moderation Events | Weekly |
|
||||
|
||||
---
|
||||
|
||||
# Disaster Recovery
|
||||
|
||||
Recommended workflow:
|
||||
|
||||
1. Stop BZOD.
|
||||
2. Create backup copy.
|
||||
3. Restore archive.
|
||||
4. Verify databases.
|
||||
5. Run integrity checks.
|
||||
6. Restart service.
|
||||
|
||||
---
|
||||
|
||||
# Operational Best Practices
|
||||
|
||||
Recommended:
|
||||
|
||||
* Enable HTTPS
|
||||
* Run daily backups
|
||||
* Monitor disk usage
|
||||
* Review audit logs
|
||||
* Keep binaries updated
|
||||
* Test restore procedures regularly
|
||||
|
||||
Avoid:
|
||||
|
||||
* Manual database modifications
|
||||
* Direct deletion of tenant databases
|
||||
* Disabling audit logging
|
||||
|
||||
---
|
||||
|
||||
# Troubleshooting
|
||||
|
||||
## User Cannot Login
|
||||
|
||||
Check:
|
||||
|
||||
* User status
|
||||
* Session validity
|
||||
* Password reset history
|
||||
|
||||
---
|
||||
|
||||
## Slug Already Exists
|
||||
|
||||
Check:
|
||||
|
||||
```text
|
||||
/admin/slugs
|
||||
```
|
||||
|
||||
for ownership conflicts.
|
||||
|
||||
---
|
||||
|
||||
## Analytics Missing
|
||||
|
||||
Verify:
|
||||
|
||||
* Analytics worker running
|
||||
* Analytics database present
|
||||
* Event queue processing
|
||||
|
||||
---
|
||||
|
||||
## Backup Failure
|
||||
|
||||
Check:
|
||||
|
||||
* Free disk space
|
||||
* File permissions
|
||||
* Backup destination path
|
||||
|
||||
---
|
||||
|
||||
# Summary
|
||||
|
||||
The BZOD administration system provides:
|
||||
|
||||
* Centralized user management
|
||||
* Quotas and session controls
|
||||
* Moderation and slug ownership management
|
||||
* Analytics visibility
|
||||
* Audit logging
|
||||
* Backup and restore capabilities
|
||||
* Health monitoring
|
||||
|
||||
while maintaining strong tenant isolation and a SQLite-native operational model.
|
||||
|
||||
---
|
||||
|
||||
End of Document.
|
||||
@@ -0,0 +1,667 @@
|
||||
# BZOD Architecture Guide
|
||||
|
||||
Version: v0.6.0
|
||||
|
||||
---
|
||||
|
||||
# Overview
|
||||
|
||||
BZOD is a self-hosted multi-user URL management platform written in Rust.
|
||||
|
||||
The platform combines:
|
||||
|
||||
* URL shortening
|
||||
* Landing pages
|
||||
* QR code generation
|
||||
* Analytics
|
||||
* User management
|
||||
* Moderation
|
||||
* Audit logging
|
||||
* Backup & restore
|
||||
* Disaster recovery
|
||||
|
||||
into a single deployable binary powered entirely by SQLite.
|
||||
|
||||
BZOD is designed around operational simplicity, tenant isolation, and long-term maintainability.
|
||||
|
||||
---
|
||||
|
||||
# Architectural Goals
|
||||
|
||||
The primary design goals are:
|
||||
|
||||
1. Self-hosted first
|
||||
2. SQLite-first architecture
|
||||
3. Multi-user operation
|
||||
4. Tenant isolation
|
||||
5. Simple deployment
|
||||
6. Minimal dependencies
|
||||
7. Easy backup and recovery
|
||||
8. No vendor lock-in
|
||||
|
||||
---
|
||||
|
||||
# High-Level Architecture
|
||||
|
||||
```text
|
||||
┌─────────────┐
|
||||
│ Browser │
|
||||
└──────┬──────┘
|
||||
│
|
||||
▼
|
||||
┌────────────────────┐
|
||||
│ Axum Router │
|
||||
└─────────┬──────────┘
|
||||
│
|
||||
┌────────────────────┼────────────────────┐
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
|
||||
users.db system.db User Databases
|
||||
|
||||
Users Global Slugs content.db
|
||||
Sessions Audit Events analytics.db
|
||||
Quotas Moderation
|
||||
API Tokens Settings
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Runtime Components
|
||||
|
||||
## Web Layer
|
||||
|
||||
Location:
|
||||
|
||||
```text
|
||||
src/web/
|
||||
```
|
||||
|
||||
Responsible for:
|
||||
|
||||
* HTTP routing
|
||||
* Dashboard rendering
|
||||
* Form handling
|
||||
* Authentication checks
|
||||
* Redirect handling
|
||||
* REST API endpoints
|
||||
|
||||
Major modules:
|
||||
|
||||
```text
|
||||
admin/ (modular feature directory)
|
||||
auth.rs (authentication and session handling)
|
||||
dashboard.rs (dashboard rendering)
|
||||
urls.rs (URL management handlers)
|
||||
pages.rs (landing page management handlers)
|
||||
analytics.rs (analytics and export handlers)
|
||||
settings.rs (settings and configuration handlers)
|
||||
users.rs (user management handlers)
|
||||
sessions.rs (session administration)
|
||||
quotas.rs (quota management)
|
||||
health.rs (health diagnostics)
|
||||
backups.rs (backup and restore handlers)
|
||||
api_keys.rs (API key management)
|
||||
audit.rs (audit log handlers)
|
||||
moderation.rs (content moderation handlers)
|
||||
mod.rs (module exports and shared helpers)
|
||||
api.rs
|
||||
pages.rs
|
||||
redirect.rs
|
||||
qr.rs
|
||||
system.rs
|
||||
multi_user.rs
|
||||
routes.rs
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Authentication Layer
|
||||
|
||||
Location:
|
||||
|
||||
```text
|
||||
src/auth/
|
||||
```
|
||||
|
||||
Responsible for:
|
||||
|
||||
* Password hashing
|
||||
* Session validation
|
||||
* Cookie management
|
||||
* CSRF protection
|
||||
* Authorization
|
||||
|
||||
Modules:
|
||||
|
||||
```text
|
||||
csrf.rs
|
||||
middleware.rs
|
||||
password.rs
|
||||
session.rs
|
||||
```
|
||||
|
||||
Authentication technologies:
|
||||
|
||||
* Argon2id password hashing
|
||||
* Session cookies
|
||||
* CSRF tokens
|
||||
* RBAC checks
|
||||
|
||||
---
|
||||
|
||||
## Database Layer
|
||||
|
||||
Location:
|
||||
|
||||
```text
|
||||
src/db/
|
||||
```
|
||||
|
||||
Responsible for:
|
||||
|
||||
* Schema creation
|
||||
* Migrations
|
||||
* Database access
|
||||
* Analytics storage
|
||||
* User management
|
||||
|
||||
Modules:
|
||||
|
||||
```text
|
||||
admin.rs
|
||||
analytics.rs
|
||||
audit_events.rs
|
||||
content.rs
|
||||
migrations.rs
|
||||
sqlite.rs
|
||||
users.rs
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Database Architecture
|
||||
|
||||
BZOD uses multiple SQLite databases rather than a single monolithic database.
|
||||
|
||||
This approach provides:
|
||||
|
||||
* Better isolation
|
||||
* Easier backup
|
||||
* Simpler disaster recovery
|
||||
* Reduced risk of cross-user data leakage
|
||||
|
||||
---
|
||||
|
||||
## users.db
|
||||
|
||||
Purpose:
|
||||
|
||||
Central identity and account database.
|
||||
|
||||
Contains:
|
||||
|
||||
```text
|
||||
users
|
||||
sessions
|
||||
api_tokens
|
||||
quotas
|
||||
```
|
||||
|
||||
Stores:
|
||||
|
||||
* User accounts
|
||||
* Password hashes
|
||||
* Session records
|
||||
* API tokens
|
||||
* Quota information
|
||||
|
||||
---
|
||||
|
||||
## system.db
|
||||
|
||||
Purpose:
|
||||
|
||||
Global platform metadata.
|
||||
|
||||
Contains:
|
||||
|
||||
```text
|
||||
global_slugs
|
||||
audit_events
|
||||
moderation_events
|
||||
reserved_slugs
|
||||
settings
|
||||
slug_history
|
||||
```
|
||||
|
||||
Stores:
|
||||
|
||||
* Global slug ownership
|
||||
* Audit records
|
||||
* Moderation actions
|
||||
* Platform settings
|
||||
* Slug transfers
|
||||
|
||||
---
|
||||
|
||||
## Tenant Databases
|
||||
|
||||
Each user receives isolated databases.
|
||||
|
||||
Directory structure:
|
||||
|
||||
```text
|
||||
users/
|
||||
└── <user_id>/
|
||||
├── content.db
|
||||
└── analytics.db
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### content.db
|
||||
|
||||
Stores:
|
||||
|
||||
* URLs
|
||||
* Landing pages
|
||||
* Metadata
|
||||
|
||||
---
|
||||
|
||||
### analytics.db
|
||||
|
||||
Stores:
|
||||
|
||||
* Visits
|
||||
* Referrers
|
||||
* QR scans
|
||||
* Browser information
|
||||
* Analytics aggregates
|
||||
|
||||
---
|
||||
|
||||
# Multi-User Architecture
|
||||
|
||||
BZOD v0.5.0 introduced complete tenant isolation.
|
||||
|
||||
Each user owns:
|
||||
|
||||
```text
|
||||
content.db
|
||||
analytics.db
|
||||
```
|
||||
|
||||
Users cannot directly access:
|
||||
|
||||
* Other users' URLs
|
||||
* Other users' landing pages
|
||||
* Other users' analytics
|
||||
|
||||
The administrator accesses all tenants through controlled administrative interfaces.
|
||||
|
||||
---
|
||||
|
||||
# Global Slug Namespace
|
||||
|
||||
All public URLs are tracked in:
|
||||
|
||||
```text
|
||||
system.db -> global_slugs
|
||||
```
|
||||
|
||||
Purpose:
|
||||
|
||||
Prevent collisions across users.
|
||||
|
||||
Example:
|
||||
|
||||
```text
|
||||
User A owns:
|
||||
|
||||
https://bzo.in/!office
|
||||
|
||||
User B cannot create:
|
||||
|
||||
https://bzo.in/!office
|
||||
```
|
||||
|
||||
This guarantees global uniqueness.
|
||||
|
||||
---
|
||||
|
||||
# Request Lifecycle
|
||||
|
||||
## URL Redirect
|
||||
|
||||
Request:
|
||||
|
||||
```text
|
||||
GET /abc123
|
||||
```
|
||||
|
||||
Flow:
|
||||
|
||||
```text
|
||||
Browser
|
||||
↓
|
||||
Axum Router
|
||||
↓
|
||||
global_slugs lookup
|
||||
↓
|
||||
Locate owner database
|
||||
↓
|
||||
Resolve URL
|
||||
↓
|
||||
Validate destination
|
||||
↓
|
||||
Record analytics
|
||||
↓
|
||||
301 Redirect (with safe Location header construction)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Landing Page
|
||||
|
||||
Request:
|
||||
|
||||
```text
|
||||
GET /p/demo
|
||||
```
|
||||
|
||||
Flow:
|
||||
|
||||
```text
|
||||
Browser
|
||||
↓
|
||||
Router
|
||||
↓
|
||||
global_slugs lookup
|
||||
↓
|
||||
Tenant content.db lookup
|
||||
↓
|
||||
Render page
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## QR Generation
|
||||
|
||||
Request:
|
||||
|
||||
```text
|
||||
GET /api/qr/demo.svg
|
||||
```
|
||||
|
||||
Flow:
|
||||
|
||||
```text
|
||||
Router
|
||||
↓
|
||||
global_slugs lookup
|
||||
↓
|
||||
Generate QR
|
||||
↓
|
||||
Return SVG
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Analytics Pipeline
|
||||
|
||||
Location:
|
||||
|
||||
```text
|
||||
src/analytics/
|
||||
```
|
||||
|
||||
Components:
|
||||
|
||||
```text
|
||||
events.rs
|
||||
queue.rs
|
||||
worker.rs
|
||||
aggregate.rs
|
||||
location.rs
|
||||
```
|
||||
|
||||
Responsibilities:
|
||||
|
||||
* Visit tracking
|
||||
* QR tracking
|
||||
* Browser detection
|
||||
* Referrer parsing
|
||||
* Aggregation
|
||||
|
||||
---
|
||||
|
||||
# Background Jobs
|
||||
|
||||
Location:
|
||||
|
||||
```text
|
||||
src/jobs/
|
||||
```
|
||||
|
||||
Jobs:
|
||||
|
||||
## aggregate.rs
|
||||
|
||||
Analytics aggregation.
|
||||
|
||||
## backup.rs
|
||||
|
||||
Automated backups.
|
||||
|
||||
## expiry.rs
|
||||
|
||||
Expired content cleanup.
|
||||
|
||||
## retention.rs
|
||||
|
||||
Retention policy enforcement.
|
||||
|
||||
## healthcheck.rs
|
||||
|
||||
System health validation.
|
||||
|
||||
## quota_reconcile.rs
|
||||
|
||||
Quota consistency verification.
|
||||
|
||||
---
|
||||
|
||||
# Services Layer
|
||||
|
||||
Location:
|
||||
|
||||
```text
|
||||
src/services/
|
||||
```
|
||||
|
||||
Purpose:
|
||||
|
||||
Business logic abstraction.
|
||||
|
||||
Modules:
|
||||
|
||||
```text
|
||||
api_keys.rs
|
||||
audit.rs
|
||||
bulk.rs
|
||||
landing_pages.rs
|
||||
qr.rs
|
||||
shortener.rs
|
||||
```
|
||||
|
||||
This layer separates business rules from HTTP handlers.
|
||||
|
||||
---
|
||||
|
||||
# CLI Architecture
|
||||
|
||||
Location:
|
||||
|
||||
```text
|
||||
src/cli/
|
||||
```
|
||||
|
||||
The CLI and Web UI share the same internal services.
|
||||
|
||||
Examples:
|
||||
|
||||
```bash
|
||||
bzod create-admin
|
||||
bzod create-user
|
||||
bzod backup
|
||||
bzod restore
|
||||
bzod doctor
|
||||
bzod migrate
|
||||
```
|
||||
|
||||
This avoids duplicate logic between administration methods.
|
||||
|
||||
---
|
||||
|
||||
# Security Model
|
||||
|
||||
Security mechanisms:
|
||||
|
||||
## Authentication
|
||||
|
||||
* Argon2id password hashes
|
||||
* Session cookies
|
||||
|
||||
## Authorization
|
||||
|
||||
* RBAC
|
||||
* Administrative permission checks
|
||||
|
||||
## CSRF Protection
|
||||
|
||||
* Form tokens
|
||||
* Request validation
|
||||
|
||||
## Tenant Isolation
|
||||
|
||||
* Separate databases
|
||||
* Controlled access paths
|
||||
|
||||
## Audit Logging
|
||||
|
||||
All critical operations are recorded.
|
||||
|
||||
Examples:
|
||||
|
||||
* Login attempts
|
||||
* User creation
|
||||
* Password resets
|
||||
* Slug transfers
|
||||
* Moderation actions
|
||||
|
||||
---
|
||||
|
||||
# Backup & Recovery
|
||||
|
||||
BZOD is designed for SQLite-first recovery.
|
||||
|
||||
Backup targets:
|
||||
|
||||
```text
|
||||
users.db
|
||||
system.db
|
||||
admin/
|
||||
users/*
|
||||
```
|
||||
|
||||
Capabilities:
|
||||
|
||||
* Full backups
|
||||
* Restore operations
|
||||
* Upgrade migrations
|
||||
* Disaster recovery validation
|
||||
|
||||
---
|
||||
|
||||
# Testing Architecture
|
||||
|
||||
Location:
|
||||
|
||||
```text
|
||||
tests/
|
||||
```
|
||||
|
||||
Coverage includes:
|
||||
|
||||
* Authentication
|
||||
* Authorization
|
||||
* User management
|
||||
* Analytics
|
||||
* Backups
|
||||
* Disaster recovery
|
||||
* Routing
|
||||
* Security
|
||||
* Concurrency
|
||||
* Upgrade validation
|
||||
* Multi-user isolation
|
||||
|
||||
The project includes comprehensive automated test coverage spanning unit, integration, security, and end-to-end tests.
|
||||
|
||||
---
|
||||
|
||||
# Deployment Models
|
||||
|
||||
Supported deployments:
|
||||
|
||||
## Native
|
||||
|
||||
```bash
|
||||
cargo build --release
|
||||
./bzod serve
|
||||
```
|
||||
|
||||
## Systemd
|
||||
|
||||
```text
|
||||
bzod.service
|
||||
```
|
||||
|
||||
## Docker
|
||||
|
||||
```text
|
||||
Dockerfile
|
||||
docker-compose.yml
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Future Architecture Direction
|
||||
|
||||
Planned for future releases:
|
||||
|
||||
* Geo analytics
|
||||
* OpenAPI generation
|
||||
* SSO integration
|
||||
* Multi-organization support
|
||||
* Advanced reporting
|
||||
* Distributed analytics aggregation
|
||||
|
||||
---
|
||||
|
||||
# Summary
|
||||
|
||||
BZOD is built around a simple principle:
|
||||
|
||||
> Keep deployment simple, keep data local, keep users isolated, and keep recovery easy.
|
||||
|
||||
The platform achieves this through:
|
||||
|
||||
* Rust
|
||||
* Axum
|
||||
* SQLite
|
||||
* Tenant isolation
|
||||
* Multi-database architecture
|
||||
* Strong automated validation
|
||||
* Operational simplicity
|
||||
@@ -0,0 +1,584 @@
|
||||
# Backup & Restore Guide
|
||||
|
||||
Version: v0.6.0
|
||||
Applies To: BZOD Multi-User Platform
|
||||
|
||||
---
|
||||
|
||||
# Overview
|
||||
|
||||
BZOD provides built-in backup and recovery functionality for both single-user and multi-user deployments.
|
||||
|
||||
The backup architecture is designed to support:
|
||||
|
||||
* Full platform backups
|
||||
* Individual tenant backups
|
||||
* Disaster recovery
|
||||
* Upgrade safety
|
||||
* Migration validation
|
||||
* Data integrity verification
|
||||
|
||||
All production deployments should maintain regular backups before performing upgrades, maintenance, or administrative operations.
|
||||
|
||||
---
|
||||
|
||||
# Database Architecture
|
||||
|
||||
BZOD stores data across multiple SQLite databases.
|
||||
|
||||
## Core Databases
|
||||
|
||||
```text
|
||||
data/
|
||||
├── users.db
|
||||
├── system.db
|
||||
└── users/
|
||||
```
|
||||
|
||||
### users.db
|
||||
|
||||
Stores:
|
||||
|
||||
* User accounts
|
||||
* Password hashes
|
||||
* Account status
|
||||
* Roles
|
||||
* Sessions
|
||||
* Quotas
|
||||
* API tokens
|
||||
|
||||
### system.db
|
||||
|
||||
Stores:
|
||||
|
||||
* Global slug registry
|
||||
* Reserved slugs
|
||||
* Slug ownership history
|
||||
* Audit events
|
||||
* Moderation events
|
||||
* System settings
|
||||
|
||||
---
|
||||
|
||||
## Tenant Databases
|
||||
|
||||
Each tenant owns isolated content and analytics databases.
|
||||
|
||||
```text
|
||||
data/users/{user_id}/
|
||||
├── content.db
|
||||
└── analytics.db
|
||||
```
|
||||
|
||||
### content.db
|
||||
|
||||
Stores:
|
||||
|
||||
* Short URLs
|
||||
* Landing pages
|
||||
* Metadata
|
||||
* Tags
|
||||
* QR code configuration
|
||||
|
||||
### analytics.db
|
||||
|
||||
Stores:
|
||||
|
||||
* Visit events
|
||||
* Referrers
|
||||
* Browser information
|
||||
* Country information
|
||||
* Aggregated statistics
|
||||
|
||||
---
|
||||
|
||||
# Backup Types
|
||||
|
||||
## Full Platform Backup
|
||||
|
||||
Creates a complete snapshot of the entire BZOD installation.
|
||||
|
||||
Includes:
|
||||
|
||||
```text
|
||||
users.db
|
||||
system.db
|
||||
all tenant content.db files
|
||||
all tenant analytics.db files
|
||||
```
|
||||
|
||||
Recommended for:
|
||||
|
||||
* Daily scheduled backups
|
||||
* Upgrades
|
||||
* Server migration
|
||||
* Disaster recovery
|
||||
|
||||
---
|
||||
|
||||
## User Backup
|
||||
|
||||
Creates a backup of a single tenant.
|
||||
|
||||
Includes:
|
||||
|
||||
```text
|
||||
content.db
|
||||
analytics.db
|
||||
```
|
||||
|
||||
Recommended for:
|
||||
|
||||
* User export
|
||||
* User migration
|
||||
* User recovery
|
||||
|
||||
---
|
||||
|
||||
# CLI Backup Commands
|
||||
|
||||
## Create Full Backup
|
||||
|
||||
```bash
|
||||
bzod backup
|
||||
```
|
||||
|
||||
Output:
|
||||
|
||||
```text
|
||||
backups/
|
||||
└── backup-YYYYMMDD-HHMMSS.zip
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Create User Backup
|
||||
|
||||
```bash
|
||||
bzod backup-user 42
|
||||
```
|
||||
|
||||
Output:
|
||||
|
||||
```text
|
||||
backups/
|
||||
└── user-42-YYYYMMDD-HHMMSS.zip
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# CLI Restore Commands
|
||||
|
||||
## Restore Full Backup
|
||||
|
||||
```bash
|
||||
bzod restore backup-20260619-020000.zip
|
||||
```
|
||||
|
||||
Restores:
|
||||
|
||||
* users.db
|
||||
* system.db
|
||||
* all tenant databases
|
||||
|
||||
---
|
||||
|
||||
## Restore Single User
|
||||
|
||||
```bash
|
||||
bzod restore-user user-42-20260619.zip
|
||||
```
|
||||
|
||||
Restores only:
|
||||
|
||||
```text
|
||||
users/42/content.db
|
||||
users/42/analytics.db
|
||||
```
|
||||
|
||||
without affecting any other tenant.
|
||||
|
||||
---
|
||||
|
||||
# Web-Based Backup Management
|
||||
|
||||
Administrative users can manage backups through:
|
||||
|
||||
```text
|
||||
/admin/backups
|
||||
```
|
||||
|
||||
Features:
|
||||
|
||||
* Create backup
|
||||
* Download backup
|
||||
* Upload backup
|
||||
* Restore backup
|
||||
* Delete backup
|
||||
|
||||
Only authenticated administrators may access backup operations.
|
||||
|
||||
---
|
||||
|
||||
# Backup Strategy
|
||||
|
||||
## Recommended Schedule
|
||||
|
||||
### Daily
|
||||
|
||||
```text
|
||||
02:00 AM
|
||||
```
|
||||
|
||||
Create a full platform backup.
|
||||
|
||||
---
|
||||
|
||||
### Weekly
|
||||
|
||||
```text
|
||||
Sunday 03:00 AM
|
||||
```
|
||||
|
||||
Create a full backup and copy it to:
|
||||
|
||||
* NAS
|
||||
* Secondary server
|
||||
* External storage
|
||||
|
||||
---
|
||||
|
||||
### Monthly
|
||||
|
||||
Archive a backup for long-term retention.
|
||||
|
||||
Recommended retention:
|
||||
|
||||
```text
|
||||
12 months
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Retention Policy
|
||||
|
||||
Recommended policy:
|
||||
|
||||
```text
|
||||
Daily Backups:
|
||||
30 days
|
||||
|
||||
Weekly Backups:
|
||||
12 weeks
|
||||
|
||||
Monthly Backups:
|
||||
12 months
|
||||
```
|
||||
|
||||
Adjust retention according to compliance requirements.
|
||||
|
||||
---
|
||||
|
||||
# Upgrade Procedure
|
||||
|
||||
Always create a backup before upgrading.
|
||||
|
||||
## Step 1
|
||||
|
||||
Create backup:
|
||||
|
||||
```bash
|
||||
bzod backup
|
||||
```
|
||||
|
||||
## Step 2
|
||||
|
||||
Upgrade BZOD binary.
|
||||
|
||||
## Step 3
|
||||
|
||||
Start BZOD.
|
||||
|
||||
```bash
|
||||
bzod serve
|
||||
```
|
||||
|
||||
## Step 4
|
||||
|
||||
Allow database migrations to complete.
|
||||
|
||||
## Step 5
|
||||
|
||||
Verify:
|
||||
|
||||
* Login
|
||||
* URLs
|
||||
* Landing pages
|
||||
* Analytics
|
||||
* Administration panels
|
||||
|
||||
---
|
||||
|
||||
# Restore Validation
|
||||
|
||||
After every restore operation verify:
|
||||
|
||||
## Authentication
|
||||
|
||||
* Administrator login works
|
||||
* Standard user login works
|
||||
|
||||
## Content
|
||||
|
||||
* URLs are visible
|
||||
* Landing pages render correctly
|
||||
|
||||
## Routing
|
||||
|
||||
* Slug redirects work
|
||||
* Landing page routes resolve
|
||||
|
||||
## Analytics
|
||||
|
||||
* Visit counts exist
|
||||
* Analytics dashboards load
|
||||
|
||||
## System
|
||||
|
||||
* Audit events visible
|
||||
* Moderation records preserved
|
||||
* System settings preserved
|
||||
|
||||
## Multi-User
|
||||
|
||||
* Tenant isolation maintained
|
||||
* Ownership mappings preserved
|
||||
|
||||
---
|
||||
|
||||
# Disaster Recovery Scenarios
|
||||
|
||||
## Scenario 1: Deleted User
|
||||
|
||||
Problem:
|
||||
|
||||
```text
|
||||
User account accidentally deleted.
|
||||
```
|
||||
|
||||
Recovery:
|
||||
|
||||
```bash
|
||||
bzod restore-user user-42.zip
|
||||
```
|
||||
|
||||
Verify:
|
||||
|
||||
* URLs restored
|
||||
* Pages restored
|
||||
* Analytics restored
|
||||
|
||||
---
|
||||
|
||||
## Scenario 2: Corrupted Tenant Database
|
||||
|
||||
Problem:
|
||||
|
||||
```text
|
||||
content.db corruption
|
||||
```
|
||||
|
||||
Recovery:
|
||||
|
||||
```bash
|
||||
bzod restore-user user-42.zip
|
||||
```
|
||||
|
||||
or
|
||||
|
||||
```bash
|
||||
bzod restore full-backup.zip
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Scenario 3: Corrupted users.db
|
||||
|
||||
Problem:
|
||||
|
||||
```text
|
||||
Unable to login
|
||||
Missing users
|
||||
Session failures
|
||||
```
|
||||
|
||||
Recovery:
|
||||
|
||||
```bash
|
||||
bzod restore full-backup.zip
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Scenario 4: Corrupted system.db
|
||||
|
||||
Problem:
|
||||
|
||||
```text
|
||||
Slug resolution failures
|
||||
Moderation data missing
|
||||
Settings lost
|
||||
```
|
||||
|
||||
Recovery:
|
||||
|
||||
```bash
|
||||
bzod restore full-backup.zip
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Scenario 5: Complete Server Failure
|
||||
|
||||
Problem:
|
||||
|
||||
```text
|
||||
Disk failure
|
||||
Server loss
|
||||
Hardware replacement
|
||||
```
|
||||
|
||||
Recovery:
|
||||
|
||||
1. Reinstall operating system
|
||||
2. Install BZOD
|
||||
3. Restore backup
|
||||
|
||||
```bash
|
||||
bzod restore backup.zip
|
||||
```
|
||||
|
||||
4. Start BZOD
|
||||
|
||||
```bash
|
||||
bzod serve
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# WAL Mode
|
||||
|
||||
BZOD uses SQLite Write-Ahead Logging (WAL).
|
||||
|
||||
Examples:
|
||||
|
||||
```text
|
||||
users.db
|
||||
users.db-wal
|
||||
users.db-shm
|
||||
|
||||
system.db
|
||||
system.db-wal
|
||||
system.db-shm
|
||||
|
||||
content.db
|
||||
content.db-wal
|
||||
content.db-shm
|
||||
|
||||
analytics.db
|
||||
analytics.db-wal
|
||||
analytics.db-shm
|
||||
```
|
||||
|
||||
Benefits:
|
||||
|
||||
* Improved concurrency
|
||||
* Better crash recovery
|
||||
* Faster write operations
|
||||
|
||||
---
|
||||
|
||||
# Backup Safety
|
||||
|
||||
Do not manually copy live SQLite databases while the server is actively writing.
|
||||
|
||||
Always use:
|
||||
|
||||
```bash
|
||||
bzod backup
|
||||
```
|
||||
|
||||
or the Backup Management UI.
|
||||
|
||||
This ensures consistent snapshots.
|
||||
|
||||
---
|
||||
|
||||
# Security Considerations
|
||||
|
||||
Backups may contain:
|
||||
|
||||
* User accounts
|
||||
* Password hashes
|
||||
* Session metadata
|
||||
* Analytics data
|
||||
* Audit records
|
||||
* API token hashes
|
||||
|
||||
Even though passwords and tokens are stored as hashes, backup archives should be treated as sensitive information.
|
||||
|
||||
Recommended practices:
|
||||
|
||||
* Encrypt backup storage
|
||||
* Restrict filesystem permissions
|
||||
* Maintain offsite copies
|
||||
* Transfer backups over secure channels
|
||||
* Test restores periodically
|
||||
|
||||
---
|
||||
|
||||
# Backup Testing
|
||||
|
||||
A backup is only useful if it can be restored.
|
||||
|
||||
Quarterly validation is recommended.
|
||||
|
||||
Example:
|
||||
|
||||
```bash
|
||||
mkdir restore-test
|
||||
|
||||
bzod restore backup.zip \
|
||||
--data-dir restore-test
|
||||
```
|
||||
|
||||
Verify:
|
||||
|
||||
* Login works
|
||||
* URLs resolve
|
||||
* Landing pages load
|
||||
* Analytics display
|
||||
* Administration dashboard functions
|
||||
|
||||
---
|
||||
|
||||
# Production Recommendation
|
||||
|
||||
Minimum production policy:
|
||||
|
||||
```text
|
||||
Daily Full Backup
|
||||
Weekly Offsite Backup
|
||||
Monthly Archive Backup
|
||||
Quarterly Restore Validation
|
||||
```
|
||||
|
||||
Following this policy protects against:
|
||||
|
||||
* User mistakes
|
||||
* Database corruption
|
||||
* Upgrade failures
|
||||
* Hardware failures
|
||||
* Site disasters
|
||||
|
||||
and provides a reliable recovery path for BZOD deployments.
|
||||
+343
-4
@@ -1,9 +1,348 @@
|
||||
# Changelog
|
||||
|
||||
## v0.4.0
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on Keep a Changelog and this project follows Semantic Versioning.
|
||||
|
||||
---
|
||||
|
||||
# v0.6.0 — Legacy Restore Compatibility & Version Reporting
|
||||
|
||||
- **Legacy Backup Restore**: Full backward-compatible restore support for `legacy_flat_backup` archives into the current multi-tenant database architecture
|
||||
- **CLI Version Reporting**: Added `--version` / `-V` flags derived from Cargo package metadata
|
||||
- **Deploy Script**: Removed obsolete `init-db` command; database creation and migration now handled by `bzod serve`
|
||||
- **Version Verification**: Deploy script now verifies installed binary version matches requested version
|
||||
|
||||
# v0.5.3 — Architecture Refinement & Redirect Hardening
|
||||
|
||||
---
|
||||
|
||||
## Changed
|
||||
|
||||
### Architecture
|
||||
|
||||
* Eliminated the monolithic `admin.rs` handler file
|
||||
* Reorganized admin functionality into focused feature modules under `src/web/admin/`
|
||||
* Separated authentication, dashboard, URLs, pages, analytics, settings, users, sessions, quotas, health, backups, API keys, audit, and moderation into dedicated modules
|
||||
* Extracted shared authentication and authorization helpers
|
||||
* Extracted common export and helper functionality
|
||||
|
||||
### Redirect Handling
|
||||
|
||||
* Removed panic-prone `HeaderValue::from_str(...).unwrap()` pattern from the redirect path
|
||||
* Added destination URL validation (scheme validation, control character rejection)
|
||||
* Added safe HTTP Location header construction
|
||||
* Improved database error logging with structured fields
|
||||
* Reduced unnecessary database mutex lock acquisitions on the redirect hot path
|
||||
* Removed synchronous expiration writes from the redirect hot path
|
||||
|
||||
---
|
||||
|
||||
## Improved
|
||||
|
||||
* Database lock scoping across admin handlers
|
||||
* Error handling consistency and observability
|
||||
* Handler decomposition for oversized functions
|
||||
* Reduced duplicated handler logic across admin operations
|
||||
|
||||
---
|
||||
|
||||
## Verified
|
||||
|
||||
* Root landing page (GET /) confirmed as intentional route serving www/index.html
|
||||
* Release binary built successfully
|
||||
* Runtime smoke tests passed (GET /, GET /login, GET /admin/login all return HTTP 200)
|
||||
* SQLite WAL mode and foreign-key enforcement initialized successfully
|
||||
* All existing migrations reported as up to date
|
||||
* Comprehensive automated test suite passed, including:
|
||||
* Authentication and migration tests
|
||||
* Redirect security tests
|
||||
* Root landing page test
|
||||
* Backup and restore tests
|
||||
* Business workflow tests
|
||||
* Security tests
|
||||
* Slug namespace, registry, and transfer tests
|
||||
* User management and isolation tests
|
||||
* WAL recovery tests
|
||||
* HTTP end-to-end tests
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
* This release is an internal architecture and quality improvement
|
||||
* No new user-facing features were introduced
|
||||
* Existing API and route behavior was preserved
|
||||
* Existing redirect security and tenant isolation behavior was preserved
|
||||
|
||||
---
|
||||
|
||||
# v0.5.1 - General Availability (GA)
|
||||
|
||||
Release Date: 2026-06-20
|
||||
|
||||
BZOD v0.5.1 is the largest release since project inception, transforming BZOD from a single-user URL shortener into a complete multi-user redirector, landing page, analytics, and administration platform.
|
||||
|
||||
---
|
||||
|
||||
## Added
|
||||
|
||||
### Multi-User Platform
|
||||
|
||||
* Multi-user architecture with isolated tenant databases
|
||||
* Standard user accounts
|
||||
* Administrator accounts
|
||||
* User provisioning and lifecycle management
|
||||
* User enable/disable operations
|
||||
* User deletion workflows
|
||||
* Password reset functionality
|
||||
* User quota management
|
||||
* User database isolation
|
||||
|
||||
### Authentication & Security
|
||||
|
||||
* Session-based authentication
|
||||
* CSRF protection
|
||||
* Role-Based Access Control (RBAC)
|
||||
* Password hashing and verification
|
||||
* Session invalidation
|
||||
* Login/logout workflows
|
||||
* Administrative privilege separation
|
||||
* Audit logging
|
||||
|
||||
### User Self-Service Portal
|
||||
|
||||
* User dashboard
|
||||
* My Links management
|
||||
* My Pages management
|
||||
* User analytics dashboard
|
||||
* API token management
|
||||
* Password management
|
||||
* Profile management
|
||||
|
||||
### Administration
|
||||
|
||||
* User management dashboard
|
||||
* User detail pages
|
||||
* User creation forms
|
||||
* User editing interface
|
||||
* Session administration
|
||||
* Quota administration
|
||||
* Moderation dashboard
|
||||
* Slug management dashboard
|
||||
* Audit event viewer
|
||||
* Backup management interface
|
||||
* System health dashboard
|
||||
|
||||
### Analytics
|
||||
|
||||
* Per-user analytics
|
||||
* URL analytics dashboards
|
||||
* Landing page analytics dashboards
|
||||
* Browser statistics
|
||||
* Referrer tracking
|
||||
* Visit logging
|
||||
* Geographic analytics framework
|
||||
* Analytics aggregation jobs
|
||||
|
||||
### Content Management
|
||||
|
||||
* Landing page builder
|
||||
* URL registry management
|
||||
* Global slug namespace
|
||||
* Slug ownership tracking
|
||||
* Slug transfer workflows
|
||||
* Soft delete support
|
||||
* Moderation controls
|
||||
|
||||
### Operations
|
||||
|
||||
* Backup CLI
|
||||
* Restore CLI
|
||||
* User backup support
|
||||
* User restore support
|
||||
* Database diagnostics
|
||||
* Health checks
|
||||
* Quota reconciliation jobs
|
||||
* Retention jobs
|
||||
* Expiry jobs
|
||||
* Aggregation workers
|
||||
|
||||
### Documentation
|
||||
|
||||
* Installation Guide
|
||||
* Upgrade Guide
|
||||
* Multi-User Guide
|
||||
* Administration Guide
|
||||
* Security Guide
|
||||
* Backup & Restore Guide
|
||||
* Database Documentation
|
||||
* Architecture Documentation
|
||||
* CLI Documentation
|
||||
* API Documentation
|
||||
* Testing Documentation
|
||||
|
||||
---
|
||||
|
||||
## Changed
|
||||
|
||||
### Architecture
|
||||
|
||||
* Migrated from single-user storage model to tenant-isolated storage model
|
||||
* Introduced users.db as central identity store
|
||||
* Introduced system.db as global platform metadata store
|
||||
* Introduced per-user content databases
|
||||
* Introduced per-user analytics databases
|
||||
|
||||
### Routing
|
||||
|
||||
* Unified global slug resolution
|
||||
* Centralized slug ownership tracking
|
||||
* Improved redirect handling
|
||||
* Improved landing page routing
|
||||
|
||||
### Analytics
|
||||
|
||||
* Improved aggregation performance
|
||||
* Improved reporting consistency
|
||||
* Improved analytics isolation
|
||||
|
||||
### Administration
|
||||
|
||||
* Expanded administrative tooling
|
||||
* Improved dashboard coverage
|
||||
* Added operational visibility
|
||||
|
||||
---
|
||||
|
||||
## Security
|
||||
|
||||
### Added
|
||||
|
||||
* CSRF validation
|
||||
* Session management
|
||||
* RBAC enforcement
|
||||
* Audit event logging
|
||||
* User isolation controls
|
||||
* Slug ownership validation
|
||||
|
||||
### Hardened
|
||||
|
||||
* Authentication flows
|
||||
* Session validation
|
||||
* Administrative authorization
|
||||
* User lifecycle operations
|
||||
|
||||
---
|
||||
|
||||
## Database
|
||||
|
||||
### Added
|
||||
|
||||
* users.db
|
||||
* system.db
|
||||
* Per-user content.db
|
||||
* Per-user analytics.db
|
||||
* Migration framework
|
||||
|
||||
### Improved
|
||||
|
||||
* WAL mode support
|
||||
* Upgrade migrations
|
||||
* Backup compatibility
|
||||
* Recovery workflows
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
### Added
|
||||
|
||||
Comprehensive automated validation covering:
|
||||
|
||||
* Authentication tests
|
||||
* Authorization tests
|
||||
* Migration tests
|
||||
* Upgrade validation tests
|
||||
* User isolation tests
|
||||
* Slug namespace tests
|
||||
* Slug transfer tests
|
||||
* Moderation tests
|
||||
* Backup and restore tests
|
||||
* Disaster recovery tests
|
||||
* Analytics tests
|
||||
* Concurrency tests
|
||||
* HTTP end-to-end tests
|
||||
* Business workflow tests
|
||||
* Security regression tests
|
||||
|
||||
### Coverage
|
||||
|
||||
* 90+ unit and integration tests
|
||||
* HTTP workflow validation
|
||||
* Upgrade path verification
|
||||
* Multi-user isolation verification
|
||||
* Backup and recovery validation
|
||||
|
||||
---
|
||||
|
||||
## Fixed
|
||||
|
||||
### Authentication
|
||||
|
||||
* Multi-user migration login regressions
|
||||
* Session validation issues
|
||||
* Administrative account migration edge cases
|
||||
|
||||
### Routing
|
||||
|
||||
* Redirect handling consistency
|
||||
* Slug ownership synchronization
|
||||
* Landing page resolution issues
|
||||
|
||||
### Analytics
|
||||
|
||||
* Aggregation edge cases
|
||||
* Reporting consistency
|
||||
* Isolation validation
|
||||
|
||||
### Concurrency
|
||||
|
||||
* Fixed mutex deadlock conditions discovered during E2E testing
|
||||
* Improved lock scoping around audit logging
|
||||
|
||||
### Administration
|
||||
|
||||
* Improved slug transfer workflows
|
||||
* Improved user lifecycle operations
|
||||
* Improved dashboard consistency
|
||||
|
||||
---
|
||||
|
||||
## Upgrade Notes
|
||||
|
||||
### From v0.4.0
|
||||
|
||||
BZOD v0.5.0 introduces a new multi-user architecture.
|
||||
|
||||
Existing installations are automatically migrated during startup.
|
||||
|
||||
Migration includes:
|
||||
|
||||
* Legacy administrator migration
|
||||
* Global slug index generation
|
||||
* User database creation
|
||||
* Analytics preservation
|
||||
* Content preservation
|
||||
|
||||
Backups are strongly recommended before upgrading.
|
||||
|
||||
---
|
||||
|
||||
# v0.4.0
|
||||
|
||||
## Added
|
||||
|
||||
* Raw visitor activity logs
|
||||
* Analytics drill-down pages
|
||||
* Date-range analytics filters
|
||||
@@ -12,13 +351,13 @@
|
||||
* Advanced pagination
|
||||
* Visitor log tables
|
||||
|
||||
### Improved
|
||||
## Improved
|
||||
|
||||
* Registry pagination
|
||||
* Analytics navigation
|
||||
* Export performance
|
||||
|
||||
### Fixed
|
||||
## Fixed
|
||||
|
||||
* Pagination edge cases
|
||||
* Analytics sorting consistency
|
||||
* Analytics sorting consistency
|
||||
+286
@@ -0,0 +1,286 @@
|
||||
# BZOD Command Line Interface (CLI)
|
||||
|
||||
BZOD includes a comprehensive command-line interface for server administration, backups, migrations, diagnostics, validation, and multi-user management.
|
||||
|
||||
The current command list for BZOD v0.6.0 is:
|
||||
|
||||
```text
|
||||
$ bzod --help
|
||||
|
||||
BZOD - Personal Redirector & Landing Page Platform
|
||||
|
||||
Usage: bzod <COMMAND>
|
||||
|
||||
Commands:
|
||||
serve Start the BZOD web server
|
||||
backup Create a tar.gz backup of all databases
|
||||
restore Restore databases from a tar.gz backup file
|
||||
migrate Apply pending database schema migrations
|
||||
stats Print database statistics and record counts in the terminal
|
||||
validate Perform a one-shot validation of all registered short link destinations
|
||||
create-admin Create a new administrator user in the database
|
||||
doctor Run database diagnostics and health checks
|
||||
shorten Shorten a URL (Feature 3)
|
||||
expand Expand a shortened code or custom slug to its destination URL (Feature 4)
|
||||
create-user Create a new standard user in the database
|
||||
delete-user Delete a standard user and all their databases/slugs
|
||||
disable-user Disable a standard user
|
||||
enable-user Enable a standard user
|
||||
reset-password Reset standard user's password
|
||||
list-users List all standard/system users
|
||||
backup-user Backup a standard user's databases to a .tar.zst package
|
||||
restore-user Restore a standard user's databases from a .tar.zst package
|
||||
admin-migrate FUTURE: Migrate legacy admin content to a specific admin tenant database
|
||||
repair Repair registry and database inconsistencies
|
||||
help Print this message or the help of the given subcommand(s)
|
||||
|
||||
Options:
|
||||
-h, --help Print help
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Server Operations
|
||||
|
||||
## Start Web Server
|
||||
|
||||
```bash
|
||||
bzod serve
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Backup & Recovery
|
||||
|
||||
## Full Backup
|
||||
|
||||
```bash
|
||||
bzod backup
|
||||
```
|
||||
|
||||
Creates a compressed backup archive containing:
|
||||
|
||||
* users.db
|
||||
* system.db
|
||||
* content databases
|
||||
* analytics databases
|
||||
* user directories
|
||||
|
||||
## Full Restore
|
||||
|
||||
```bash
|
||||
bzod restore backup.tar.gz
|
||||
```
|
||||
|
||||
Restores an entire BZOD installation from a backup archive.
|
||||
|
||||
---
|
||||
|
||||
# Database Operations
|
||||
|
||||
## Apply Migrations
|
||||
|
||||
```bash
|
||||
bzod migrate
|
||||
```
|
||||
|
||||
Applies any pending database migrations.
|
||||
|
||||
Safe to execute multiple times.
|
||||
|
||||
## Database Statistics
|
||||
|
||||
```bash
|
||||
bzod stats
|
||||
```
|
||||
|
||||
Displays database statistics, record counts, storage usage, and operational metrics.
|
||||
|
||||
---
|
||||
|
||||
# Validation & Diagnostics
|
||||
|
||||
## Validate Links
|
||||
|
||||
```bash
|
||||
bzod validate
|
||||
```
|
||||
|
||||
Checks all registered URLs and reports invalid destinations.
|
||||
|
||||
## Health Diagnostics
|
||||
|
||||
```bash
|
||||
bzod doctor
|
||||
```
|
||||
|
||||
Performs:
|
||||
|
||||
* SQLite integrity checks
|
||||
* WAL validation
|
||||
* Database availability checks
|
||||
* Storage verification
|
||||
* System health diagnostics
|
||||
* Global registry integrity validation
|
||||
|
||||
## Registry Repair
|
||||
|
||||
```bash
|
||||
bzod repair registry --dry-run
|
||||
```
|
||||
|
||||
Provides a transaction-safe repair utility for fixing global slug registry inconsistencies detected by `bzod doctor`.
|
||||
|
||||
* Use `--dry-run` to preview changes safely.
|
||||
* Use `--force` to execute changes and remove orphaned entries.
|
||||
* Use `--slug <slug>` to target a single missing entry.
|
||||
|
||||
---
|
||||
|
||||
# URL Management
|
||||
|
||||
## Create Short URL
|
||||
|
||||
```bash
|
||||
bzod shorten https://example.com
|
||||
```
|
||||
|
||||
## Expand Existing URL
|
||||
|
||||
```bash
|
||||
bzod expand abc123
|
||||
```
|
||||
|
||||
Returns the destination URL associated with the slug.
|
||||
|
||||
---
|
||||
|
||||
# Administrator Management
|
||||
|
||||
## Create Administrator
|
||||
|
||||
```bash
|
||||
bzod create-admin admin
|
||||
```
|
||||
|
||||
Creates a new administrator account.
|
||||
|
||||
---
|
||||
|
||||
# User Management
|
||||
|
||||
## List Users
|
||||
|
||||
```bash
|
||||
bzod list-users
|
||||
```
|
||||
|
||||
Displays all users in the platform.
|
||||
|
||||
## Create User
|
||||
|
||||
```bash
|
||||
bzod create-user alice
|
||||
```
|
||||
|
||||
Creates a new standard user.
|
||||
|
||||
## Disable User
|
||||
|
||||
```bash
|
||||
bzod disable-user alice
|
||||
```
|
||||
|
||||
Blocks login and invalidates sessions.
|
||||
|
||||
## Enable User
|
||||
|
||||
```bash
|
||||
bzod enable-user alice
|
||||
```
|
||||
|
||||
Re-enables a disabled user.
|
||||
|
||||
## Reset Password
|
||||
|
||||
```bash
|
||||
bzod reset-password alice
|
||||
```
|
||||
|
||||
Resets a user's password.
|
||||
|
||||
## Delete User
|
||||
|
||||
```bash
|
||||
bzod delete-user alice
|
||||
```
|
||||
|
||||
Deletes:
|
||||
|
||||
* User account
|
||||
* User databases
|
||||
* Sessions
|
||||
* API tokens
|
||||
* Slug ownership
|
||||
|
||||
---
|
||||
|
||||
# User Backup Operations
|
||||
|
||||
## Backup User
|
||||
|
||||
```bash
|
||||
bzod backup-user alice
|
||||
```
|
||||
|
||||
Creates a portable `.tar.zst` archive containing all user-owned data.
|
||||
|
||||
## Restore User
|
||||
|
||||
```bash
|
||||
bzod restore-user alice.tar.zst
|
||||
```
|
||||
|
||||
Restores a user from a previously generated archive.
|
||||
|
||||
---
|
||||
|
||||
# Recommended Maintenance
|
||||
|
||||
Daily:
|
||||
|
||||
```bash
|
||||
bzod doctor
|
||||
```
|
||||
|
||||
Weekly:
|
||||
|
||||
```bash
|
||||
bzod backup
|
||||
```
|
||||
|
||||
Before Upgrades:
|
||||
|
||||
```bash
|
||||
bzod backup
|
||||
bzod validate
|
||||
```
|
||||
|
||||
After Upgrades:
|
||||
|
||||
```bash
|
||||
bzod migrate
|
||||
bzod doctor
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Related Documentation
|
||||
|
||||
* INSTALL.md
|
||||
* MULTI_USER.md
|
||||
* ADMIN_GUIDE.md
|
||||
* BACKUP_RESTORE.md
|
||||
* SECURITY.md
|
||||
* API.md
|
||||
* ARCHITECTURE.md
|
||||
+303
-331
@@ -1,382 +1,354 @@
|
||||
# BZOD vs Other Self-Hosted URL Shorteners
|
||||
# BZOD v0.6.0 vs Self-Hosted URL Management Platforms
|
||||
|
||||
**BZOD (nx9-url-shortener)** is a modern, privacy-focused, self-hosted URL management platform built in Rust as part of the **NX9 Platform**.
|
||||
BZOD is a modern, privacy-focused, self-hosted URL Management Platform written in Rust and developed as part of the NX9 Platform.
|
||||
|
||||
Unlike many traditional URL shorteners that focus solely on redirects, BZOD combines:
|
||||
Unlike traditional URL shorteners that focus primarily on URL redirection, BZOD provides a complete platform for managing URLs, landing pages, analytics, users, permissions, backups, and operational workflows.
|
||||
|
||||
## Quick Comparison
|
||||
|
||||
| Feature | BZOD | Shlink | YOURLS | Chhoto URL |
|
||||
|--------------------------|------|--------|--------|------------|
|
||||
| Language | Rust | PHP | PHP | Rust |
|
||||
| Single Binary | ✅ | ❌ | ❌ | ✅ |
|
||||
| Landing Pages | ✅ | ❌ | Plugin | ❌ |
|
||||
| QR Code + Analytics | ✅ | Partial| Plugin | Partial |
|
||||
| Password Protection | ✅ | Limited| Plugin | ❌ |
|
||||
| Backup & Restore | ✅ | External| External| ❌ |
|
||||
| Audit Trail | ✅ | Limited| Plugin | ❌ |
|
||||
| CLI Tools | ✅ | Limited| Limited| Limited |
|
||||
| Dependencies | None | PHP + DB | PHP + DB | None |
|
||||
| Deployment Complexity | Low | Medium | High | Low |
|
||||
|
||||
---
|
||||
### Rust URL Shortener Comparison
|
||||
| Project | Language | Single Binary | Landing Pages | QR Codes + Analytics | Password Protection | Backup & Restore | CLI Tools | Audit Trail | Admin Dashboard | Notes |
|
||||
|----------------------|----------|---------------|---------------|----------------------|---------------------|------------------|-------------|-------------|-----------------|--------------------------------------------|
|
||||
| **BZOD** | Rust | ✅ (~11 MB) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | Feature-rich, multi-user ready, strong philosophy |
|
||||
| Chhoto URL | Rust | ✅ | ❌ | Partial | ❌ | ❌ | Limited | ❌ | Basic | Very minimal, smallest footprint |
|
||||
| smrs | Rust | ✅ | ❌ | ❌ | ❌ | ❌ | Limited | ❌ | Basic | Personal project, very simple |
|
||||
| urlshortener-rs | Rust | Library | N/A | N/A | N/A | N/A | N/A | N/A | N/A | Library, not full server |
|
||||
| Custom Rust | Rust | Varies | Varies | Varies | Varies | Varies | Varies | Varies | Varies | Usually minimal implementations |
|
||||
|
||||
# Executive Summary
|
||||
|
||||
BZOD combines:
|
||||
|
||||
* URL shortening
|
||||
* Landing pages
|
||||
* QR code generation
|
||||
* QR analytics
|
||||
* Password protection
|
||||
* Link analytics
|
||||
* Password-protected links
|
||||
* Link expiration
|
||||
* REST API
|
||||
* CLI automation
|
||||
* Backup & restore
|
||||
* Administrative dashboard
|
||||
* Multi-user operation
|
||||
* User management
|
||||
* User quotas
|
||||
* Session management
|
||||
* Audit logging
|
||||
|
||||
into a single lightweight deployment.
|
||||
|
||||
**Philosophy:** *One binary. One command. Full ownership.*
|
||||
|
||||
---
|
||||
|
||||
## At a Glance
|
||||
|
||||
* Rust-based
|
||||
* Single ~18 MB binary
|
||||
* Embedded SQLite
|
||||
* No external services required
|
||||
* Landing pages
|
||||
* QR generation & analytics
|
||||
* Password-protected links
|
||||
* REST API
|
||||
* CLI automation
|
||||
* Moderation
|
||||
* Backup & restore
|
||||
* Audit trail
|
||||
* MIT OR Apache-2.0 licensed
|
||||
* One-command deployment
|
||||
* Disaster recovery tooling
|
||||
|
||||
into a single Rust binary deployment.
|
||||
|
||||
---
|
||||
|
||||
## Quick Comparison
|
||||
# At a Glance
|
||||
|
||||
| Feature | BZOD | Shlink | YOURLS | Chhoto URL |
|
||||
| --------------------- | ----------------- | -------- | -------- | ---------- |
|
||||
| Language | Rust | PHP | PHP | Rust |
|
||||
| Single Binary | ✅ | ❌ | ❌ | ✅ |
|
||||
| Landing Pages | ✅ | ❌ | Plugin | ❌ |
|
||||
| QR Code + Analytics | ✅ | Partial | Plugin | Partial |
|
||||
| Password Protection | ✅ | Limited | Plugin | ❌ |
|
||||
| Backup & Restore | ✅ | External | External | ❌ |
|
||||
| Audit Trail | ✅ | Limited | Plugin | ❌ |
|
||||
| CLI Tools | ✅ | Limited | Limited | Limited |
|
||||
| Dependencies | None | PHP + DB | PHP + DB | None |
|
||||
| Deployment Complexity | Low | Medium | High | Low |
|
||||
| License | MIT OR Apache-2.0 | MIT | MIT | MIT |
|
||||
| Feature | BZOD |
|
||||
| -------------------- | ----------------- |
|
||||
| Language | Rust |
|
||||
| License | MIT OR Apache-2.0 |
|
||||
| Deployment | Single Binary |
|
||||
| Runtime Dependencies | None |
|
||||
| Database | SQLite |
|
||||
| Multi-User | Yes |
|
||||
| Landing Pages | Yes |
|
||||
| QR Codes | Yes |
|
||||
| Analytics | Yes |
|
||||
| REST API | Yes |
|
||||
| CLI Tools | Yes |
|
||||
| Backups | Built-in |
|
||||
| Audit Logs | Built-in |
|
||||
| RBAC | Built-in |
|
||||
|
||||
---
|
||||
|
||||
## Design Philosophy
|
||||
# What Changed in v0.5.0
|
||||
|
||||
| Principle | BZOD |
|
||||
| -------------------------- | ----------------- |
|
||||
| Self-hosted | ✅ |
|
||||
| Privacy-first | ✅ |
|
||||
| Open Source | MIT OR Apache-2.0 |
|
||||
| Vendor Lock-in | None |
|
||||
| Telemetry | None |
|
||||
| External Services Required | None |
|
||||
| Database Server Required | No (SQLite) |
|
||||
| Runtime Dependencies | None |
|
||||
| Single Binary | Yes (~18 MB) |
|
||||
| Linux-first | Yes |
|
||||
BZOD v0.5.0 introduces a major architectural evolution.
|
||||
|
||||
## New Platform Capabilities
|
||||
|
||||
* Multi-user architecture
|
||||
* Tenant isolation
|
||||
* Global slug namespace
|
||||
* User management
|
||||
* User quotas
|
||||
* Session management
|
||||
* Administrative dashboards
|
||||
* User self-service dashboards
|
||||
* Audit event logging
|
||||
* Moderation workflows
|
||||
* Backup management
|
||||
* Health monitoring
|
||||
* Upgrade framework
|
||||
* Migration tooling
|
||||
|
||||
BZOD is no longer merely a URL shortener.
|
||||
|
||||
It is now a self-hosted URL Management Platform.
|
||||
|
||||
---
|
||||
|
||||
## The NX9 Philosophy
|
||||
# Traditional URL Shortener Comparison
|
||||
|
||||
BZOD is part of the **NX9 Platform**.
|
||||
|
||||
NX9 projects follow strict engineering principles:
|
||||
|
||||
* Linux-native first
|
||||
* Rust-first
|
||||
* Single binary deployments
|
||||
* No NodeJS
|
||||
* No React
|
||||
* No Python runtime dependencies
|
||||
* No vendor lock-in
|
||||
* No telemetry
|
||||
* Privacy-first by default
|
||||
* MIT OR Apache-2.0 licensed
|
||||
|
||||
GitHub and Codeberg are source-code repositories, not the projects themselves.
|
||||
|
||||
The software is the project.
|
||||
|
||||
The goal of NX9 is simple:
|
||||
|
||||
> Build technology that serves people, organizations, communities, and governments — not advertising networks, data brokers, or vendor ecosystems.
|
||||
| Capability | BZOD | Shlink | YOURLS | Chhoto URL |
|
||||
| ------------------- | ---- | -------- | -------- | ---------- |
|
||||
| URL Shortening | ✅ | ✅ | ✅ | ✅ |
|
||||
| Landing Pages | ✅ | ❌ | Plugin | ❌ |
|
||||
| QR Generation | ✅ | Partial | Plugin | Partial |
|
||||
| QR Analytics | ✅ | Partial | Plugin | ❌ |
|
||||
| Password Protection | ✅ | Limited | Plugin | ❌ |
|
||||
| Link Expiration | ✅ | ✅ | Plugin | Limited |
|
||||
| REST API | ✅ | ✅ | ✅ | JSON-RPC |
|
||||
| Backup & Restore | ✅ | External | External | ❌ |
|
||||
| Audit Logs | ✅ | Limited | Plugin | ❌ |
|
||||
| Multi User | ✅ | Partial | Plugin | ❌ |
|
||||
| User Quotas | ✅ | ❌ | ❌ | ❌ |
|
||||
| User Isolation | ✅ | ❌ | ❌ | ❌ |
|
||||
| User Dashboards | ✅ | ❌ | ❌ | ❌ |
|
||||
|
||||
---
|
||||
|
||||
## Why BZOD Exists
|
||||
# Multi-User Platform Comparison
|
||||
|
||||
Most self-hosted URL shorteners optimize for one of two extremes:
|
||||
BZOD v0.5.0 introduces first-class multi-user support.
|
||||
|
||||
### 1. Minimal Redirect Service
|
||||
| Capability | BZOD |
|
||||
| ---------------------- | ---- |
|
||||
| User Accounts | ✅ |
|
||||
| Administrator Accounts | ✅ |
|
||||
| User Isolation | ✅ |
|
||||
| User Quotas | ✅ |
|
||||
| Session Management | ✅ |
|
||||
| API Tokens | ✅ |
|
||||
| Audit Trail | ✅ |
|
||||
| Moderation | ✅ |
|
||||
| Tenant Analytics | ✅ |
|
||||
| Self-Service Portal | ✅ |
|
||||
|
||||
A tiny application that creates short links and redirects traffic.
|
||||
Most self-hosted URL shorteners are fundamentally single-user applications.
|
||||
|
||||
Advantages:
|
||||
BZOD is designed for:
|
||||
|
||||
* Extremely lightweight
|
||||
* Easy to understand
|
||||
* Easy to maintain
|
||||
|
||||
Disadvantages:
|
||||
|
||||
* Limited administration
|
||||
* Limited analytics
|
||||
* Limited security features
|
||||
* Often requires additional tools
|
||||
|
||||
### 2. Large Multi-Service Platform
|
||||
|
||||
Feature-rich systems with extensive integrations and dependencies.
|
||||
|
||||
Advantages:
|
||||
|
||||
* Powerful analytics
|
||||
* Advanced routing
|
||||
* Large ecosystems
|
||||
|
||||
Disadvantages:
|
||||
|
||||
* More infrastructure
|
||||
* More maintenance
|
||||
* Higher resource requirements
|
||||
|
||||
### BZOD's Approach
|
||||
|
||||
BZOD intentionally sits in the middle.
|
||||
|
||||
It is:
|
||||
|
||||
* Small enough for a Raspberry Pi
|
||||
* Powerful enough for organizations
|
||||
* Simple enough for homelabs
|
||||
* Complete enough for production use
|
||||
* Individuals
|
||||
* Teams
|
||||
* Organizations
|
||||
* Educational Institutions
|
||||
* Governments
|
||||
* Service Providers
|
||||
|
||||
---
|
||||
|
||||
# BZOD vs Go URL Shorteners
|
||||
# Security Comparison
|
||||
|
||||
Popular Go projects include:
|
||||
| Security Feature | BZOD | Typical URL Shortener |
|
||||
| ------------------------- | ---- | --------------------- |
|
||||
| Argon2id Password Hashing | ✅ | Varies |
|
||||
| Session Management | ✅ | Basic |
|
||||
| CSRF Protection | ✅ | Varies |
|
||||
| RBAC | ✅ | Rare |
|
||||
| Audit Logging | ✅ | Rare |
|
||||
| User Disablement | ✅ | Rare |
|
||||
| Moderation Controls | ✅ | Rare |
|
||||
| Tenant Isolation | ✅ | Rare |
|
||||
| API Token Security | ✅ | Varies |
|
||||
|
||||
---
|
||||
|
||||
# Operations Comparison
|
||||
|
||||
| Operational Feature | BZOD |
|
||||
| ------------------- | ---- |
|
||||
| Backup Creation | ✅ |
|
||||
| Backup Restore | ✅ |
|
||||
| User Backup | ✅ |
|
||||
| User Restore | ✅ |
|
||||
| Disaster Recovery | ✅ |
|
||||
| Upgrade Validation | ✅ |
|
||||
| Health Monitoring | ✅ |
|
||||
| WAL Recovery | ✅ |
|
||||
| Migration Framework | ✅ |
|
||||
|
||||
Most competing products rely on external tooling for these capabilities.
|
||||
|
||||
---
|
||||
|
||||
# Deployment Comparison
|
||||
|
||||
| Requirement | BZOD | Shlink | YOURLS |
|
||||
| -------------------------- | ---- | -------- | -------- |
|
||||
| Single Binary | ✅ | ❌ | ❌ |
|
||||
| SQLite Only | ✅ | Optional | Optional |
|
||||
| External Database Required | ❌ | Usually | Usually |
|
||||
| Docker Support | ✅ | ✅ | ✅ |
|
||||
| Systemd Support | ✅ | Manual | Manual |
|
||||
| Backup Framework | ✅ | ❌ | ❌ |
|
||||
| Upgrade Framework | ✅ | ❌ | ❌ |
|
||||
|
||||
---
|
||||
|
||||
# BZOD vs Go-Based URL Shorteners
|
||||
|
||||
Popular Go alternatives include:
|
||||
|
||||
* Krtk
|
||||
* Slash
|
||||
* Goshorly
|
||||
* Slash
|
||||
* Shortr
|
||||
* Custom Gin/Echo implementations
|
||||
|
||||
## Detailed Comparison
|
||||
### Strengths of Go Projects
|
||||
|
||||
| Aspect | BZOD (Rust) | Typical Go Projects |
|
||||
| ------------------- | -------------------- | ------------------- |
|
||||
| Binary | Single ~18 MB binary | Usually 10–20 MB |
|
||||
| Runtime | None | None |
|
||||
| Database | Embedded SQLite | SQLite / PostgreSQL |
|
||||
| Landing Pages | ✅ Built-in | Rare |
|
||||
| QR Generation | ✅ | Sometimes |
|
||||
| QR Analytics | ✅ | Rare |
|
||||
| Password Protection | ✅ | Varies |
|
||||
| Link Expiry | ✅ | Often |
|
||||
| One-Time Links | ✅ | Rare |
|
||||
| UTM Builder | ✅ | Rare |
|
||||
| REST API | ✅ | Usually |
|
||||
| CLI | ✅ Extensive | Usually limited |
|
||||
| Backup & Restore | ✅ Built-in | Rare |
|
||||
| Audit Logs | ✅ | Rare |
|
||||
| Admin Dashboard | ✅ | Varies |
|
||||
* Small binaries
|
||||
* Excellent performance
|
||||
* Simple codebases
|
||||
|
||||
### Summary
|
||||
### Strengths of BZOD
|
||||
|
||||
Go shorteners are often:
|
||||
|
||||
* Extremely simple
|
||||
* Fast
|
||||
* Easy to extend
|
||||
|
||||
BZOD focuses on:
|
||||
|
||||
* Rich built-in functionality
|
||||
* Complete ownership
|
||||
* Minimal operations
|
||||
* Batteries-included deployment
|
||||
|
||||
---
|
||||
|
||||
# BZOD vs Python URL Shorteners
|
||||
|
||||
Popular Python projects include:
|
||||
|
||||
* Pygmy
|
||||
* ReducePy
|
||||
* Schort
|
||||
* Flask/FastAPI examples
|
||||
|
||||
## Detailed Comparison
|
||||
|
||||
| Aspect | BZOD (Rust) | Python Solutions |
|
||||
| ---------------- | --------------------- | ----------------- |
|
||||
| Runtime | None | Python required |
|
||||
| Deploy Size | ~18 MB | Often 100+ MB |
|
||||
| Memory Usage | Very Low | Moderate |
|
||||
| Landing Pages | ✅ | Rare |
|
||||
| QR Analytics | ✅ | Rare |
|
||||
| Backup & Restore | ✅ | Rare |
|
||||
| CLI Tools | ✅ | Limited |
|
||||
| Dashboard | ✅ | Varies |
|
||||
| Security | Argon2id + Audit Logs | Project dependent |
|
||||
| Performance | Excellent | Good |
|
||||
|
||||
### Summary
|
||||
|
||||
Python solutions are ideal when:
|
||||
|
||||
* Already using Python
|
||||
* Rapid prototyping
|
||||
* Easy customization
|
||||
|
||||
BZOD is ideal when:
|
||||
|
||||
* Long-term deployment matters
|
||||
* Resource efficiency matters
|
||||
* Minimal maintenance is desired
|
||||
|
||||
---
|
||||
|
||||
# BZOD vs Shlink
|
||||
|
||||
Shlink is one of the most mature self-hosted URL shorteners available.
|
||||
|
||||
## Detailed Comparison
|
||||
|
||||
| Aspect | BZOD | Shlink |
|
||||
| ------------------------ | ------------- | ---------------- |
|
||||
| Language | Rust | PHP |
|
||||
| Deployment | Single binary | PHP stack |
|
||||
| External DB | No | Usually yes |
|
||||
| Landing Pages | ✅ | ❌ |
|
||||
| Password Protected Links | ✅ | Limited |
|
||||
| QR Analytics | ✅ | Partial |
|
||||
| UTM Builder | ✅ | ❌ |
|
||||
| Backup & Restore | ✅ | External tooling |
|
||||
| Audit Trail | ✅ | Limited |
|
||||
| Dynamic Redirect Rules | ❌ | ✅ |
|
||||
| Multi-domain | Planned | ✅ |
|
||||
| Ecosystem | Growing | Mature |
|
||||
|
||||
### Summary
|
||||
|
||||
Choose Shlink when:
|
||||
|
||||
* Multi-domain management is critical
|
||||
* Dynamic redirect rules are required
|
||||
* Enterprise-scale analytics matter
|
||||
|
||||
Choose BZOD when:
|
||||
|
||||
* Simplicity matters
|
||||
* Privacy matters
|
||||
* Minimal infrastructure matters
|
||||
* Landing pages are important
|
||||
|
||||
---
|
||||
|
||||
# BZOD vs YOURLS
|
||||
|
||||
YOURLS is the classic self-hosted URL shortener.
|
||||
|
||||
## Detailed Comparison
|
||||
|
||||
| Aspect | BZOD | YOURLS |
|
||||
| ------------- | ------------- | ---------- |
|
||||
| Language | Rust | PHP |
|
||||
| Architecture | Single binary | LAMP stack |
|
||||
| Plugins | Not required | Extensive |
|
||||
| Landing Pages | ✅ | Plugin |
|
||||
| QR Analytics | ✅ | Plugin |
|
||||
| Audit Logs | ✅ | Plugin |
|
||||
| Backup Tools | ✅ | External |
|
||||
| API | ✅ | ✅ |
|
||||
| Maintenance | Minimal | Moderate |
|
||||
|
||||
### Summary
|
||||
|
||||
YOURLS wins on:
|
||||
|
||||
* Age
|
||||
* Community
|
||||
* Plugin ecosystem
|
||||
|
||||
BZOD wins on:
|
||||
|
||||
* Simplicity
|
||||
* Deployment
|
||||
* Modern architecture
|
||||
* Integrated features
|
||||
|
||||
---
|
||||
|
||||
# BZOD vs Chhoto URL
|
||||
|
||||
Chhoto URL is the closest Rust-based competitor.
|
||||
|
||||
## Detailed Comparison
|
||||
|
||||
| Aspect | BZOD | Chhoto URL |
|
||||
| ---------------- | ------ | ---------- |
|
||||
| Language | Rust | Rust |
|
||||
| Landing Pages | ✅ | ❌ |
|
||||
| QR Codes | ✅ | ✅ |
|
||||
| QR Analytics | ✅ | ❌ |
|
||||
| Password Links | ✅ | ❌ |
|
||||
| Backup & Restore | ✅ | ❌ |
|
||||
| REST API | ✅ | JSON-RPC |
|
||||
| Audit Logs | ✅ | ❌ |
|
||||
| Analytics | Rich | Basic |
|
||||
| Binary Size | ~18 MB | Smaller |
|
||||
|
||||
### Summary
|
||||
|
||||
Choose Chhoto URL for:
|
||||
|
||||
* Maximum simplicity
|
||||
* Minimal footprint
|
||||
|
||||
Choose BZOD for:
|
||||
|
||||
* Feature completeness
|
||||
* Better administration
|
||||
* Better analytics
|
||||
|
||||
---
|
||||
|
||||
## Future Comparisons
|
||||
|
||||
Additional comparison sections may be added in the future for:
|
||||
|
||||
* Bitly
|
||||
* Dub
|
||||
* Pygmy
|
||||
* Krtk
|
||||
* Other self-hosted URL management platforms
|
||||
|
||||
---
|
||||
|
||||
## Conclusion
|
||||
|
||||
**BZOD is not merely a URL shortener.**
|
||||
|
||||
It is a lightweight URL management platform that combines:
|
||||
|
||||
* Link shortening
|
||||
* Multi-user support
|
||||
* Landing pages
|
||||
* QR services
|
||||
* User management
|
||||
* Built-in analytics
|
||||
* Backup framework
|
||||
* Audit logging
|
||||
* Moderation
|
||||
* Administrative dashboards
|
||||
|
||||
---
|
||||
|
||||
# BZOD vs Python-Based Solutions
|
||||
|
||||
Examples:
|
||||
|
||||
* Pygmy
|
||||
* Schort
|
||||
* ReducePy
|
||||
* Flask-based projects
|
||||
* FastAPI-based projects
|
||||
|
||||
### Python Advantages
|
||||
|
||||
* Rapid development
|
||||
* Familiar ecosystem
|
||||
|
||||
### BZOD Advantages
|
||||
|
||||
* No runtime dependency
|
||||
* Lower memory consumption
|
||||
* Single binary deployment
|
||||
* Operational tooling included
|
||||
* Better long-term maintenance characteristics
|
||||
|
||||
---
|
||||
|
||||
# Reliability & Testing
|
||||
|
||||
BZOD v0.5.0 includes a comprehensive automated validation suite.
|
||||
|
||||
Coverage includes:
|
||||
|
||||
* Unit tests
|
||||
* Integration tests
|
||||
* HTTP E2E tests
|
||||
* Business workflow tests
|
||||
* Upgrade validation tests
|
||||
* Backup/restore tests
|
||||
* Disaster recovery tests
|
||||
* Security tests
|
||||
* Concurrency tests
|
||||
* WAL recovery tests
|
||||
|
||||
The platform is validated using more than 90 automated tests.
|
||||
|
||||
---
|
||||
|
||||
# NX9 Platform Philosophy
|
||||
|
||||
BZOD follows the NX9 engineering philosophy:
|
||||
|
||||
* Linux-first
|
||||
* Rust-first
|
||||
* Self-hosted
|
||||
* Privacy-first
|
||||
* No telemetry
|
||||
* No vendor lock-in
|
||||
* No external dependencies
|
||||
* Single binary deployment
|
||||
|
||||
The goal is simple:
|
||||
|
||||
> Build software that remains useful, understandable, maintainable, and deployable decades into the future.
|
||||
|
||||
---
|
||||
|
||||
# Who Should Use BZOD?
|
||||
|
||||
BZOD is suitable for:
|
||||
|
||||
### Individuals
|
||||
|
||||
* Personal URL management
|
||||
* Homelabs
|
||||
* Self-hosted services
|
||||
|
||||
### Organizations
|
||||
|
||||
* Marketing campaigns
|
||||
* Internal redirects
|
||||
* Landing page hosting
|
||||
|
||||
### Governments
|
||||
|
||||
* Public service redirects
|
||||
* Long-term link preservation
|
||||
* Controlled infrastructure
|
||||
|
||||
### Service Providers
|
||||
|
||||
* Multi-tenant URL management
|
||||
* Managed short-link services
|
||||
* White-label deployments
|
||||
|
||||
---
|
||||
|
||||
# Conclusion
|
||||
|
||||
BZOD v0.5.0 is not simply a URL shortener.
|
||||
|
||||
It is a self-hosted URL Management Platform providing:
|
||||
|
||||
* Multi-user operation
|
||||
* Tenant isolation
|
||||
* URL shortening
|
||||
* Landing pages
|
||||
* QR generation
|
||||
* Analytics
|
||||
* Automation
|
||||
* Security
|
||||
* Backups
|
||||
* Audit logging
|
||||
* Moderation
|
||||
* User administration
|
||||
* Backup & restore
|
||||
* Health monitoring
|
||||
|
||||
into a single deployable Rust binary.
|
||||
within a single Rust binary deployment.
|
||||
|
||||
As part of the NX9 Platform, BZOD follows a simple principle:
|
||||
BZOD is designed for individuals, organizations, governments, educational institutions, and service providers that require full ownership of their links, analytics, and infrastructure.
|
||||
|
||||
> Own your links. Own your data. Own your infrastructure.
|
||||
> Own your links.
|
||||
> Own your data.
|
||||
> Own your infrastructure.
|
||||
|
||||
No telemetry. No vendor lock-in. No unnecessary complexity.
|
||||
|
||||
For users seeking privacy, simplicity, ownership, and long-term sustainability, BZOD offers a compelling alternative to both cloud SaaS platforms and traditional self-hosted URL shorteners.
|
||||
@@ -0,0 +1,503 @@
|
||||
# DATABASES.md
|
||||
|
||||
# BZOD Database Architecture
|
||||
|
||||
BZOD v0.6.0 uses SQLite exclusively.
|
||||
|
||||
Rather than using a single monolithic database, BZOD separates data into administrative and tenant-specific databases. This architecture improves security, isolation, backup flexibility, disaster recovery, and scalability.
|
||||
|
||||
---
|
||||
|
||||
# Overview
|
||||
|
||||
BZOD stores data in the following structure:
|
||||
|
||||
```text
|
||||
data/
|
||||
├── admin/
|
||||
│ ├── admin.db
|
||||
│ ├── system.db
|
||||
│ └── users.db
|
||||
│
|
||||
└── users/
|
||||
├── 1/
|
||||
│ ├── analytics.db
|
||||
│ ├── content.db
|
||||
│ └── profile.db
|
||||
│
|
||||
├── 2/
|
||||
│ ├── analytics.db
|
||||
│ ├── content.db
|
||||
│ └── profile.db
|
||||
│
|
||||
└── N/
|
||||
├── analytics.db
|
||||
├── content.db
|
||||
└── profile.db
|
||||
```
|
||||
|
||||
Each user receives isolated databases.
|
||||
|
||||
No user content or analytics are stored in the central administrative databases.
|
||||
|
||||
---
|
||||
|
||||
# Administrative Databases
|
||||
|
||||
Administrative databases are located under:
|
||||
|
||||
```text
|
||||
data/admin/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# users.db
|
||||
|
||||
Primary authentication and user management database.
|
||||
|
||||
Purpose:
|
||||
|
||||
* User accounts
|
||||
* Password hashes
|
||||
* Sessions
|
||||
* Quotas
|
||||
* API tokens
|
||||
* User status tracking
|
||||
|
||||
Typical tables:
|
||||
|
||||
```text
|
||||
users
|
||||
sessions
|
||||
quotas
|
||||
api_tokens
|
||||
```
|
||||
|
||||
Responsibilities:
|
||||
|
||||
* Authentication
|
||||
* Authorization
|
||||
* Session management
|
||||
* Account status
|
||||
* Quota enforcement
|
||||
|
||||
This is the primary identity database of the platform.
|
||||
|
||||
---
|
||||
|
||||
# system.db
|
||||
|
||||
Global platform database.
|
||||
|
||||
Purpose:
|
||||
|
||||
* Global slug namespace
|
||||
* Moderation
|
||||
* Auditing
|
||||
* System configuration
|
||||
|
||||
Typical tables:
|
||||
|
||||
```text
|
||||
global_slugs
|
||||
slug_history
|
||||
moderation_events
|
||||
audit_events
|
||||
reserved_slugs
|
||||
settings
|
||||
```
|
||||
|
||||
Responsibilities:
|
||||
|
||||
* Global slug uniqueness
|
||||
* Slug ownership
|
||||
* Moderation actions
|
||||
* Audit logging
|
||||
* System settings
|
||||
|
||||
Every redirect ultimately resolves through records stored in this database.
|
||||
|
||||
---
|
||||
|
||||
# admin.db
|
||||
|
||||
Administrative application database.
|
||||
|
||||
Purpose:
|
||||
|
||||
* Administrative metadata
|
||||
* Administrative API key records
|
||||
* Legacy compatibility structures
|
||||
* Internal management data
|
||||
|
||||
Typical tables:
|
||||
|
||||
```text
|
||||
api_keys
|
||||
audit_events
|
||||
```
|
||||
|
||||
This database is reserved for administrative functions and does not store tenant content.
|
||||
|
||||
---
|
||||
|
||||
# Tenant Databases
|
||||
|
||||
Tenant databases are located under:
|
||||
|
||||
```text
|
||||
data/users/{user_id}/
|
||||
```
|
||||
|
||||
Each user owns a completely isolated set of databases.
|
||||
|
||||
Example:
|
||||
|
||||
```text
|
||||
data/users/2/
|
||||
├── analytics.db
|
||||
├── content.db
|
||||
└── profile.db
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# content.db
|
||||
|
||||
Stores user-owned content.
|
||||
|
||||
Purpose:
|
||||
|
||||
* Short URLs
|
||||
* Landing pages
|
||||
* QR metadata
|
||||
* Preview metadata
|
||||
|
||||
Typical tables:
|
||||
|
||||
```text
|
||||
urls
|
||||
pages
|
||||
qr_codes
|
||||
previews
|
||||
```
|
||||
|
||||
Responsibilities:
|
||||
|
||||
* URL management
|
||||
* Landing page management
|
||||
* Content ownership
|
||||
|
||||
This database contains the actual resources owned by a user.
|
||||
|
||||
---
|
||||
|
||||
# analytics.db
|
||||
|
||||
Stores traffic and visitor information.
|
||||
|
||||
Purpose:
|
||||
|
||||
* Visit recording
|
||||
* Referrer tracking
|
||||
* Browser tracking
|
||||
* Country statistics
|
||||
* Aggregated analytics
|
||||
|
||||
Typical tables:
|
||||
|
||||
```text
|
||||
visits
|
||||
referrers
|
||||
browsers
|
||||
countries
|
||||
daily_stats
|
||||
```
|
||||
|
||||
Responsibilities:
|
||||
|
||||
* Analytics collection
|
||||
* Reporting
|
||||
* Dashboard statistics
|
||||
|
||||
Analytics are fully isolated per user.
|
||||
|
||||
Administrators access aggregated analytics by querying each user's analytics database.
|
||||
|
||||
---
|
||||
|
||||
# profile.db
|
||||
|
||||
Stores user-specific profile information.
|
||||
|
||||
Purpose:
|
||||
|
||||
* User preferences
|
||||
* Profile settings
|
||||
* Future extensible metadata
|
||||
|
||||
Typical tables:
|
||||
|
||||
```text
|
||||
profile
|
||||
preferences
|
||||
```
|
||||
|
||||
Responsibilities:
|
||||
|
||||
* User profile management
|
||||
* Dashboard preferences
|
||||
* Future personalization features
|
||||
|
||||
---
|
||||
|
||||
# Database Isolation Model
|
||||
|
||||
BZOD follows a strict tenant isolation model.
|
||||
|
||||
```text
|
||||
User A
|
||||
├── content.db
|
||||
├── analytics.db
|
||||
└── profile.db
|
||||
|
||||
User B
|
||||
├── content.db
|
||||
├── analytics.db
|
||||
└── profile.db
|
||||
```
|
||||
|
||||
User databases never share tables.
|
||||
|
||||
Cross-user content access is prevented by design.
|
||||
|
||||
Benefits:
|
||||
|
||||
* Security
|
||||
* Easier backups
|
||||
* Easier deletion
|
||||
* Reduced corruption impact
|
||||
|
||||
---
|
||||
|
||||
# Global Slug Registry
|
||||
|
||||
The system maintains a single namespace.
|
||||
|
||||
Stored in:
|
||||
|
||||
```text
|
||||
system.db
|
||||
```
|
||||
|
||||
Table:
|
||||
|
||||
```text
|
||||
global_slugs
|
||||
```
|
||||
|
||||
Example:
|
||||
|
||||
```text
|
||||
abc123 → User 2 URL
|
||||
docs → User 5 Page
|
||||
demo → User 1 URL
|
||||
```
|
||||
|
||||
This guarantees:
|
||||
|
||||
* Global uniqueness
|
||||
* Ownership tracking
|
||||
* Moderation support
|
||||
* Slug transfer support
|
||||
|
||||
---
|
||||
|
||||
# Write Flow
|
||||
|
||||
Creating a URL:
|
||||
|
||||
```text
|
||||
1. Validate quota
|
||||
2. Register slug in system.db
|
||||
3. Create URL in content.db
|
||||
4. Update quota counters
|
||||
5. Write audit event
|
||||
```
|
||||
|
||||
Creating a landing page:
|
||||
|
||||
```text
|
||||
1. Validate quota
|
||||
2. Register slug in system.db
|
||||
3. Create page in content.db
|
||||
4. Update quota counters
|
||||
5. Write audit event
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Analytics Flow
|
||||
|
||||
Visitor request:
|
||||
|
||||
```text
|
||||
GET /abc123
|
||||
```
|
||||
|
||||
Process:
|
||||
|
||||
```text
|
||||
global_slugs
|
||||
↓
|
||||
content.db lookup
|
||||
↓
|
||||
redirect
|
||||
↓
|
||||
analytics.db visit record
|
||||
```
|
||||
|
||||
Analytics writes never modify content records.
|
||||
|
||||
---
|
||||
|
||||
# WAL Mode
|
||||
|
||||
All databases operate in SQLite WAL mode.
|
||||
|
||||
Verify:
|
||||
|
||||
```sql
|
||||
PRAGMA journal_mode;
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
```text
|
||||
wal
|
||||
```
|
||||
|
||||
Benefits:
|
||||
|
||||
* Improved concurrency
|
||||
* Reduced write contention
|
||||
* Crash recovery
|
||||
|
||||
Associated files:
|
||||
|
||||
```text
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# WAL Checkpointing
|
||||
|
||||
Large WAL files are normal during heavy traffic.
|
||||
|
||||
Example:
|
||||
|
||||
```text
|
||||
analytics.db-wal
|
||||
content.db-wal
|
||||
```
|
||||
|
||||
To manually checkpoint:
|
||||
|
||||
```sql
|
||||
PRAGMA wal_checkpoint(TRUNCATE);
|
||||
```
|
||||
|
||||
The healthcheck and backup jobs may trigger checkpoints automatically.
|
||||
|
||||
---
|
||||
|
||||
# Backups
|
||||
|
||||
Recommended:
|
||||
|
||||
```bash
|
||||
bzod backup
|
||||
```
|
||||
|
||||
This creates a consistent archive of:
|
||||
|
||||
```text
|
||||
admin/
|
||||
users/
|
||||
```
|
||||
|
||||
Never manually copy live databases while the application is running.
|
||||
|
||||
---
|
||||
|
||||
# Integrity Verification
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
bzod doctor
|
||||
```
|
||||
|
||||
Or:
|
||||
|
||||
```sql
|
||||
PRAGMA integrity_check;
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
```text
|
||||
ok
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Migration System
|
||||
|
||||
BZOD maintains schema versions using:
|
||||
|
||||
```sql
|
||||
PRAGMA user_version;
|
||||
```
|
||||
|
||||
Startup automatically executes:
|
||||
|
||||
```text
|
||||
Db::init()
|
||||
```
|
||||
|
||||
which:
|
||||
|
||||
1. Creates missing databases
|
||||
2. Applies migrations
|
||||
3. Validates schemas
|
||||
4. Repairs legacy installations when required
|
||||
|
||||
---
|
||||
|
||||
# Design Principles
|
||||
|
||||
BZOD database architecture prioritizes:
|
||||
|
||||
* SQLite-only deployment
|
||||
* Multi-user isolation
|
||||
* Operational simplicity
|
||||
* Backup friendliness
|
||||
* Easy disaster recovery
|
||||
* Minimal dependencies
|
||||
* Single-binary deployment
|
||||
|
||||
---
|
||||
|
||||
# Related Documentation
|
||||
|
||||
* ARCHITECTURE.md
|
||||
* MULTI_USER.md
|
||||
* BACKUP_RESTORE.md
|
||||
* INSTALL.md
|
||||
* UPGRADE.md
|
||||
* SECURITY.md
|
||||
+604
@@ -0,0 +1,604 @@
|
||||
# BZOD Installation Guide
|
||||
|
||||
Version: v0.6.0
|
||||
|
||||
---
|
||||
|
||||
# Introduction
|
||||
|
||||
BZOD is a self-hosted multi-user URL management platform written in Rust.
|
||||
|
||||
Features include:
|
||||
|
||||
* URL shortening
|
||||
* Landing pages
|
||||
* QR code generation
|
||||
* Analytics
|
||||
* User management
|
||||
* Audit logging
|
||||
* Moderation
|
||||
* Backup & restore
|
||||
* Disaster recovery
|
||||
|
||||
BZOD is distributed as a single executable and uses SQLite databases for storage.
|
||||
|
||||
No PostgreSQL, MySQL, Redis, Elasticsearch, or external services are required.
|
||||
|
||||
---
|
||||
|
||||
# Installation Methods
|
||||
|
||||
BZOD supports three deployment methods:
|
||||
|
||||
| Method | Recommended For |
|
||||
| -------------- | ---------------- |
|
||||
| Docker Compose | Most deployments |
|
||||
| Native Binary | Linux servers |
|
||||
| Source Build | Development |
|
||||
|
||||
---
|
||||
|
||||
# System Requirements
|
||||
|
||||
## Minimum
|
||||
|
||||
| Component | Requirement |
|
||||
| --------- | ------------ |
|
||||
| CPU | 1 Core |
|
||||
| Memory | 512 MB |
|
||||
| Storage | 1 GB |
|
||||
| OS | Linux x86_64 |
|
||||
|
||||
## Recommended
|
||||
|
||||
| Component | Requirement |
|
||||
| --------- | ------------------------ |
|
||||
| CPU | 2+ Cores |
|
||||
| Memory | 2 GB |
|
||||
| Storage | 10+ GB SSD |
|
||||
| OS | Debian 12 / Ubuntu 24.04 |
|
||||
|
||||
## Tested Platforms
|
||||
|
||||
* Debian 12 Bookworm
|
||||
* Ubuntu 22.04
|
||||
* Ubuntu 24.04
|
||||
* Arch Linux
|
||||
* Docker
|
||||
* CasaOS
|
||||
|
||||
---
|
||||
|
||||
# Installation Using Docker
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Install:
|
||||
|
||||
```bash
|
||||
docker
|
||||
docker compose
|
||||
```
|
||||
|
||||
Verify:
|
||||
|
||||
```bash
|
||||
docker --version
|
||||
docker compose version
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Create Directory
|
||||
|
||||
```bash
|
||||
mkdir -p /opt/bzod
|
||||
cd /opt/bzod
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Copy Files
|
||||
|
||||
Required:
|
||||
|
||||
```text
|
||||
docker-compose.yml
|
||||
Dockerfile
|
||||
```
|
||||
|
||||
Optional:
|
||||
|
||||
```text
|
||||
bzod.service
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Start Container
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Verify:
|
||||
|
||||
```bash
|
||||
docker compose ps
|
||||
```
|
||||
|
||||
View logs:
|
||||
|
||||
```bash
|
||||
docker compose logs -f
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Stop Container
|
||||
|
||||
```bash
|
||||
docker compose down
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Restart Container
|
||||
|
||||
```bash
|
||||
docker compose restart
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Native Installation
|
||||
|
||||
## Install Dependencies
|
||||
|
||||
### Debian / Ubuntu
|
||||
|
||||
```bash
|
||||
sudo apt update
|
||||
|
||||
sudo apt install -y \
|
||||
build-essential \
|
||||
pkg-config \
|
||||
libssl-dev \
|
||||
sqlite3
|
||||
```
|
||||
|
||||
### Arch Linux
|
||||
|
||||
```bash
|
||||
sudo pacman -S \
|
||||
base-devel \
|
||||
openssl \
|
||||
sqlite
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Download Release Binary
|
||||
|
||||
Example:
|
||||
|
||||
```bash
|
||||
wget https://example.com/bzod-v0.6.0-linux-amd64.tar.gz
|
||||
```
|
||||
|
||||
Extract:
|
||||
|
||||
```bash
|
||||
tar -xzf bzod-v0.6.0-linux-amd64.tar.gz
|
||||
```
|
||||
|
||||
Install:
|
||||
|
||||
```bash
|
||||
sudo install -m755 bzod /usr/local/bin/bzod
|
||||
```
|
||||
|
||||
Verify:
|
||||
|
||||
```bash
|
||||
bzod --help
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Build From Source
|
||||
|
||||
## Install Rust
|
||||
|
||||
```bash
|
||||
curl https://sh.rustup.rs -sSf | sh
|
||||
```
|
||||
|
||||
Verify:
|
||||
|
||||
```bash
|
||||
cargo --version
|
||||
rustc --version
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Clone Repository
|
||||
|
||||
```bash
|
||||
git clone https://github.com/thakares/nx9-url-shortener.git
|
||||
|
||||
cd nx9-url-shortener
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Build
|
||||
|
||||
Development:
|
||||
|
||||
```bash
|
||||
cargo build
|
||||
```
|
||||
|
||||
Release:
|
||||
|
||||
```bash
|
||||
cargo build --release
|
||||
```
|
||||
|
||||
Binary:
|
||||
|
||||
```bash
|
||||
target/release/bzod
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Data Directory
|
||||
|
||||
BZOD automatically creates its databases on first startup.
|
||||
|
||||
Default structure:
|
||||
|
||||
```text
|
||||
data/
|
||||
├── users.db
|
||||
├── system.db
|
||||
│
|
||||
├── admin/
|
||||
│ ├── content.db
|
||||
│ └── analytics.db
|
||||
│
|
||||
└── users/
|
||||
└── ...
|
||||
```
|
||||
|
||||
Do not manually modify database files while BZOD is running.
|
||||
|
||||
---
|
||||
|
||||
# First Startup
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
bzod serve
|
||||
```
|
||||
|
||||
By default:
|
||||
|
||||
```text
|
||||
http://localhost:8080
|
||||
```
|
||||
|
||||
Open:
|
||||
|
||||
```text
|
||||
http://localhost:8080
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Bootstrap Administrator
|
||||
|
||||
On a fresh installation:
|
||||
|
||||
1. Open Login page
|
||||
2. Use bootstrap credentials
|
||||
3. Create the first administrator account
|
||||
4. Save the credentials securely
|
||||
|
||||
After bootstrap:
|
||||
|
||||
* Bootstrap mode is disabled
|
||||
* Normal authentication is enforced
|
||||
|
||||
---
|
||||
|
||||
# Create Administrator Using CLI
|
||||
|
||||
Alternative method:
|
||||
|
||||
```bash
|
||||
bzod create-admin
|
||||
```
|
||||
|
||||
Follow prompts:
|
||||
|
||||
```text
|
||||
Username:
|
||||
Password:
|
||||
```
|
||||
|
||||
The administrator account is stored in:
|
||||
|
||||
```text
|
||||
users.db
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Reverse Proxy Configuration
|
||||
|
||||
Using Nginx is recommended.
|
||||
|
||||
Example:
|
||||
|
||||
```nginx
|
||||
server {
|
||||
server_name bzod.example.com;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8080;
|
||||
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Reload:
|
||||
|
||||
```bash
|
||||
sudo nginx -t
|
||||
sudo systemctl reload nginx
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# HTTPS
|
||||
|
||||
Recommended options:
|
||||
|
||||
* Let's Encrypt
|
||||
* Nginx Proxy Manager
|
||||
* Caddy
|
||||
* Traefik
|
||||
|
||||
Always use HTTPS in production.
|
||||
|
||||
---
|
||||
|
||||
# Running as Systemd Service
|
||||
|
||||
Install binary:
|
||||
|
||||
```bash
|
||||
sudo install -m755 bzod /usr/local/bin/bzod
|
||||
```
|
||||
|
||||
Copy service:
|
||||
|
||||
```bash
|
||||
sudo cp bzod.service /etc/systemd/system/
|
||||
```
|
||||
|
||||
Reload:
|
||||
|
||||
```bash
|
||||
sudo systemctl daemon-reload
|
||||
```
|
||||
|
||||
Enable:
|
||||
|
||||
```bash
|
||||
sudo systemctl enable bzod
|
||||
```
|
||||
|
||||
Start:
|
||||
|
||||
```bash
|
||||
sudo systemctl start bzod
|
||||
```
|
||||
|
||||
Status:
|
||||
|
||||
```bash
|
||||
sudo systemctl status bzod
|
||||
```
|
||||
|
||||
Logs:
|
||||
|
||||
```bash
|
||||
journalctl -u bzod -f
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Firewall
|
||||
|
||||
Open HTTP:
|
||||
|
||||
```bash
|
||||
sudo ufw allow 8080/tcp
|
||||
```
|
||||
|
||||
HTTPS:
|
||||
|
||||
```bash
|
||||
sudo ufw allow 443/tcp
|
||||
```
|
||||
|
||||
HTTP:
|
||||
|
||||
```bash
|
||||
sudo ufw allow 80/tcp
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Health Verification
|
||||
|
||||
Open:
|
||||
|
||||
```text
|
||||
http://localhost:8080
|
||||
```
|
||||
|
||||
Login as administrator.
|
||||
|
||||
Verify:
|
||||
|
||||
* Dashboard loads
|
||||
* User list loads
|
||||
* URL creation works
|
||||
* Landing pages work
|
||||
* QR generation works
|
||||
* Analytics record visits
|
||||
|
||||
---
|
||||
|
||||
# Upgrade Procedure
|
||||
|
||||
Always backup before upgrading.
|
||||
|
||||
Create backup:
|
||||
|
||||
```bash
|
||||
bzod backup
|
||||
```
|
||||
|
||||
Stop service:
|
||||
|
||||
```bash
|
||||
sudo systemctl stop bzod
|
||||
```
|
||||
|
||||
Replace binary.
|
||||
|
||||
Run migrations:
|
||||
|
||||
```bash
|
||||
bzod migrate
|
||||
```
|
||||
|
||||
Start service:
|
||||
|
||||
```bash
|
||||
sudo systemctl start bzod
|
||||
```
|
||||
|
||||
Verify logs.
|
||||
|
||||
See:
|
||||
|
||||
```text
|
||||
docs/UPGRADE.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Troubleshooting
|
||||
|
||||
## Port Already In Use
|
||||
|
||||
Check:
|
||||
|
||||
```bash
|
||||
ss -tulpn | grep 8080
|
||||
```
|
||||
|
||||
Change port or stop conflicting service.
|
||||
|
||||
---
|
||||
|
||||
## Database Locked
|
||||
|
||||
Verify only one BZOD instance is running:
|
||||
|
||||
```bash
|
||||
ps aux | grep bzod
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Permission Errors
|
||||
|
||||
Verify ownership:
|
||||
|
||||
```bash
|
||||
chown -R bzod:bzod data/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Login Problems
|
||||
|
||||
Verify:
|
||||
|
||||
* Administrator account exists
|
||||
* Session cookies enabled
|
||||
* System clock is correct
|
||||
|
||||
---
|
||||
|
||||
## View Logs
|
||||
|
||||
Systemd:
|
||||
|
||||
```bash
|
||||
journalctl -u bzod -f
|
||||
```
|
||||
|
||||
Docker:
|
||||
|
||||
```bash
|
||||
docker compose logs -f
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Next Steps
|
||||
|
||||
After installation:
|
||||
|
||||
1. Read `MULTI_USER.md`
|
||||
2. Read `ADMIN_GUIDE.md`
|
||||
3. Configure backups
|
||||
4. Configure HTTPS
|
||||
5. Create additional users
|
||||
6. Verify restore procedures
|
||||
|
||||
---
|
||||
|
||||
# Additional Documentation
|
||||
|
||||
| File | Purpose |
|
||||
| ----------------- | ------------------------ |
|
||||
| ARCHITECTURE.md | System architecture |
|
||||
| MULTI_USER.md | Multi-user design |
|
||||
| ADMIN_GUIDE.md | Administrative workflows |
|
||||
| BACKUP_RESTORE.md | Backup procedures |
|
||||
| SECURITY.md | Security model |
|
||||
| CLI.md | Command reference |
|
||||
| API.md | REST API reference |
|
||||
| UPGRADE.md | Upgrade instructions |
|
||||
|
||||
---
|
||||
|
||||
End of Document.
|
||||
@@ -0,0 +1,732 @@
|
||||
# BZOD Multi-User Architecture Guide
|
||||
|
||||
Version: v0.6.0
|
||||
|
||||
---
|
||||
|
||||
# Introduction
|
||||
|
||||
BZOD v0.5.0 introduces a complete multi-user architecture that transforms BZOD from a single-tenant URL shortener into a secure, isolated, self-hosted multi-user platform.
|
||||
|
||||
Each user receives logically isolated content and analytics storage while sharing a common authentication, administration, moderation, and routing infrastructure.
|
||||
|
||||
This document explains the architecture, database layout, ownership model, security boundaries, quotas, slug management, and administrative workflows.
|
||||
|
||||
---
|
||||
|
||||
# Design Goals
|
||||
|
||||
The multi-user architecture was designed around the following principles:
|
||||
|
||||
* Strong tenant isolation
|
||||
* Single binary deployment
|
||||
* SQLite-only operation
|
||||
* Minimal operational complexity
|
||||
* No external services required
|
||||
* Global slug namespace
|
||||
* Centralized administration
|
||||
* Disaster recovery support
|
||||
* Simple backup and restore workflows
|
||||
|
||||
---
|
||||
|
||||
# User Types
|
||||
|
||||
BZOD supports the following account types.
|
||||
|
||||
## Administrator
|
||||
|
||||
Administrators can:
|
||||
|
||||
* Access the administrative dashboard
|
||||
* Create users
|
||||
* Delete users
|
||||
* Reset passwords
|
||||
* Manage quotas
|
||||
* Transfer ownership
|
||||
* Moderate content
|
||||
* Manage backups
|
||||
* Access health dashboards
|
||||
* Access audit logs
|
||||
|
||||
Administrators cannot bypass database isolation.
|
||||
|
||||
---
|
||||
|
||||
## Standard User
|
||||
|
||||
Standard users can:
|
||||
|
||||
* Create short URLs
|
||||
* Create landing pages
|
||||
* View analytics
|
||||
* Generate QR codes
|
||||
* Manage API tokens
|
||||
* Update passwords
|
||||
|
||||
Standard users cannot:
|
||||
|
||||
* Access other user content
|
||||
* Access administrative functions
|
||||
* Access system settings
|
||||
|
||||
---
|
||||
|
||||
## System Accounts
|
||||
|
||||
System accounts are reserved for internal operations.
|
||||
|
||||
They cannot authenticate into the dashboard.
|
||||
|
||||
---
|
||||
|
||||
# Database Architecture
|
||||
|
||||
BZOD uses multiple SQLite databases.
|
||||
|
||||
## users.db
|
||||
|
||||
Central identity store.
|
||||
|
||||
Contains:
|
||||
|
||||
```text
|
||||
users
|
||||
sessions
|
||||
quotas
|
||||
api_tokens
|
||||
```
|
||||
|
||||
Responsibilities:
|
||||
|
||||
* Authentication
|
||||
* Session management
|
||||
* Password verification
|
||||
* User status management
|
||||
* Quota tracking
|
||||
|
||||
---
|
||||
|
||||
## system.db
|
||||
|
||||
Global platform database.
|
||||
|
||||
Contains:
|
||||
|
||||
```text
|
||||
global_slugs
|
||||
slug_history
|
||||
moderation_events
|
||||
audit_events
|
||||
settings
|
||||
reserved_slugs
|
||||
```
|
||||
|
||||
Responsibilities:
|
||||
|
||||
* Slug ownership
|
||||
* Moderation
|
||||
* Audit logging
|
||||
* Global settings
|
||||
* System metadata
|
||||
|
||||
---
|
||||
|
||||
## Tenant Databases
|
||||
|
||||
Every tenant owns independent databases.
|
||||
|
||||
Example:
|
||||
|
||||
```text
|
||||
users/
|
||||
└── 15/
|
||||
├── content.db
|
||||
└── analytics.db
|
||||
```
|
||||
|
||||
Responsibilities:
|
||||
|
||||
### content.db
|
||||
|
||||
Stores:
|
||||
|
||||
```text
|
||||
urls
|
||||
pages
|
||||
qr_metadata
|
||||
previews
|
||||
```
|
||||
|
||||
### analytics.db
|
||||
|
||||
Stores:
|
||||
|
||||
```text
|
||||
visits
|
||||
aggregates
|
||||
referrers
|
||||
browsers
|
||||
countries
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Directory Structure
|
||||
|
||||
Example installation:
|
||||
|
||||
```text
|
||||
data/
|
||||
├── users.db
|
||||
├── system.db
|
||||
│
|
||||
├── admin/
|
||||
│ ├── content.db
|
||||
│ └── analytics.db
|
||||
│
|
||||
└── users/
|
||||
├── 2/
|
||||
│ ├── content.db
|
||||
│ └── analytics.db
|
||||
│
|
||||
├── 3/
|
||||
│ ├── content.db
|
||||
│ └── analytics.db
|
||||
│
|
||||
└── 4/
|
||||
├── content.db
|
||||
└── analytics.db
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Global Slug Namespace
|
||||
|
||||
BZOD uses a platform-wide namespace.
|
||||
|
||||
A slug can only exist once.
|
||||
|
||||
Examples:
|
||||
|
||||
```text
|
||||
/company
|
||||
/about
|
||||
/docs
|
||||
```
|
||||
|
||||
If User A owns:
|
||||
|
||||
```text
|
||||
/company
|
||||
```
|
||||
|
||||
User B cannot create:
|
||||
|
||||
```text
|
||||
/company
|
||||
```
|
||||
|
||||
The operation is rejected.
|
||||
|
||||
---
|
||||
|
||||
# Slug Registration Flow
|
||||
|
||||
When a URL or page is created:
|
||||
|
||||
1. Validate quota.
|
||||
2. Validate slug.
|
||||
3. Register slug in system.db.
|
||||
4. Create record in tenant content.db.
|
||||
5. Increment quota counters.
|
||||
6. Write audit log.
|
||||
|
||||
If any step fails:
|
||||
|
||||
* Changes are rolled back.
|
||||
* Partial records are removed.
|
||||
|
||||
---
|
||||
|
||||
# Global Slug Table
|
||||
|
||||
Conceptually:
|
||||
|
||||
```text
|
||||
global_slugs
|
||||
```
|
||||
|
||||
Contains:
|
||||
|
||||
```text
|
||||
slug
|
||||
owner_user_id
|
||||
target_type
|
||||
target_id
|
||||
status
|
||||
created_at
|
||||
```
|
||||
|
||||
Example:
|
||||
|
||||
| slug | owner | type |
|
||||
| ---- | ----- | ---- |
|
||||
| docs | 3 | page |
|
||||
| api | 8 | page |
|
||||
| home | 2 | url |
|
||||
|
||||
---
|
||||
|
||||
# Slug Ownership Transfer
|
||||
|
||||
Administrators may transfer ownership.
|
||||
|
||||
Process:
|
||||
|
||||
1. Validate destination quotas.
|
||||
2. Copy content.
|
||||
3. Move ownership.
|
||||
4. Update global slug registry.
|
||||
5. Record history.
|
||||
6. Write audit event.
|
||||
|
||||
Analytics remain preserved.
|
||||
|
||||
URLs remain functional.
|
||||
|
||||
---
|
||||
|
||||
# Tenant Isolation
|
||||
|
||||
Each user owns independent databases.
|
||||
|
||||
Example:
|
||||
|
||||
```text
|
||||
User A
|
||||
└── users/2/
|
||||
|
||||
User B
|
||||
└── users/3/
|
||||
```
|
||||
|
||||
User A never accesses:
|
||||
|
||||
```text
|
||||
users/3/content.db
|
||||
users/3/analytics.db
|
||||
```
|
||||
|
||||
User B never accesses:
|
||||
|
||||
```text
|
||||
users/2/content.db
|
||||
users/2/analytics.db
|
||||
```
|
||||
|
||||
All access is enforced by application logic.
|
||||
|
||||
---
|
||||
|
||||
# Authentication Architecture
|
||||
|
||||
Authentication is centralized.
|
||||
|
||||
Stored in:
|
||||
|
||||
```text
|
||||
users.db
|
||||
```
|
||||
|
||||
Tables:
|
||||
|
||||
```text
|
||||
users
|
||||
sessions
|
||||
```
|
||||
|
||||
All dashboard sessions use:
|
||||
|
||||
```text
|
||||
bzod_session
|
||||
```
|
||||
|
||||
Sessions are validated against:
|
||||
|
||||
```text
|
||||
users.db.sessions
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Session Lifecycle
|
||||
|
||||
Login:
|
||||
|
||||
```text
|
||||
User Login
|
||||
↓
|
||||
Create Session
|
||||
↓
|
||||
Store in users.db
|
||||
↓
|
||||
Set bzod_session cookie
|
||||
```
|
||||
|
||||
Logout:
|
||||
|
||||
```text
|
||||
Delete session row
|
||||
↓
|
||||
Expire cookie
|
||||
```
|
||||
|
||||
Disabled users immediately lose access.
|
||||
|
||||
---
|
||||
|
||||
# Quota System
|
||||
|
||||
Every user has quotas.
|
||||
|
||||
Examples:
|
||||
|
||||
```text
|
||||
max_urls
|
||||
max_pages
|
||||
max_storage_mb
|
||||
max_api_tokens
|
||||
```
|
||||
|
||||
Current utilization is tracked separately.
|
||||
|
||||
Administrators may:
|
||||
|
||||
* Increase limits
|
||||
* Reduce limits
|
||||
* Trigger reconciliation
|
||||
|
||||
---
|
||||
|
||||
# Quota Reconciliation
|
||||
|
||||
Background job:
|
||||
|
||||
```text
|
||||
quota_reconcile
|
||||
```
|
||||
|
||||
Purpose:
|
||||
|
||||
* Detect drift
|
||||
* Recount resources
|
||||
* Repair counters
|
||||
|
||||
Example:
|
||||
|
||||
```text
|
||||
Stored URLs = 50
|
||||
Actual URLs = 47
|
||||
```
|
||||
|
||||
Counter automatically corrected.
|
||||
|
||||
---
|
||||
|
||||
# Analytics Isolation
|
||||
|
||||
Each tenant stores analytics independently.
|
||||
|
||||
Example:
|
||||
|
||||
```text
|
||||
users/10/analytics.db
|
||||
```
|
||||
|
||||
Contains only User 10 traffic.
|
||||
|
||||
Administrators can:
|
||||
|
||||
* View aggregated analytics
|
||||
* Access user analytics
|
||||
|
||||
Users cannot view analytics from other tenants.
|
||||
|
||||
---
|
||||
|
||||
# QR Code System
|
||||
|
||||
QR codes are generated dynamically.
|
||||
|
||||
Endpoints:
|
||||
|
||||
```text
|
||||
/api/qr/{slug}.png
|
||||
/api/qr/{slug}.svg
|
||||
```
|
||||
|
||||
Slug ownership is resolved through:
|
||||
|
||||
```text
|
||||
system.db.global_slugs
|
||||
```
|
||||
|
||||
No content database scan is required.
|
||||
|
||||
---
|
||||
|
||||
# Moderation Architecture
|
||||
|
||||
Administrators can:
|
||||
|
||||
* Flag content
|
||||
* Disable content
|
||||
* Delete content
|
||||
* Transfer ownership
|
||||
|
||||
Disabled content returns:
|
||||
|
||||
```http
|
||||
410 Gone
|
||||
```
|
||||
|
||||
For:
|
||||
|
||||
```text
|
||||
/slug
|
||||
/p/slug
|
||||
/api/qr/slug.png
|
||||
/api/qr/slug.svg
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Audit Logging
|
||||
|
||||
All administrative actions are recorded.
|
||||
|
||||
Examples:
|
||||
|
||||
```text
|
||||
login
|
||||
logout
|
||||
user_create
|
||||
user_delete
|
||||
password_reset
|
||||
quota_update
|
||||
slug_transfer
|
||||
backup_create
|
||||
restore_execute
|
||||
```
|
||||
|
||||
Stored in:
|
||||
|
||||
```text
|
||||
system.db
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Backup Architecture
|
||||
|
||||
Supported levels:
|
||||
|
||||
## Full Platform Backup
|
||||
|
||||
Includes:
|
||||
|
||||
```text
|
||||
users.db
|
||||
system.db
|
||||
all tenant databases
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## User Backup
|
||||
|
||||
Includes:
|
||||
|
||||
```text
|
||||
content.db
|
||||
analytics.db
|
||||
```
|
||||
|
||||
For a specific user.
|
||||
|
||||
---
|
||||
|
||||
# Disaster Recovery
|
||||
|
||||
Supported operations:
|
||||
|
||||
```bash
|
||||
bzod backup
|
||||
bzod restore
|
||||
bzod backup-user
|
||||
bzod restore-user
|
||||
```
|
||||
|
||||
Recovery preserves:
|
||||
|
||||
* URLs
|
||||
* Pages
|
||||
* Analytics
|
||||
* Users
|
||||
* Slugs
|
||||
* Settings
|
||||
|
||||
---
|
||||
|
||||
# Upgrade Path
|
||||
|
||||
BZOD automatically migrates:
|
||||
|
||||
```text
|
||||
v0.4.x
|
||||
```
|
||||
|
||||
to
|
||||
|
||||
```text
|
||||
v0.5.x
|
||||
```
|
||||
|
||||
Migration process:
|
||||
|
||||
1. Create users.db.
|
||||
2. Create system.db.
|
||||
3. Create admin tenant.
|
||||
4. Migrate content.
|
||||
5. Migrate analytics.
|
||||
6. Populate global_slugs.
|
||||
7. Create legacy_admin.
|
||||
8. Validate integrity.
|
||||
|
||||
No manual database migration is normally required.
|
||||
|
||||
---
|
||||
|
||||
# Security Model
|
||||
|
||||
Security boundaries:
|
||||
|
||||
## Authentication
|
||||
|
||||
Centralized.
|
||||
|
||||
```text
|
||||
users.db
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Authorization
|
||||
|
||||
Role-based.
|
||||
|
||||
```text
|
||||
admin
|
||||
standard
|
||||
system
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## CSRF Protection
|
||||
|
||||
All forms protected.
|
||||
|
||||
Invalid tokens:
|
||||
|
||||
```http
|
||||
403 Forbidden
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Session Security
|
||||
|
||||
* Secure session IDs
|
||||
* Session invalidation
|
||||
* Expiration support
|
||||
* Replay protection
|
||||
|
||||
---
|
||||
|
||||
## Tenant Isolation
|
||||
|
||||
Per-user databases.
|
||||
|
||||
No shared content tables.
|
||||
|
||||
---
|
||||
|
||||
# Operational Recommendations
|
||||
|
||||
Recommended deployment:
|
||||
|
||||
```text
|
||||
Nginx
|
||||
↓
|
||||
BZOD
|
||||
↓
|
||||
SQLite WAL
|
||||
```
|
||||
|
||||
Enable:
|
||||
|
||||
* HTTPS
|
||||
* Daily backups
|
||||
* Log rotation
|
||||
* Health monitoring
|
||||
|
||||
---
|
||||
|
||||
# Limitations
|
||||
|
||||
Current v0.5.0 limitations:
|
||||
|
||||
* SQLite backend only
|
||||
* Single server deployment
|
||||
* No clustering
|
||||
* No federation
|
||||
* No organization account hierarchy
|
||||
|
||||
These may be addressed in future releases.
|
||||
|
||||
---
|
||||
|
||||
# Future Expansion
|
||||
|
||||
Potential v0.6.x features:
|
||||
|
||||
* Organization accounts
|
||||
* Service accounts
|
||||
* SSO integration
|
||||
* Multi-node replication
|
||||
* Advanced analytics dashboards
|
||||
* Scheduled tasks UI
|
||||
|
||||
---
|
||||
|
||||
# Summary
|
||||
|
||||
BZOD v0.5.0 provides:
|
||||
|
||||
* Centralized authentication
|
||||
* Multi-user isolation
|
||||
* Global slug namespace
|
||||
* Per-user analytics
|
||||
* Administrative moderation
|
||||
* Quotas
|
||||
* Audit logging
|
||||
* Backup & disaster recovery
|
||||
* Single-binary deployment
|
||||
|
||||
while remaining lightweight, SQLite-native, and operationally simple.
|
||||
|
||||
---
|
||||
|
||||
End of Document.
|
||||
@@ -0,0 +1,419 @@
|
||||
# BZOD v0.6.0 — Legacy Restore Compatibility & Version Reporting
|
||||
|
||||
Release Date: 2026-08-09
|
||||
|
||||
## Highlights
|
||||
|
||||
- **Legacy Backup Restore Compatibility**: Backups created with the web admin "Download Backup" feature (`legacy_flat_backup` format) can now be correctly restored into the current multi-tenant database architecture. Previously, these restores failed with "no such table: users" because the restore validator ran against the empty legacy `users.db` before layout normalization.
|
||||
|
||||
- **CLI Version Reporting**: `bzod --version` and `bzod -V` now report the application version derived from Cargo.toml package metadata, ensuring the reported version cannot diverge from the build.
|
||||
|
||||
- **Deploy Script Modernization**: Removed the obsolete `init-db` command from the deployment script. Database creation and schema migration are now handled automatically by `bzod serve`. The deploy script now verifies the installed binary version using `--version`.
|
||||
|
||||
## Breaking Changes
|
||||
|
||||
None.
|
||||
|
||||
# BZOD v0.5.3 — Architecture Refinement & Redirect Hardening
|
||||
|
||||
BZOD v0.5.3 is an internal quality and maintainability release focused on architectural refinement, redirect handler hardening, and comprehensive verification.
|
||||
|
||||
No new user-facing features are introduced. Existing API contracts, route behavior, authentication, and tenant isolation are fully preserved.
|
||||
|
||||
---
|
||||
|
||||
# Highlights
|
||||
|
||||
## Modular Admin Architecture
|
||||
|
||||
The former monolithic admin handler file was eliminated and replaced with a focused module directory at `src/web/admin/`.
|
||||
|
||||
Feature modules:
|
||||
|
||||
* `auth.rs` — authentication and session handling
|
||||
* `dashboard.rs` — dashboard rendering
|
||||
* `urls.rs` — URL management handlers
|
||||
* `pages.rs` — landing page management handlers
|
||||
* `analytics.rs` — analytics and export handlers
|
||||
* `settings.rs` — settings and configuration handlers
|
||||
* `users.rs` — user management handlers
|
||||
* `sessions.rs` — session administration
|
||||
* `quotas.rs` — quota management
|
||||
* `health.rs` — health diagnostics
|
||||
* `backups.rs` — backup and restore handlers
|
||||
* `api_keys.rs` — API key management
|
||||
* `audit.rs` — audit log handlers
|
||||
* `moderation.rs` — content moderation handlers
|
||||
|
||||
Benefits:
|
||||
|
||||
* Improved code organization and navigability
|
||||
* Reduced coupling between feature areas
|
||||
* Improved database lock scoping
|
||||
* Reduced duplicated handler logic
|
||||
* Better error handling consistency and observability
|
||||
* Simplified future extension
|
||||
|
||||
---
|
||||
|
||||
## Redirect Handler Hardening
|
||||
|
||||
The public redirect path (`GET /:code`) was hardened against invalid HTTP Location header values.
|
||||
|
||||
Changes:
|
||||
|
||||
* Removed the panic-prone `HeaderValue::from_str(...).unwrap()` pattern
|
||||
* Added destination URL validation (scheme enforcement, control character rejection)
|
||||
* Added safe Location header construction that handles malformed values gracefully
|
||||
* Improved database error logging with structured fields
|
||||
* Reduced unnecessary database mutex lock acquisitions
|
||||
* Removed synchronous expiration writes from the redirect hot path
|
||||
|
||||
Existing redirect security and tenant isolation behavior was preserved.
|
||||
|
||||
---
|
||||
|
||||
## Root Landing Page Verification
|
||||
|
||||
* Confirmed `GET /` as an intentional application route serving `www/index.html`
|
||||
* Resolved a runtime path-resolution issue affecting static landing-page resolution
|
||||
* Verified `GET /` returns HTTP 200
|
||||
* Verified `GET /login` returns HTTP 200
|
||||
* Verified `GET /admin/login` returns HTTP 200
|
||||
|
||||
---
|
||||
|
||||
# Testing & Validation
|
||||
|
||||
BZOD v0.5.3 passed:
|
||||
|
||||
* Release build (`cargo build --release`)
|
||||
* Comprehensive automated test suite, including:
|
||||
* Authentication and migration tests
|
||||
* Redirect security tests
|
||||
* Root landing page test
|
||||
* Backup and restore tests
|
||||
* Business workflow tests
|
||||
* Security tests
|
||||
* Slug namespace, registry, and transfer tests
|
||||
* User management and isolation tests
|
||||
* WAL recovery tests
|
||||
* HTTP end-to-end tests
|
||||
* Runtime smoke tests against the release binary
|
||||
* SQLite WAL mode and foreign-key enforcement initialization
|
||||
* Database migration verification (all migrations up to date)
|
||||
|
||||
---
|
||||
|
||||
# Compatibility
|
||||
|
||||
* No breaking changes
|
||||
* No API changes
|
||||
* No route changes
|
||||
* No database schema changes
|
||||
* No configuration changes
|
||||
* Direct upgrade from v0.5.1 with no migration required
|
||||
|
||||
---
|
||||
|
||||
# Repository
|
||||
|
||||
* Clean source tree established
|
||||
* Build artifacts, temporary reports, and IDE metadata removed
|
||||
* Existing BZOD Git history preserved
|
||||
* Refactoring baseline merged with existing history
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
# BZOD v0.5.1 — Namespace Integrity & Platform Hardening
|
||||
|
||||
**Release Date:** 2026-06-20
|
||||
|
||||
BZOD v0.5.1 focuses on platform integrity, multi-tenant safety, dashboard parity, QR reliability, and upgrade validation.
|
||||
|
||||
While v0.5.0 introduced the multi-user architecture, v0.5.1 strengthens the foundations required for safe operation at scale.
|
||||
|
||||
---
|
||||
|
||||
# Highlights
|
||||
## Runtime Efficiency (v0.5.1)
|
||||
|
||||
| Metric | Value |
|
||||
|---------------------|------------|
|
||||
| Binary Size | 11 MB |
|
||||
| RSS Memory | 11.8 MB |
|
||||
| Peak RSS | 11.8 MB |
|
||||
| CPU Idle | 0.02% |
|
||||
| Swap Usage | 0 KB |
|
||||
| PIDs | 7 |
|
||||
|
||||
**On a typical 32 GB server:**
|
||||
- Memory usage: ~0.04%
|
||||
- No swapping
|
||||
- Plenty of headroom
|
||||
|
||||
BZOD runs closer to a lightweight infrastructure service than a typical web application.
|
||||
|
||||
## Global Slug Registry
|
||||
|
||||
Introduced a hardened global slug registry to guarantee namespace integrity across the entire platform.
|
||||
|
||||
The following resources can no longer share the same slug:
|
||||
|
||||
* Administrator URLs
|
||||
* Administrator Landing Pages
|
||||
* User URLs
|
||||
* User Landing Pages
|
||||
|
||||
Duplicate namespace conflicts are automatically detected and blocked.
|
||||
|
||||
---
|
||||
|
||||
## Namespace Integrity Validation
|
||||
|
||||
New validation routines now verify:
|
||||
|
||||
* Duplicate slug detection
|
||||
* Missing ownership records
|
||||
* Invalid registry entries
|
||||
* Invalid target types
|
||||
* Orphaned slug references
|
||||
|
||||
Namespace conflicts now abort upgrades and restores before corruption can occur.
|
||||
|
||||
---
|
||||
|
||||
## Reservation-Based Slug Allocation
|
||||
|
||||
BZOD now reserves slugs before content creation.
|
||||
|
||||
Creation workflow:
|
||||
|
||||
```text
|
||||
Quota Check
|
||||
↓
|
||||
Reserve Global Slug
|
||||
↓
|
||||
Create Content
|
||||
↓
|
||||
Activate Slug
|
||||
↓
|
||||
Increment Quota
|
||||
↓
|
||||
Audit Log
|
||||
```
|
||||
|
||||
Benefits:
|
||||
|
||||
* Prevents race conditions
|
||||
* Prevents duplicate creation under concurrency
|
||||
* Enables safer rollback handling
|
||||
|
||||
---
|
||||
|
||||
## Stale Reservation Recovery
|
||||
|
||||
Added automatic cleanup of abandoned slug reservations.
|
||||
|
||||
Scenarios covered:
|
||||
|
||||
* Server crash during creation
|
||||
* Interrupted writes
|
||||
* Failed transactions
|
||||
|
||||
BZOD now automatically recovers stale reservations during startup.
|
||||
|
||||
---
|
||||
|
||||
## Dashboard Parity
|
||||
|
||||
Administrator and Standard User dashboards now provide equivalent functionality where appropriate.
|
||||
|
||||
Added parity validation for:
|
||||
|
||||
* URL management
|
||||
* Landing page management
|
||||
* Analytics
|
||||
* QR code previews
|
||||
* Export functionality
|
||||
|
||||
Differences remain only for administrator-specific operations.
|
||||
|
||||
---
|
||||
|
||||
## Unified Analytics Templates
|
||||
|
||||
Removed duplicated analytics templates.
|
||||
|
||||
Benefits:
|
||||
|
||||
* Consistent rendering
|
||||
* Reduced maintenance burden
|
||||
* Improved reliability
|
||||
|
||||
Administrator and user analytics now share the same rendering logic.
|
||||
|
||||
---
|
||||
|
||||
## QR Code Improvements
|
||||
|
||||
QR functionality was substantially improved.
|
||||
|
||||
### Added
|
||||
|
||||
* Inline QR previews
|
||||
* PNG downloads
|
||||
* SVG downloads
|
||||
* Shared QR rendering component
|
||||
|
||||
### Fixed
|
||||
|
||||
* Landing page QR generation
|
||||
* Multi-user QR ownership handling
|
||||
* QR routing consistency
|
||||
* Content-type validation
|
||||
|
||||
---
|
||||
|
||||
## Canonical Landing Page Routing
|
||||
|
||||
Landing page slugs now redirect permanently to canonical page URLs.
|
||||
|
||||
Example:
|
||||
|
||||
```text
|
||||
/landing-page
|
||||
```
|
||||
|
||||
redirects to:
|
||||
|
||||
```text
|
||||
/p/landing-page
|
||||
```
|
||||
|
||||
using:
|
||||
|
||||
```http
|
||||
301 Moved Permanently
|
||||
```
|
||||
|
||||
This improves consistency and SEO behavior.
|
||||
|
||||
---
|
||||
|
||||
## Ownership Isolation Hardening
|
||||
|
||||
Additional protections ensure:
|
||||
|
||||
* Users cannot access another user's analytics
|
||||
* Users cannot export another user's data
|
||||
* Users cannot manage another user's resources
|
||||
|
||||
New ownership validation tests were added.
|
||||
|
||||
---
|
||||
|
||||
## Backup & Restore Improvements
|
||||
|
||||
Restore operations now validate namespace integrity before importing data.
|
||||
|
||||
Benefits:
|
||||
|
||||
* No silent slug collisions
|
||||
* No partial restores
|
||||
* No hidden ownership conflicts
|
||||
|
||||
Restore operations fail safely when conflicts are detected.
|
||||
|
||||
---
|
||||
|
||||
## Upgrade Validation Enhancements
|
||||
|
||||
Upgrade workflows now verify:
|
||||
|
||||
* Global namespace consistency
|
||||
* Duplicate slug conflicts
|
||||
* Registry integrity
|
||||
* Tenant ownership correctness
|
||||
|
||||
Unsafe upgrades are blocked automatically.
|
||||
|
||||
---
|
||||
|
||||
## Health & Diagnostics
|
||||
|
||||
The system health subsystem now validates:
|
||||
|
||||
* Global slug registry integrity
|
||||
* Namespace conflicts
|
||||
* Ownership consistency
|
||||
* Stale reservations
|
||||
|
||||
This improves operational visibility and troubleshooting.
|
||||
|
||||
---
|
||||
|
||||
# Testing & Validation
|
||||
|
||||
BZOD v0.5.1 passed:
|
||||
|
||||
* Formatting validation (`cargo fmt --check`)
|
||||
* Static analysis (`cargo clippy --all-targets -- -D warnings`)
|
||||
* Full automated test suite
|
||||
* Namespace integrity tests
|
||||
* Ownership isolation tests
|
||||
* QR endpoint tests
|
||||
* Dashboard parity tests
|
||||
* Upgrade validation tests
|
||||
* Backup & restore tests
|
||||
* Disaster recovery tests
|
||||
* Security tests
|
||||
* Concurrency tests
|
||||
|
||||
All automated tests pass successfully.
|
||||
|
||||
---
|
||||
|
||||
# Upgrade Notes
|
||||
|
||||
Administrators upgrading from v0.5.0 should review:
|
||||
|
||||
* UPGRADE.md
|
||||
* MULTI_USER.md
|
||||
* BACKUP_RESTORE.md
|
||||
* DATABASES.md
|
||||
* TESTING.md
|
||||
|
||||
BZOD will automatically validate namespace integrity before completing upgrades.
|
||||
|
||||
Duplicate slugs that previously existed across users or resource types must be resolved before migration can proceed.
|
||||
|
||||
---
|
||||
|
||||
# Breaking Changes
|
||||
|
||||
## Global Namespace Enforcement
|
||||
|
||||
Slugs are now globally unique across the entire platform.
|
||||
|
||||
Configurations that previously relied on duplicate slugs across users or resource types will be rejected during upgrade.
|
||||
|
||||
This behavior is intentional and protects routing integrity.
|
||||
|
||||
---
|
||||
|
||||
# Summary
|
||||
|
||||
BZOD v0.5.1 is an integrity-focused release that significantly strengthens:
|
||||
|
||||
* Namespace safety
|
||||
* Multi-tenant isolation
|
||||
* Dashboard consistency
|
||||
* QR reliability
|
||||
* Restore safety
|
||||
* Upgrade safety
|
||||
* Operational diagnostics
|
||||
|
||||
The result is a more predictable, recoverable, and production-ready platform.
|
||||
@@ -0,0 +1,685 @@
|
||||
# BZOD Security Guide
|
||||
|
||||
Version: v0.6.0
|
||||
|
||||
---
|
||||
|
||||
# Security Overview
|
||||
|
||||
BZOD is designed as a self-hosted URL shortener and landing page platform with a strong emphasis on:
|
||||
|
||||
* Multi-user isolation
|
||||
* Secure authentication
|
||||
* Role-based access control
|
||||
* Auditability
|
||||
* Data ownership
|
||||
* Disaster recovery
|
||||
* Operational simplicity
|
||||
|
||||
This document describes the security architecture, threat model, authentication mechanisms, authorization controls, and operational security recommendations for BZOD v0.6.0.
|
||||
|
||||
---
|
||||
|
||||
# Security Principles
|
||||
|
||||
BZOD follows several core principles:
|
||||
|
||||
1. Least Privilege
|
||||
2. Tenant Isolation
|
||||
3. Defense in Depth
|
||||
4. Auditability
|
||||
5. Secure Defaults
|
||||
6. Explicit Ownership
|
||||
7. Fail Secure
|
||||
|
||||
---
|
||||
|
||||
# Threat Model
|
||||
|
||||
BZOD is designed to protect against:
|
||||
|
||||
* Unauthorized dashboard access
|
||||
* Credential theft
|
||||
* Session hijacking
|
||||
* Cross-user data access
|
||||
* Slug takeover attempts
|
||||
* Privilege escalation
|
||||
* CSRF attacks
|
||||
* XSS injection attempts
|
||||
* Unauthorized API access
|
||||
* Malicious content modification
|
||||
* Accidental administrative mistakes
|
||||
|
||||
BZOD is not intended to defend against:
|
||||
|
||||
* Physical server compromise
|
||||
* Root-level operating system compromise
|
||||
* Malware running as the BZOD service user
|
||||
* Full database theft by a privileged host administrator
|
||||
|
||||
---
|
||||
|
||||
# Authentication
|
||||
|
||||
Authentication is centralized in:
|
||||
|
||||
```text
|
||||
users.db
|
||||
```
|
||||
|
||||
Tables:
|
||||
|
||||
```text
|
||||
users
|
||||
sessions
|
||||
api_tokens
|
||||
```
|
||||
|
||||
All users authenticate through the same identity system.
|
||||
|
||||
---
|
||||
|
||||
# Password Security
|
||||
|
||||
Passwords are never stored in plaintext.
|
||||
|
||||
Stored values:
|
||||
|
||||
```text
|
||||
password_hash
|
||||
```
|
||||
|
||||
Passwords are hashed before storage.
|
||||
|
||||
Administrative password resets generate entirely new hashes.
|
||||
|
||||
Existing passwords cannot be recovered.
|
||||
|
||||
---
|
||||
|
||||
# Session Security
|
||||
|
||||
All dashboard authentication uses:
|
||||
|
||||
```text
|
||||
bzod_session
|
||||
```
|
||||
|
||||
cookie.
|
||||
|
||||
Sessions are stored in:
|
||||
|
||||
```text
|
||||
users.db.sessions
|
||||
```
|
||||
|
||||
Each session contains:
|
||||
|
||||
```text
|
||||
session_id
|
||||
user_id
|
||||
created_at
|
||||
expires_at
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Session Validation
|
||||
|
||||
Each authenticated request verifies:
|
||||
|
||||
1. Session exists
|
||||
2. Session has not expired
|
||||
3. User exists
|
||||
4. User status is active
|
||||
5. User has required permissions
|
||||
|
||||
Failure at any step immediately invalidates access.
|
||||
|
||||
---
|
||||
|
||||
## Session Revocation
|
||||
|
||||
Sessions are revoked when:
|
||||
|
||||
* User logs out
|
||||
* User is disabled
|
||||
* User is deleted
|
||||
* Password is reset
|
||||
* Administrator revokes sessions
|
||||
|
||||
---
|
||||
|
||||
## Session Fixation Protection
|
||||
|
||||
BZOD generates new session identifiers after successful authentication.
|
||||
|
||||
Previously issued identifiers are not reused.
|
||||
|
||||
---
|
||||
|
||||
# Authorization Model
|
||||
|
||||
BZOD implements Role-Based Access Control (RBAC).
|
||||
|
||||
Supported roles:
|
||||
|
||||
```text
|
||||
admin
|
||||
standard
|
||||
system
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Administrator
|
||||
|
||||
Administrators can:
|
||||
|
||||
* Manage users
|
||||
* Reset passwords
|
||||
* Transfer ownership
|
||||
* Manage quotas
|
||||
* Access audit logs
|
||||
* Review analytics
|
||||
* Create backups
|
||||
* Restore backups
|
||||
* Moderate content
|
||||
|
||||
Administrators cannot bypass audit logging.
|
||||
|
||||
---
|
||||
|
||||
## Standard User
|
||||
|
||||
Standard users can:
|
||||
|
||||
* Manage owned URLs
|
||||
* Manage owned landing pages
|
||||
* View owned analytics
|
||||
* Generate API tokens
|
||||
* Manage owned content
|
||||
|
||||
Standard users cannot:
|
||||
|
||||
* Access other users' content
|
||||
* Access administrative endpoints
|
||||
* Access system settings
|
||||
|
||||
---
|
||||
|
||||
## System Accounts
|
||||
|
||||
System accounts are internal accounts.
|
||||
|
||||
They cannot authenticate into:
|
||||
|
||||
* Dashboard
|
||||
* REST API
|
||||
|
||||
---
|
||||
|
||||
# Multi-User Isolation
|
||||
|
||||
Multi-user isolation is one of the primary security features of BZOD.
|
||||
|
||||
Each tenant receives independent databases.
|
||||
|
||||
Example:
|
||||
|
||||
```text
|
||||
users/
|
||||
├── 2/
|
||||
│ ├── content.db
|
||||
│ └── analytics.db
|
||||
│
|
||||
├── 3/
|
||||
│ ├── content.db
|
||||
│ └── analytics.db
|
||||
```
|
||||
|
||||
User 2 never accesses:
|
||||
|
||||
```text
|
||||
users/3/content.db
|
||||
users/3/analytics.db
|
||||
```
|
||||
|
||||
User 3 never accesses:
|
||||
|
||||
```text
|
||||
users/2/content.db
|
||||
users/2/analytics.db
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Global Slug Security
|
||||
|
||||
All public slugs are stored in:
|
||||
|
||||
```text
|
||||
system.db.global_slugs
|
||||
```
|
||||
|
||||
Each slug is globally unique.
|
||||
|
||||
Example:
|
||||
|
||||
```text
|
||||
/company
|
||||
```
|
||||
|
||||
may belong to only one owner.
|
||||
|
||||
Duplicate registrations are rejected.
|
||||
|
||||
---
|
||||
|
||||
## Slug Ownership
|
||||
|
||||
Every slug contains:
|
||||
|
||||
```text
|
||||
owner_user_id
|
||||
target_id
|
||||
target_type
|
||||
status
|
||||
```
|
||||
|
||||
Ownership must match before modification is permitted.
|
||||
|
||||
---
|
||||
|
||||
## Slug Transfer Protection
|
||||
|
||||
Only administrators may transfer ownership.
|
||||
|
||||
Transfer operations:
|
||||
|
||||
1. Validate destination quotas
|
||||
2. Validate destination user
|
||||
3. Copy content
|
||||
4. Update ownership
|
||||
5. Record history
|
||||
6. Write audit event
|
||||
|
||||
---
|
||||
|
||||
# API Security
|
||||
|
||||
REST API authentication uses API tokens.
|
||||
|
||||
Tokens are stored as hashes.
|
||||
|
||||
Plaintext tokens are shown only once during creation.
|
||||
|
||||
---
|
||||
|
||||
## API Token Security
|
||||
|
||||
Stored values:
|
||||
|
||||
```text
|
||||
token_hash
|
||||
```
|
||||
|
||||
Never:
|
||||
|
||||
```text
|
||||
plaintext_token
|
||||
```
|
||||
|
||||
If a token is lost:
|
||||
|
||||
1. Revoke it
|
||||
2. Generate a new token
|
||||
|
||||
---
|
||||
|
||||
## API Permissions
|
||||
|
||||
Admin tokens:
|
||||
|
||||
```text
|
||||
Full administrative access
|
||||
```
|
||||
|
||||
Standard user tokens:
|
||||
|
||||
```text
|
||||
Owned resources only
|
||||
```
|
||||
|
||||
System accounts:
|
||||
|
||||
```text
|
||||
API access denied
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# CSRF Protection
|
||||
|
||||
All dashboard forms require valid CSRF tokens.
|
||||
|
||||
Protected actions include:
|
||||
|
||||
* Login
|
||||
* User creation
|
||||
* Password reset
|
||||
* Content modification
|
||||
* Moderation actions
|
||||
* Quota updates
|
||||
* Backup operations
|
||||
|
||||
---
|
||||
|
||||
## Invalid CSRF Requests
|
||||
|
||||
Invalid requests return:
|
||||
|
||||
```http
|
||||
403 Forbidden
|
||||
```
|
||||
|
||||
and are rejected before processing.
|
||||
|
||||
---
|
||||
|
||||
# XSS Protection
|
||||
|
||||
User-supplied content is validated before rendering.
|
||||
|
||||
Templates use:
|
||||
|
||||
```text
|
||||
Askama
|
||||
```
|
||||
|
||||
which escapes output by default.
|
||||
|
||||
Recommended:
|
||||
|
||||
* Do not allow arbitrary JavaScript
|
||||
* Validate HTML content
|
||||
* Restrict trusted editors
|
||||
|
||||
---
|
||||
|
||||
# Content Moderation
|
||||
|
||||
Administrators may:
|
||||
|
||||
* Flag content
|
||||
* Disable content
|
||||
* Delete content
|
||||
|
||||
Disabled content returns:
|
||||
|
||||
```http
|
||||
410 Gone
|
||||
```
|
||||
|
||||
for:
|
||||
|
||||
```text
|
||||
/{slug}
|
||||
/p/{slug}
|
||||
/api/qr/{slug}.png
|
||||
/api/qr/{slug}.svg
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Redirect Security
|
||||
|
||||
The redirect handler validates destination URLs before constructing HTTP Location headers.
|
||||
|
||||
Protections include:
|
||||
|
||||
* URL scheme validation (only http and https destinations are permitted)
|
||||
* Control character rejection
|
||||
* CRLF injection prevention
|
||||
* Safe Location header construction (no panics on malformed values)
|
||||
|
||||
Invalid redirect destinations return:
|
||||
|
||||
```http
|
||||
500 Internal Server Error
|
||||
```
|
||||
|
||||
with structured server-side logging. Full destination values are not exposed to clients.
|
||||
|
||||
---
|
||||
|
||||
# Audit Logging
|
||||
|
||||
Security-sensitive actions are logged.
|
||||
|
||||
Examples:
|
||||
|
||||
```text
|
||||
login
|
||||
logout
|
||||
failed_login
|
||||
user_created
|
||||
user_deleted
|
||||
password_reset
|
||||
slug_transfer
|
||||
quota_update
|
||||
backup_created
|
||||
restore_executed
|
||||
```
|
||||
|
||||
Stored in:
|
||||
|
||||
```text
|
||||
system.db
|
||||
```
|
||||
|
||||
Audit logs should be reviewed regularly.
|
||||
|
||||
---
|
||||
|
||||
# Backup Security
|
||||
|
||||
Backups may contain:
|
||||
|
||||
* User records
|
||||
* Session records
|
||||
* URLs
|
||||
* Pages
|
||||
* Analytics
|
||||
* API token hashes
|
||||
|
||||
Backups should be treated as sensitive data.
|
||||
|
||||
---
|
||||
|
||||
## Recommendations
|
||||
|
||||
Store backups:
|
||||
|
||||
* Offsite
|
||||
* Encrypted
|
||||
* Access-controlled
|
||||
|
||||
Never expose backup archives publicly.
|
||||
|
||||
---
|
||||
|
||||
# Database Security
|
||||
|
||||
SQLite databases should be accessible only to the BZOD service account.
|
||||
|
||||
Recommended permissions:
|
||||
|
||||
```bash
|
||||
chmod 700 data
|
||||
chmod 600 *.db
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# HTTPS Requirements
|
||||
|
||||
Production deployments should always use HTTPS.
|
||||
|
||||
Recommended reverse proxies:
|
||||
|
||||
* Nginx
|
||||
* Caddy
|
||||
* Traefik
|
||||
|
||||
Never expose login pages over plaintext HTTP.
|
||||
|
||||
---
|
||||
|
||||
# Security Headers
|
||||
|
||||
Recommended reverse proxy headers:
|
||||
|
||||
```http
|
||||
X-Frame-Options: DENY
|
||||
X-Content-Type-Options: nosniff
|
||||
Referrer-Policy: strict-origin-when-cross-origin
|
||||
Content-Security-Policy: default-src 'self'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Password Policy Recommendations
|
||||
|
||||
Recommended minimum:
|
||||
|
||||
```text
|
||||
12 characters
|
||||
```
|
||||
|
||||
Encourage:
|
||||
|
||||
* Password managers
|
||||
* Unique passwords
|
||||
* Randomly generated credentials
|
||||
|
||||
Avoid:
|
||||
|
||||
* Reused passwords
|
||||
* Dictionary words
|
||||
* Predictable patterns
|
||||
|
||||
---
|
||||
|
||||
# Brute Force Protection
|
||||
|
||||
Recommended deployment protections:
|
||||
|
||||
* Reverse proxy rate limiting
|
||||
* Fail2Ban
|
||||
* Firewall rules
|
||||
|
||||
Example:
|
||||
|
||||
```text
|
||||
5 login attempts
|
||||
within 5 minutes
|
||||
```
|
||||
|
||||
before temporary blocking.
|
||||
|
||||
---
|
||||
|
||||
# Administrative Security Checklist
|
||||
|
||||
Before production deployment:
|
||||
|
||||
* Enable HTTPS
|
||||
* Configure backups
|
||||
* Review file permissions
|
||||
* Remove default credentials
|
||||
* Verify audit logging
|
||||
* Test restore procedures
|
||||
* Review active sessions
|
||||
|
||||
---
|
||||
|
||||
# Incident Response
|
||||
|
||||
If compromise is suspected:
|
||||
|
||||
1. Disable affected accounts.
|
||||
2. Revoke active sessions.
|
||||
3. Revoke API tokens.
|
||||
4. Create forensic backup.
|
||||
5. Review audit logs.
|
||||
6. Restore from trusted backups if necessary.
|
||||
7. Rotate credentials.
|
||||
|
||||
---
|
||||
|
||||
# Security Testing
|
||||
|
||||
BZOD v0.6.0 includes tests covering:
|
||||
|
||||
* Authentication
|
||||
* Authorization
|
||||
* Session validation
|
||||
* CSRF enforcement
|
||||
* Slug ownership
|
||||
* User isolation
|
||||
* Upgrade migrations
|
||||
* Backup integrity
|
||||
* Disaster recovery
|
||||
* Redirect destination validation
|
||||
* HTTP Location header safety
|
||||
|
||||
These tests are executed during CI and release validation.
|
||||
|
||||
---
|
||||
|
||||
# Responsible Disclosure
|
||||
|
||||
If a security vulnerability is discovered:
|
||||
|
||||
1. Do not publish exploit details immediately.
|
||||
2. Report the issue privately.
|
||||
3. Allow time for remediation.
|
||||
4. Coordinate disclosure after a fix is available.
|
||||
|
||||
---
|
||||
|
||||
# Known Limitations
|
||||
|
||||
Current limitations include:
|
||||
|
||||
* No MFA support
|
||||
* No SSO integration
|
||||
* No hardware security key support
|
||||
* No built-in rate limiter
|
||||
* No WebAuthn support
|
||||
|
||||
These may be addressed in future releases.
|
||||
|
||||
---
|
||||
|
||||
# Summary
|
||||
|
||||
BZOD v0.6.0 provides:
|
||||
|
||||
* Centralized authentication
|
||||
* Secure session management
|
||||
* RBAC authorization
|
||||
* Multi-user isolation
|
||||
* Global slug ownership controls
|
||||
* CSRF protection
|
||||
* API token hashing
|
||||
* Audit logging
|
||||
* Backup security
|
||||
* Operational security guidance
|
||||
|
||||
while maintaining a lightweight, SQLite-native, self-hosted architecture.
|
||||
|
||||
---
|
||||
|
||||
End of Document.
|
||||
+397
-315
@@ -1,34 +1,68 @@
|
||||
# TESTING.md
|
||||
# BZOD Testing & Validation Guide
|
||||
|
||||
# BZOD Test Procedures
|
||||
## Overview
|
||||
|
||||
This document describes the official verification procedures for BZOD.
|
||||
BZOD follows a defense-in-depth validation strategy.
|
||||
|
||||
The objective is not merely to confirm that code compiles, but to ensure that the complete platform can be built, deployed, backed up, restored, migrated, and recovered successfully.
|
||||
A release is considered valid only when:
|
||||
|
||||
* Code quality checks pass
|
||||
* Automated tests pass
|
||||
* Upgrade validation passes
|
||||
* Backup/restore validation passes
|
||||
* Namespace integrity validation passes
|
||||
* Multi-user isolation validation passes
|
||||
* Disaster recovery validation passes
|
||||
|
||||
The objective is not simply to ensure the application starts, but to ensure that it can be safely upgraded, operated, backed up, restored, and recovered.
|
||||
|
||||
---
|
||||
|
||||
# Philosophy
|
||||
# Validation Philosophy
|
||||
|
||||
BZOD prioritizes:
|
||||
|
||||
1. Data Integrity
|
||||
2. Operational Simplicity
|
||||
3. Recovery Capability
|
||||
4. Deployment Reproducibility
|
||||
5. Functional Correctness
|
||||
1. Namespace Integrity
|
||||
2. Data Integrity
|
||||
3. Multi-Tenant Isolation
|
||||
4. Operational Simplicity
|
||||
5. Recovery Capability
|
||||
6. Security
|
||||
7. Functional Correctness
|
||||
|
||||
A passing unit test suite alone is insufficient.
|
||||
A successful release is not merely one that runs.
|
||||
|
||||
A release is considered valid only if backup, restore, migration, and recovery procedures have been verified.
|
||||
A successful release is one that can be recovered.
|
||||
|
||||
---
|
||||
|
||||
# Test Categories
|
||||
# Automated Test Coverage
|
||||
|
||||
## 1. Build Verification
|
||||
Current validation suite includes:
|
||||
|
||||
Verify the application compiles successfully.
|
||||
* Unit Tests
|
||||
* Integration Tests
|
||||
* HTTP E2E Tests
|
||||
* Business Workflow Tests
|
||||
* Security Tests
|
||||
* Backup & Restore Tests
|
||||
* Disaster Recovery Tests
|
||||
* Migration Tests
|
||||
* Upgrade Validation Tests
|
||||
* Namespace Integrity Tests
|
||||
* Ownership Isolation Tests
|
||||
* Dashboard Parity Tests
|
||||
* QR Endpoint Tests
|
||||
* Concurrency Tests
|
||||
* WAL Recovery Tests
|
||||
|
||||
The platform currently executes approximately 100+ automated tests.
|
||||
|
||||
---
|
||||
|
||||
# 1. Build Validation
|
||||
|
||||
Verify successful compilation.
|
||||
|
||||
```bash
|
||||
cargo check
|
||||
@@ -36,261 +70,79 @@ cargo build
|
||||
cargo build --release
|
||||
```
|
||||
|
||||
Expected Result:
|
||||
Expected:
|
||||
|
||||
* No compiler errors
|
||||
* No panics during startup
|
||||
* Release binary generated successfully
|
||||
* No compilation failures
|
||||
* Release binary generated
|
||||
|
||||
---
|
||||
|
||||
## 2. Static Analysis
|
||||
# 2. Formatting Validation
|
||||
|
||||
```bash
|
||||
cargo fmt --check
|
||||
cargo clippy --all-targets
|
||||
```
|
||||
|
||||
Expected Result:
|
||||
Expected:
|
||||
|
||||
* Formatting passes
|
||||
* No significant Clippy warnings
|
||||
* No formatting errors
|
||||
|
||||
---
|
||||
|
||||
## 3. Unit Tests
|
||||
# 3. Static Analysis
|
||||
|
||||
```bash
|
||||
cargo test
|
||||
cargo clippy --all-targets -- -D warnings
|
||||
```
|
||||
|
||||
Expected Result:
|
||||
Expected:
|
||||
|
||||
* Zero warnings
|
||||
* Zero errors
|
||||
|
||||
---
|
||||
|
||||
# 4. Complete Automated Test Suite
|
||||
|
||||
```bash
|
||||
cargo test --all-targets -- --nocapture
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
* All tests pass
|
||||
* No failures
|
||||
* No ignored critical tests
|
||||
|
||||
---
|
||||
|
||||
## 4. Database Initialization
|
||||
# 5. Database Initialization Validation
|
||||
|
||||
Create a clean environment.
|
||||
|
||||
```bash
|
||||
rm -rf data
|
||||
|
||||
./bzod stats
|
||||
```
|
||||
|
||||
Expected Result:
|
||||
|
||||
* Databases are automatically created
|
||||
* Migrations applied successfully
|
||||
|
||||
Verify:
|
||||
|
||||
```bash
|
||||
./bzod doctor
|
||||
```
|
||||
|
||||
Expected Result:
|
||||
|
||||
```text
|
||||
Overall status: HEALTHY
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Migration Verification
|
||||
|
||||
Run migrations repeatedly.
|
||||
|
||||
```bash
|
||||
./bzod migrate
|
||||
./bzod migrate
|
||||
./bzod migrate
|
||||
```
|
||||
|
||||
Expected Result:
|
||||
|
||||
* No duplicate migrations
|
||||
* No errors
|
||||
* Schema remains stable
|
||||
|
||||
---
|
||||
|
||||
## 6. Administrator Creation
|
||||
|
||||
Create an administrator account.
|
||||
|
||||
```bash
|
||||
./bzod create-admin
|
||||
```
|
||||
|
||||
Expected Result:
|
||||
|
||||
* User created successfully
|
||||
* Authentication works
|
||||
|
||||
Attempt duplicate creation:
|
||||
|
||||
```bash
|
||||
./bzod create-admin
|
||||
```
|
||||
|
||||
Expected Result:
|
||||
|
||||
* Duplicate username rejected
|
||||
|
||||
---
|
||||
|
||||
## 7. Backup Verification
|
||||
|
||||
Create backup archive.
|
||||
|
||||
```bash
|
||||
./bzod backup
|
||||
```
|
||||
|
||||
Expected Result:
|
||||
|
||||
* Backup archive generated
|
||||
* Archive contains all databases
|
||||
|
||||
Verify:
|
||||
|
||||
```bash
|
||||
tar -tzf backup-*.tar.gz
|
||||
```
|
||||
|
||||
Expected Result:
|
||||
|
||||
```text
|
||||
admin.db
|
||||
content.db
|
||||
analytics.db
|
||||
system.db
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Restore Verification
|
||||
|
||||
Create sample data.
|
||||
|
||||
Generate:
|
||||
|
||||
* Administrator
|
||||
* URL records
|
||||
* Landing pages
|
||||
* Analytics records
|
||||
|
||||
Create backup:
|
||||
|
||||
```bash
|
||||
./bzod backup
|
||||
```
|
||||
|
||||
Delete databases:
|
||||
Create clean environment:
|
||||
|
||||
```bash
|
||||
rm -rf data
|
||||
```
|
||||
|
||||
Restore:
|
||||
|
||||
```bash
|
||||
./bzod restore --file backup.tar.gz
|
||||
```
|
||||
|
||||
Expected Result:
|
||||
|
||||
* Restore completes successfully
|
||||
* All records preserved
|
||||
|
||||
Verify:
|
||||
|
||||
```bash
|
||||
./bzod doctor
|
||||
./bzod stats
|
||||
```
|
||||
|
||||
Expected Result:
|
||||
|
||||
```text
|
||||
Overall status: HEALTHY
|
||||
```
|
||||
|
||||
and original record counts preserved.
|
||||
|
||||
---
|
||||
## 9. Disaster Recovery Scenario
|
||||
|
||||
1. Create backup
|
||||
2. Stop container
|
||||
3. Delete databases
|
||||
4. Restore from backup
|
||||
5. Fix permissions
|
||||
6. Restart container
|
||||
7. Validate:
|
||||
- URLs
|
||||
- Landing pages
|
||||
- Audit logs
|
||||
- Settings
|
||||
- Analytics
|
||||
- Status page
|
||||
|
||||
Expected Result:
|
||||
System fully restored without data loss.
|
||||
## 10. Disaster Recovery Test
|
||||
|
||||
This is the most important test.
|
||||
|
||||
Procedure:
|
||||
|
||||
1. Backup system.
|
||||
2. Delete entire data directory.
|
||||
3. Restore backup.
|
||||
4. Start server.
|
||||
5. Login to Admin UI.
|
||||
|
||||
Commands:
|
||||
|
||||
```bash
|
||||
./bzod backup
|
||||
|
||||
rm -rf data
|
||||
|
||||
./bzod restore --file backup.tar.gz
|
||||
|
||||
./bzod serve
|
||||
```
|
||||
|
||||
Expected Result:
|
||||
|
||||
* System fully operational
|
||||
* No manual database repair required
|
||||
|
||||
---
|
||||
|
||||
## 11. Database Health Verification
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
./bzod doctor
|
||||
bzod stats
|
||||
```
|
||||
|
||||
Expected Result:
|
||||
Expected:
|
||||
|
||||
For every database:
|
||||
* Database hierarchy created
|
||||
* Migrations applied
|
||||
* System healthy
|
||||
|
||||
```text
|
||||
Integrity: ok
|
||||
Foreign keys: enabled
|
||||
Journal mode: wal
|
||||
Validate:
|
||||
|
||||
```bash
|
||||
bzod doctor
|
||||
```
|
||||
|
||||
Final result:
|
||||
Expected:
|
||||
|
||||
```text
|
||||
Overall status: HEALTHY
|
||||
@@ -298,137 +150,367 @@ Overall status: HEALTHY
|
||||
|
||||
---
|
||||
|
||||
## 12. SQLite Integrity Checks
|
||||
# 6. Namespace Integrity Validation
|
||||
|
||||
Manual verification.
|
||||
BZOD maintains a global slug namespace.
|
||||
|
||||
```bash
|
||||
sqlite3 data/admin.db "PRAGMA integrity_check;"
|
||||
sqlite3 data/content.db "PRAGMA integrity_check;"
|
||||
sqlite3 data/analytics.db "PRAGMA integrity_check;"
|
||||
sqlite3 data/system.db "PRAGMA integrity_check;"
|
||||
```
|
||||
|
||||
Expected Result:
|
||||
The following must never coexist:
|
||||
|
||||
```text
|
||||
ok
|
||||
Admin URL
|
||||
hello
|
||||
|
||||
User URL
|
||||
hello
|
||||
|
||||
Landing Page
|
||||
hello
|
||||
```
|
||||
|
||||
for all databases.
|
||||
Validate:
|
||||
|
||||
```bash
|
||||
bzod doctor
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
```text
|
||||
No namespace conflicts detected
|
||||
```
|
||||
|
||||
Duplicate slugs must abort upgrade and restore operations.
|
||||
|
||||
---
|
||||
|
||||
## 13. Web Interface Verification
|
||||
# 7. Multi-User Isolation Validation
|
||||
|
||||
Start server.
|
||||
Verify:
|
||||
|
||||
```bash
|
||||
./bzod serve
|
||||
* User A cannot access User B URLs
|
||||
* User A cannot access User B Pages
|
||||
* User A cannot access User B Analytics
|
||||
* User A cannot export User B analytics
|
||||
|
||||
Expected:
|
||||
|
||||
```http
|
||||
403 Forbidden
|
||||
```
|
||||
|
||||
for all unauthorized access.
|
||||
|
||||
---
|
||||
|
||||
# 8. Dashboard Parity Validation
|
||||
|
||||
Verify:
|
||||
|
||||
## Administrator URLs
|
||||
|
||||
Contains:
|
||||
|
||||
* Analytics
|
||||
* QR Preview
|
||||
* PNG Download
|
||||
* SVG Download
|
||||
|
||||
## User URLs
|
||||
|
||||
Contains identical functionality.
|
||||
|
||||
Differences allowed:
|
||||
|
||||
* User Management
|
||||
* Moderation
|
||||
* Backups
|
||||
* Health
|
||||
* Audit
|
||||
* Quotas
|
||||
|
||||
Everything else must match.
|
||||
|
||||
---
|
||||
|
||||
# 9. Analytics Validation
|
||||
|
||||
Verify:
|
||||
|
||||
* URL Analytics
|
||||
* Landing Page Analytics
|
||||
* CSV Export
|
||||
* JSON Export
|
||||
* Date Filters
|
||||
* Charts
|
||||
* Referrer Breakdown
|
||||
* Country Breakdown
|
||||
* Browser Breakdown
|
||||
* Device Breakdown
|
||||
|
||||
Expected:
|
||||
|
||||
Administrator and owner views return identical analytics.
|
||||
|
||||
---
|
||||
|
||||
# 10. QR Validation
|
||||
|
||||
Verify:
|
||||
|
||||
```text
|
||||
/api/qr/<slug>.png
|
||||
/api/qr/<slug>.svg
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
```http
|
||||
200 OK
|
||||
```
|
||||
|
||||
Verify:
|
||||
|
||||
* Homepage loads
|
||||
* Redirects function
|
||||
* Landing pages render
|
||||
* Admin login works
|
||||
* Dashboard loads
|
||||
* API endpoints respond
|
||||
```text
|
||||
Content-Type: image/png
|
||||
Content-Type: image/svg+xml
|
||||
```
|
||||
|
||||
Disabled resources:
|
||||
|
||||
```http
|
||||
410 Gone
|
||||
```
|
||||
|
||||
Missing resources:
|
||||
|
||||
```http
|
||||
404 Not Found
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 14. Docker Verification
|
||||
# 11. Routing Validation
|
||||
|
||||
Build image.
|
||||
URL resources:
|
||||
|
||||
```text
|
||||
/<slug>
|
||||
```
|
||||
|
||||
must redirect correctly.
|
||||
|
||||
Landing Pages:
|
||||
|
||||
```text
|
||||
/<slug>
|
||||
```
|
||||
|
||||
must redirect permanently to:
|
||||
|
||||
```text
|
||||
/p/<slug>
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
```http
|
||||
301 Moved Permanently
|
||||
```
|
||||
|
||||
and:
|
||||
|
||||
```http
|
||||
200 OK
|
||||
```
|
||||
|
||||
for final landing page render.
|
||||
|
||||
Root landing page:
|
||||
|
||||
```text
|
||||
GET /
|
||||
```
|
||||
|
||||
must serve the static landing page.
|
||||
|
||||
Expected:
|
||||
|
||||
```http
|
||||
200 OK
|
||||
Content-Type: text/html
|
||||
```
|
||||
|
||||
Redirect security:
|
||||
|
||||
Redirect destinations are validated against:
|
||||
|
||||
* Invalid URL schemes
|
||||
* CRLF injection attempts
|
||||
* Control character injection
|
||||
* Malformed HTTP Location header values
|
||||
|
||||
Invalid destinations must return:
|
||||
|
||||
```http
|
||||
500 Internal Server Error
|
||||
```
|
||||
|
||||
and must not panic or produce malformed HTTP responses.
|
||||
|
||||
---
|
||||
|
||||
# 12. Backup Validation
|
||||
|
||||
Create backup:
|
||||
|
||||
```bash
|
||||
bzod backup
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
Archive generated successfully.
|
||||
|
||||
Validate archive contents.
|
||||
|
||||
---
|
||||
|
||||
# 13. Restore Validation
|
||||
|
||||
Restore backup:
|
||||
|
||||
```bash
|
||||
bzod restore --file backup.tar.gz
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
* Restore succeeds
|
||||
* All data preserved
|
||||
* Namespace integrity preserved
|
||||
|
||||
---
|
||||
|
||||
# 14. Collision Protection Validation
|
||||
|
||||
Attempt restore containing duplicate slugs.
|
||||
|
||||
Expected:
|
||||
|
||||
```text
|
||||
Restore aborted
|
||||
Slug conflict detected
|
||||
```
|
||||
|
||||
No partial restore.
|
||||
|
||||
---
|
||||
|
||||
# 15. Upgrade Validation
|
||||
|
||||
Verify upgrade from legacy deployments.
|
||||
|
||||
Expected:
|
||||
|
||||
* User databases migrated
|
||||
* Analytics preserved
|
||||
* Links preserved
|
||||
* Landing pages preserved
|
||||
* Authentication preserved
|
||||
|
||||
Duplicate slugs must abort upgrade.
|
||||
|
||||
---
|
||||
|
||||
# 16. Disaster Recovery Validation
|
||||
|
||||
Procedure:
|
||||
|
||||
1. Backup system
|
||||
2. Stop service
|
||||
3. Remove data directory
|
||||
4. Restore backup
|
||||
5. Start service
|
||||
|
||||
Expected:
|
||||
|
||||
* Full recovery
|
||||
* No manual repair
|
||||
* All URLs functional
|
||||
* All Landing Pages functional
|
||||
* Analytics preserved
|
||||
|
||||
---
|
||||
|
||||
# 17. Docker Validation
|
||||
|
||||
```bash
|
||||
docker compose build --no-cache
|
||||
```
|
||||
|
||||
Start service.
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Verify:
|
||||
|
||||
```bash
|
||||
docker compose logs -f
|
||||
docker compose logs
|
||||
```
|
||||
|
||||
Expected Result:
|
||||
Expected:
|
||||
|
||||
```text
|
||||
Listening for requests
|
||||
Server started successfully
|
||||
```
|
||||
|
||||
Container health:
|
||||
|
||||
```text
|
||||
healthy
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# 18. WAL Recovery Validation
|
||||
|
||||
Verify:
|
||||
|
||||
```bash
|
||||
./bzod doctor
|
||||
```
|
||||
|
||||
inside container.
|
||||
* SQLite WAL mode enabled
|
||||
* Recovery after backup succeeds
|
||||
* No corruption detected
|
||||
|
||||
---
|
||||
|
||||
## 15. Upgrade Verification
|
||||
# Release Validation Checklist
|
||||
|
||||
1. Create backup.
|
||||
2. Upgrade binary.
|
||||
3. Run migration.
|
||||
4. Start service.
|
||||
Before every release:
|
||||
|
||||
```bash
|
||||
./bzod backup
|
||||
cargo fmt --check
|
||||
|
||||
./bzod migrate
|
||||
cargo clippy --all-targets -- -D warnings
|
||||
|
||||
./bzod serve
|
||||
cargo test --all-targets -- --nocapture
|
||||
|
||||
cargo build --release
|
||||
|
||||
cargo audit
|
||||
```
|
||||
|
||||
Expected Result:
|
||||
|
||||
* Existing data preserved
|
||||
* No migration failures
|
||||
|
||||
---
|
||||
## Analytics Verification
|
||||
|
||||
Verify:
|
||||
|
||||
* URL analytics page loads
|
||||
* Landing page analytics page loads
|
||||
* Visitor activity table renders
|
||||
* Empty visitor tables render correctly
|
||||
* CSV export downloads successfully
|
||||
* JSON export downloads successfully
|
||||
* Date filtering works
|
||||
* Invalid date filters return HTTP 400
|
||||
* Pagination preserves active filters
|
||||
* Exports respect active filters
|
||||
# Release Acceptance Criteria
|
||||
|
||||
A release is considered production-ready only if:
|
||||
|
||||
* Build verification passes
|
||||
* Static analysis passes
|
||||
* Unit tests pass
|
||||
* Backup verification passes
|
||||
* Restore verification passes
|
||||
* Disaster recovery verification passes
|
||||
* Doctor reports HEALTHY
|
||||
* Docker deployment succeeds
|
||||
* Web UI functions correctly
|
||||
|
||||
Failure of backup, restore, or disaster recovery tests is considered a release blocker.
|
||||
Release is approved only if all steps succeed.
|
||||
|
||||
---
|
||||
|
||||
# Guiding Principle
|
||||
# Release Blockers
|
||||
|
||||
A successful release is not merely one that starts.
|
||||
The following are release blockers:
|
||||
|
||||
A successful release is one that can be recovered.
|
||||
* Namespace conflicts
|
||||
* Backup failure
|
||||
* Restore failure
|
||||
* Upgrade failure
|
||||
* Multi-user isolation failure
|
||||
* Ownership validation failure
|
||||
* Security test failure
|
||||
* Data corruption
|
||||
* Disaster recovery failure
|
||||
|
||||
A release that cannot be restored is not considered production ready.
|
||||
+795
@@ -0,0 +1,795 @@
|
||||
# Upgrade Guide
|
||||
|
||||
Version: v0.6.0
|
||||
|
||||
This document describes the upgrade process for existing BZOD deployments upgrading to BZOD v0.5.1.
|
||||
|
||||
---
|
||||
|
||||
# Overview
|
||||
|
||||
BZOD v0.5.1 is a platform hardening release focused on:
|
||||
|
||||
* Global namespace integrity
|
||||
* Multi-tenant safety
|
||||
* Dashboard parity
|
||||
* QR reliability
|
||||
* Upgrade validation
|
||||
* Restore collision protection
|
||||
* Ownership isolation
|
||||
|
||||
While v0.5.0 introduced the multi-user architecture, v0.5.1 strengthens the operational and data integrity guarantees required for production deployments.
|
||||
|
||||
---
|
||||
|
||||
# Supported Upgrade Paths
|
||||
|
||||
Supported:
|
||||
|
||||
```text
|
||||
v0.5.0 → v0.5.1
|
||||
v0.4.x → v0.5.1
|
||||
```
|
||||
|
||||
Recommended:
|
||||
|
||||
```text
|
||||
v0.4.x → v0.5.0 → v0.5.1
|
||||
```
|
||||
|
||||
Unsupported:
|
||||
|
||||
```text
|
||||
v0.3.x → v0.5.1
|
||||
```
|
||||
|
||||
Older installations should first upgrade to v0.4.x.
|
||||
|
||||
---
|
||||
|
||||
# Major Changes in v0.5.1
|
||||
|
||||
## Global Namespace Enforcement
|
||||
|
||||
BZOD now enforces a single platform-wide slug namespace.
|
||||
|
||||
The following resources can no longer share the same slug:
|
||||
|
||||
* Administrator URLs
|
||||
* Administrator Landing Pages
|
||||
* User URLs
|
||||
* User Landing Pages
|
||||
|
||||
Example:
|
||||
|
||||
```text
|
||||
Admin URL:
|
||||
hello
|
||||
|
||||
User URL:
|
||||
hello
|
||||
```
|
||||
|
||||
Result:
|
||||
|
||||
```text
|
||||
Upgrade aborted.
|
||||
Namespace conflict detected.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Global Slug Registry
|
||||
|
||||
BZOD now treats the slug registry as the authoritative source of truth.
|
||||
|
||||
All slugs are registered in:
|
||||
|
||||
```text
|
||||
system.db
|
||||
```
|
||||
|
||||
Table:
|
||||
|
||||
```text
|
||||
global_slugs
|
||||
```
|
||||
|
||||
The registry tracks:
|
||||
|
||||
```text
|
||||
slug
|
||||
owner_user_id
|
||||
target_type
|
||||
target_id
|
||||
status
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Reservation-Based Slug Allocation
|
||||
|
||||
Slug creation now follows:
|
||||
|
||||
```text
|
||||
Quota Validation
|
||||
↓
|
||||
Reserve Global Slug
|
||||
↓
|
||||
Create Resource
|
||||
↓
|
||||
Activate Slug
|
||||
↓
|
||||
Update Quotas
|
||||
↓
|
||||
Audit Log
|
||||
```
|
||||
|
||||
Benefits:
|
||||
|
||||
* Prevents race conditions
|
||||
* Prevents duplicate allocations
|
||||
* Improves rollback safety
|
||||
* Improves multi-user integrity
|
||||
|
||||
---
|
||||
|
||||
## Stale Reservation Recovery
|
||||
|
||||
BZOD automatically cleans abandoned reservations created by:
|
||||
|
||||
* Server crashes
|
||||
* Interrupted requests
|
||||
* Failed transactions
|
||||
|
||||
Stale reservations are validated and cleaned during startup.
|
||||
|
||||
---
|
||||
|
||||
# Breaking Changes
|
||||
|
||||
## Global Slug Uniqueness
|
||||
|
||||
Deployments containing duplicate slugs will not upgrade.
|
||||
|
||||
Example:
|
||||
|
||||
```text
|
||||
User 1:
|
||||
!nx9-dns-server
|
||||
|
||||
User 3:
|
||||
!nx9-dns-server
|
||||
```
|
||||
|
||||
Result:
|
||||
|
||||
```text
|
||||
Upgrade aborted.
|
||||
|
||||
Database upgrade aborted due to slug conflicts.
|
||||
```
|
||||
|
||||
Conflicts must be resolved before migration can continue.
|
||||
|
||||
---
|
||||
|
||||
## Restore Collision Protection
|
||||
|
||||
Restore operations now validate namespace integrity.
|
||||
|
||||
Example:
|
||||
|
||||
```text
|
||||
Existing slug:
|
||||
company
|
||||
|
||||
Backup slug:
|
||||
company
|
||||
```
|
||||
|
||||
Result:
|
||||
|
||||
```text
|
||||
Restore aborted.
|
||||
Slug conflict detected.
|
||||
```
|
||||
|
||||
No partial restore occurs.
|
||||
|
||||
---
|
||||
|
||||
# Pre-Upgrade Checklist
|
||||
|
||||
Before upgrading:
|
||||
|
||||
* Create backup
|
||||
* Verify backup integrity
|
||||
* Stop active traffic
|
||||
* Run diagnostics
|
||||
* Resolve namespace conflicts
|
||||
|
||||
---
|
||||
|
||||
# Step 1: Create Backup
|
||||
|
||||
Full backup:
|
||||
|
||||
```bash
|
||||
bzod backup
|
||||
```
|
||||
|
||||
Manual backup:
|
||||
|
||||
```bash
|
||||
tar czf bzod-backup.tar.gz data/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Step 2: Verify Backup
|
||||
|
||||
Verify archive contents:
|
||||
|
||||
```text
|
||||
users.db
|
||||
system.db
|
||||
|
||||
users/
|
||||
```
|
||||
|
||||
If upgrading from legacy versions:
|
||||
|
||||
```text
|
||||
admin.db
|
||||
content.db
|
||||
analytics.db
|
||||
```
|
||||
|
||||
should also be present.
|
||||
|
||||
---
|
||||
|
||||
# Step 3: Run Diagnostics
|
||||
|
||||
Execute:
|
||||
|
||||
```bash
|
||||
bzod doctor
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
```text
|
||||
Overall Status: HEALTHY
|
||||
```
|
||||
|
||||
Verify:
|
||||
|
||||
```text
|
||||
No namespace conflicts detected
|
||||
No ownership violations detected
|
||||
No registry corruption detected
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Step 4: Stop Service
|
||||
|
||||
Systemd:
|
||||
|
||||
```bash
|
||||
sudo systemctl stop bzod
|
||||
```
|
||||
|
||||
Docker:
|
||||
|
||||
```bash
|
||||
docker compose down
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Upgrade Procedure
|
||||
|
||||
## Install New Version
|
||||
|
||||
Build:
|
||||
|
||||
```bash
|
||||
cargo build --release
|
||||
```
|
||||
|
||||
Or install official release binary.
|
||||
|
||||
---
|
||||
|
||||
## Start BZOD
|
||||
|
||||
```bash
|
||||
bzod serve
|
||||
```
|
||||
|
||||
or:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Automatic Upgrade Actions
|
||||
|
||||
During startup BZOD automatically performs:
|
||||
|
||||
1. Database migration checks
|
||||
2. Namespace integrity validation
|
||||
3. Registry validation
|
||||
4. Stale reservation cleanup
|
||||
5. Global slug verification
|
||||
6. Schema migration execution
|
||||
|
||||
---
|
||||
|
||||
# Namespace Validation
|
||||
|
||||
BZOD scans:
|
||||
|
||||
```text
|
||||
legacy databases
|
||||
administrator databases
|
||||
tenant databases
|
||||
```
|
||||
|
||||
for duplicate slugs.
|
||||
|
||||
Example:
|
||||
|
||||
```text
|
||||
Owner 1:
|
||||
hello
|
||||
|
||||
Owner 3:
|
||||
hello
|
||||
```
|
||||
|
||||
Result:
|
||||
|
||||
```text
|
||||
Namespace conflict detected.
|
||||
Upgrade aborted.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Registry Validation
|
||||
|
||||
BZOD validates:
|
||||
|
||||
* Duplicate slug entries
|
||||
* Missing owners
|
||||
* Missing targets
|
||||
* Invalid target types
|
||||
* Invalid status values
|
||||
|
||||
Allowed target types:
|
||||
|
||||
```text
|
||||
url
|
||||
page
|
||||
```
|
||||
|
||||
Allowed statuses:
|
||||
|
||||
```text
|
||||
reserving
|
||||
active
|
||||
disabled
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Post-Upgrade Validation
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
bzod doctor
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
```text
|
||||
Namespace Integrity: PASS
|
||||
Registry Integrity: PASS
|
||||
Ownership Integrity: PASS
|
||||
Database Integrity: PASS
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Login Validation
|
||||
|
||||
Verify:
|
||||
|
||||
```text
|
||||
Administrator login succeeds
|
||||
User login succeeds
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# URL Validation
|
||||
|
||||
Verify:
|
||||
|
||||
```text
|
||||
https://example.com/abc123
|
||||
```
|
||||
|
||||
redirects correctly.
|
||||
|
||||
Expected:
|
||||
|
||||
```http
|
||||
302 Found
|
||||
```
|
||||
|
||||
or configured redirect behavior.
|
||||
|
||||
---
|
||||
|
||||
# Landing Page Validation
|
||||
|
||||
Verify:
|
||||
|
||||
```text
|
||||
https://example.com/p/demo
|
||||
```
|
||||
|
||||
renders successfully.
|
||||
|
||||
Verify:
|
||||
|
||||
```text
|
||||
https://example.com/demo
|
||||
```
|
||||
|
||||
redirects permanently:
|
||||
|
||||
```http
|
||||
301 Moved Permanently
|
||||
```
|
||||
|
||||
to:
|
||||
|
||||
```text
|
||||
/p/demo
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# QR Validation
|
||||
|
||||
Verify:
|
||||
|
||||
```text
|
||||
/api/qr/demo.png
|
||||
/api/qr/demo.svg
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
```http
|
||||
200 OK
|
||||
```
|
||||
|
||||
Content types:
|
||||
|
||||
```text
|
||||
image/png
|
||||
image/svg+xml
|
||||
```
|
||||
|
||||
Disabled resources:
|
||||
|
||||
```http
|
||||
410 Gone
|
||||
```
|
||||
|
||||
Missing resources:
|
||||
|
||||
```http
|
||||
404 Not Found
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Dashboard Validation
|
||||
|
||||
Verify Administrator Dashboards:
|
||||
|
||||
* URLs
|
||||
* Landing Pages
|
||||
* Analytics
|
||||
* QR Preview
|
||||
* PNG Download
|
||||
* SVG Download
|
||||
|
||||
Verify Standard User Dashboards:
|
||||
|
||||
* URLs
|
||||
* Landing Pages
|
||||
* Analytics
|
||||
* QR Preview
|
||||
* PNG Download
|
||||
* SVG Download
|
||||
|
||||
Both should provide equivalent functionality except for administrator-only operations.
|
||||
|
||||
---
|
||||
|
||||
# Ownership Isolation Validation
|
||||
|
||||
Verify:
|
||||
|
||||
```text
|
||||
User A
|
||||
```
|
||||
|
||||
cannot access:
|
||||
|
||||
```text
|
||||
User B Analytics
|
||||
User B URLs
|
||||
User B Landing Pages
|
||||
User B Exports
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
```http
|
||||
403 Forbidden
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Backup & Restore Validation
|
||||
|
||||
Create backup:
|
||||
|
||||
```bash
|
||||
bzod backup
|
||||
```
|
||||
|
||||
Restore backup:
|
||||
|
||||
```bash
|
||||
bzod restore backup.tar.gz
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
* No namespace conflicts
|
||||
* No ownership conflicts
|
||||
* No partial restores
|
||||
|
||||
---
|
||||
|
||||
# Rollback Procedure
|
||||
|
||||
If upgrade validation fails:
|
||||
|
||||
Stop service:
|
||||
|
||||
```bash
|
||||
sudo systemctl stop bzod
|
||||
```
|
||||
|
||||
or:
|
||||
|
||||
```bash
|
||||
docker compose down
|
||||
```
|
||||
|
||||
Restore backup:
|
||||
|
||||
```bash
|
||||
bzod restore backup.tar.gz
|
||||
```
|
||||
|
||||
or restore archived data directory.
|
||||
|
||||
Reinstall previous release.
|
||||
|
||||
---
|
||||
|
||||
# Docker Upgrade
|
||||
|
||||
Pull image:
|
||||
|
||||
```bash
|
||||
docker compose pull
|
||||
```
|
||||
|
||||
Restart:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Monitor:
|
||||
|
||||
```bash
|
||||
docker compose logs -f
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
```text
|
||||
Namespace validation passed
|
||||
Registry validation passed
|
||||
Server started successfully
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Systemd Upgrade
|
||||
|
||||
Replace binary:
|
||||
|
||||
```bash
|
||||
sudo cp bzod /usr/local/bin/
|
||||
```
|
||||
|
||||
Restart:
|
||||
|
||||
```bash
|
||||
sudo systemctl restart bzod
|
||||
```
|
||||
|
||||
Verify:
|
||||
|
||||
```bash
|
||||
sudo systemctl status bzod
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
```text
|
||||
active (running)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Automated Upgrade Validation
|
||||
|
||||
Execute:
|
||||
|
||||
```bash
|
||||
cargo fmt --check
|
||||
cargo clippy --all-targets -- -D warnings
|
||||
cargo test --all-targets -- --nocapture
|
||||
```
|
||||
|
||||
Particularly validate:
|
||||
|
||||
```text
|
||||
upgrade_validation_tests
|
||||
backup_restore_tests
|
||||
slug_registry_tests
|
||||
ownership_tests
|
||||
analytics_parity_tests
|
||||
transaction_tests
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Recommended Upgrade Workflow
|
||||
|
||||
```text
|
||||
1. Create Backup
|
||||
2. Verify Backup
|
||||
3. Run bzod doctor
|
||||
4. Resolve Namespace Conflicts
|
||||
5. Stop Service
|
||||
6. Install v0.5.1
|
||||
7. Start Service
|
||||
8. Validate Registry
|
||||
9. Validate URLs
|
||||
10. Validate Landing Pages
|
||||
11. Validate QR Endpoints
|
||||
12. Validate Dashboards
|
||||
13. Validate Ownership Isolation
|
||||
14. Return To Production
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Troubleshooting
|
||||
|
||||
## Upgrade Aborted Due To Slug Conflicts
|
||||
|
||||
Example:
|
||||
|
||||
```text
|
||||
Slug '!nx9-dns-server'
|
||||
is defined in multiple content databases
|
||||
by owners [1,3]
|
||||
```
|
||||
|
||||
Cause:
|
||||
|
||||
```text
|
||||
Duplicate slug detected.
|
||||
```
|
||||
|
||||
Resolution:
|
||||
|
||||
```text
|
||||
Rename or remove conflicting resources.
|
||||
Restart upgrade.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## QR Codes Return 404
|
||||
|
||||
Verify:
|
||||
|
||||
```text
|
||||
global_slugs
|
||||
```
|
||||
|
||||
contains the slug.
|
||||
|
||||
Verify slug status:
|
||||
|
||||
```text
|
||||
active
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Landing Page Redirect Fails
|
||||
|
||||
Verify:
|
||||
|
||||
```text
|
||||
target_type = page
|
||||
```
|
||||
|
||||
in:
|
||||
|
||||
```text
|
||||
global_slugs
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Ownership Errors
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
bzod doctor
|
||||
```
|
||||
|
||||
Verify ownership integrity passes.
|
||||
|
||||
---
|
||||
|
||||
# Upgrade Status
|
||||
|
||||
BZOD v0.5.1 upgrade path has been validated through:
|
||||
|
||||
* Migration Tests
|
||||
* Upgrade Validation Tests
|
||||
* Namespace Integrity Tests
|
||||
* Ownership Isolation Tests
|
||||
* Backup & Restore Tests
|
||||
* Dashboard Parity Tests
|
||||
* QR Endpoint Tests
|
||||
* Routing Tests
|
||||
|
||||
The v0.5.1 upgrade path is considered production-ready.
|
||||
@@ -8,15 +8,19 @@ pub struct AnalyticsQueue {
|
||||
}
|
||||
|
||||
impl AnalyticsQueue {
|
||||
pub fn new(db: Db, capacity: usize) -> Self {
|
||||
pub fn new(
|
||||
db: Db,
|
||||
capacity: usize,
|
||||
shutdown_rx: tokio::sync::watch::Receiver<bool>,
|
||||
) -> (Self, tokio::task::JoinHandle<()>) {
|
||||
let (sender, receiver) = mpsc::channel(capacity);
|
||||
|
||||
// Spawn background worker to batch-write records
|
||||
tokio::spawn(async move {
|
||||
super::worker::run_worker(db, receiver).await;
|
||||
let handle = tokio::spawn(async move {
|
||||
super::worker::run_worker(db, receiver, shutdown_rx).await;
|
||||
});
|
||||
|
||||
Self { sender }
|
||||
(Self { sender }, handle)
|
||||
}
|
||||
|
||||
// Attempt to queue a visit. Non-blocking.
|
||||
|
||||
+10
-1
@@ -7,7 +7,11 @@ use crate::db::analytics::insert_visits_batch;
|
||||
use crate::db::Db;
|
||||
use crate::models::VisitRecord;
|
||||
|
||||
pub async fn run_worker(db: Db, mut receiver: mpsc::Receiver<VisitRecord>) {
|
||||
pub async fn run_worker(
|
||||
db: Db,
|
||||
mut receiver: mpsc::Receiver<VisitRecord>,
|
||||
mut shutdown_rx: tokio::sync::watch::Receiver<bool>,
|
||||
) {
|
||||
let mut batch = Vec::new();
|
||||
let batch_size = 50;
|
||||
let flush_interval = Duration::from_secs(2);
|
||||
@@ -37,6 +41,11 @@ pub async fn run_worker(db: Db, mut receiver: mpsc::Receiver<VisitRecord>) {
|
||||
flush_batch(&db, &mut batch);
|
||||
}
|
||||
}
|
||||
_ = shutdown_rx.changed() => {
|
||||
info!("Analytics worker flushing pending records");
|
||||
flush_batch(&db, &mut batch);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,11 +4,42 @@ use crate::state::AppState;
|
||||
use axum::{
|
||||
extract::{FromRef, FromRequestParts},
|
||||
http::{request::Parts, StatusCode},
|
||||
Json,
|
||||
};
|
||||
|
||||
// Extractor: Authenticate API requests using Bearer token
|
||||
pub struct ApiUser(pub ApiActor);
|
||||
|
||||
impl ApiUser {
|
||||
pub fn require_admin(
|
||||
&self,
|
||||
) -> Result<&crate::models::User, (StatusCode, Json<crate::web::api::ApiError>)> {
|
||||
match &self.0 {
|
||||
ApiActor::Admin(u) => Ok(u),
|
||||
_ => Err((
|
||||
StatusCode::FORBIDDEN,
|
||||
Json(crate::web::api::ApiError {
|
||||
error: "Admin privileges required".to_string(),
|
||||
}),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn require_tenant(
|
||||
&self,
|
||||
) -> Result<&crate::models::TenantUser, (StatusCode, Json<crate::web::api::ApiError>)> {
|
||||
match &self.0 {
|
||||
ApiActor::User(u) => Ok(u),
|
||||
_ => Err((
|
||||
StatusCode::FORBIDDEN,
|
||||
Json(crate::web::api::ApiError {
|
||||
error: "Tenant privileges required".to_string(),
|
||||
}),
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[axum::async_trait]
|
||||
impl<S> FromRequestParts<S> for ApiUser
|
||||
where
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
use crate::config::Config;
|
||||
use crate::db::Db;
|
||||
use rusqlite::Connection;
|
||||
use std::path::PathBuf;
|
||||
use tracing::{error, info};
|
||||
|
||||
pub async fn run(
|
||||
target_admin_id: i64,
|
||||
data_dir: Option<String>,
|
||||
dry_run: bool,
|
||||
force: bool,
|
||||
mut config: Config,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
if let Some(d) = data_dir {
|
||||
config.data_dir = PathBuf::from(d);
|
||||
}
|
||||
let db = Db::init(&config)?;
|
||||
|
||||
// 1. Verify target admin exists and is an admin
|
||||
let target_user = {
|
||||
let conn = db.users.lock().unwrap();
|
||||
crate::db::users::get_user_by_id(&conn, target_admin_id)?
|
||||
};
|
||||
|
||||
let target_user = match target_user {
|
||||
Some(u) => u,
|
||||
None => {
|
||||
error!("Target admin ID {} not found", target_admin_id);
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
if target_user.account_type != "admin" {
|
||||
error!(
|
||||
"Target user '{}' (ID {}) is not an admin account.",
|
||||
target_user.username, target_admin_id
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if target_admin_id == 1 {
|
||||
error!("Target admin ID cannot be 1 (legacy admin).");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// 2. Open databases
|
||||
let legacy_content_path = config.data_dir.join("users").join("1").join("content.db");
|
||||
|
||||
if !legacy_content_path.exists() {
|
||||
info!(
|
||||
"No legacy admin content database found at {:?}",
|
||||
legacy_content_path
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
db.init_user_databases(target_admin_id)?;
|
||||
let target_content_path = config
|
||||
.data_dir
|
||||
.join("users")
|
||||
.join(target_admin_id.to_string())
|
||||
.join("content.db");
|
||||
|
||||
let mut legacy_conn = Connection::open(&legacy_content_path)?;
|
||||
let mut target_conn = Connection::open(&target_content_path)?;
|
||||
let mut system_conn = db.system.lock().unwrap();
|
||||
|
||||
println!("Scanning legacy admin content database...");
|
||||
|
||||
// 3. Count items
|
||||
let urls = {
|
||||
let mut stmt = legacy_conn.prepare("SELECT * FROM urls;")?;
|
||||
let mut rows = stmt.query([])?;
|
||||
let mut data = Vec::new();
|
||||
while let Ok(Some(_)) = rows.next() {
|
||||
data.push(1);
|
||||
}
|
||||
data
|
||||
};
|
||||
let url_count = urls.len();
|
||||
|
||||
let pages = {
|
||||
let mut stmt = legacy_conn.prepare("SELECT * FROM landing_pages;")?;
|
||||
let mut rows = stmt.query([])?;
|
||||
let mut data = Vec::new();
|
||||
while let Ok(Some(_)) = rows.next() {
|
||||
data.push(1);
|
||||
}
|
||||
data
|
||||
};
|
||||
let page_count = pages.len();
|
||||
|
||||
println!(
|
||||
"Found {} URLs and {} Landing Pages owned by legacy admin (ID 1).",
|
||||
url_count, page_count
|
||||
);
|
||||
|
||||
if dry_run {
|
||||
println!("Dry run mode enabled. No changes will be made.");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if !force {
|
||||
println!("Migration requires the --force flag to execute. Aborting.");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
println!(
|
||||
"Starting migration to Admin '{}' (ID {})...",
|
||||
target_user.username, target_admin_id
|
||||
);
|
||||
|
||||
// 4. Perform Migration (using ATTACH DATABASE for fast copy)
|
||||
// We attach the legacy db to the target db to do INSERT INTO ... SELECT * FROM
|
||||
target_conn.execute(
|
||||
"ATTACH DATABASE ?1 AS legacy;",
|
||||
rusqlite::params![legacy_content_path.to_string_lossy()],
|
||||
)?;
|
||||
|
||||
let tx = target_conn.transaction()?;
|
||||
tx.execute("INSERT OR IGNORE INTO urls SELECT * FROM legacy.urls;", [])?;
|
||||
tx.execute(
|
||||
"INSERT OR IGNORE INTO landing_pages SELECT * FROM legacy.landing_pages;",
|
||||
[],
|
||||
)?;
|
||||
tx.commit()?;
|
||||
|
||||
target_conn.execute("DETACH DATABASE legacy;", [])?;
|
||||
|
||||
// 5. Update global registry
|
||||
let sys_tx = system_conn.transaction()?;
|
||||
let updated_slugs = sys_tx.execute(
|
||||
"UPDATE global_slugs SET owner_user_id = ?1 WHERE owner_user_id = 1;",
|
||||
rusqlite::params![target_admin_id],
|
||||
)?;
|
||||
sys_tx.commit()?;
|
||||
|
||||
// 6. Delete from legacy
|
||||
let legacy_tx = legacy_conn.transaction()?;
|
||||
legacy_tx.execute("DELETE FROM urls;", [])?;
|
||||
legacy_tx.execute("DELETE FROM landing_pages;", [])?;
|
||||
legacy_tx.commit()?;
|
||||
|
||||
println!("Migration Complete!");
|
||||
println!("-------------------");
|
||||
println!("Migrated {} URLs.", url_count);
|
||||
println!("Migrated {} Landing Pages.", page_count);
|
||||
println!("Updated {} slugs in global registry.", updated_slugs);
|
||||
println!("Cleared legacy content database.");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
use crate::config::Config;
|
||||
use crate::db::Db;
|
||||
use crate::services::destination_audit::{audit_all_destinations, format_report};
|
||||
use std::path::PathBuf;
|
||||
use tracing::info;
|
||||
|
||||
/// Read-only audit of all stored redirect destinations.
|
||||
///
|
||||
/// Does not rewrite, delete, or "repair" any records.
|
||||
pub async fn run(
|
||||
data_dir: Option<String>,
|
||||
mut config: Config,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
if let Some(d) = data_dir {
|
||||
config.data_dir = PathBuf::from(d);
|
||||
}
|
||||
|
||||
info!("Starting read-only destination audit...");
|
||||
let db = Db::init(&config)?;
|
||||
let report = audit_all_destinations(&db)?;
|
||||
print!("{}", format_report(&report));
|
||||
|
||||
if report.invalid > 0 {
|
||||
// Non-zero exit so automation can detect findings without treating them as crashes.
|
||||
Err(format!(
|
||||
"destination audit found {} invalid stored URL(s)",
|
||||
report.invalid
|
||||
)
|
||||
.into())
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
+106
-3
@@ -22,11 +22,25 @@ pub async fn run(
|
||||
println!("Data directory: {:?}", config.data_dir);
|
||||
println!();
|
||||
|
||||
let databases = ["admin", "content", "analytics", "system"];
|
||||
let mut all_healthy = true;
|
||||
|
||||
for db_name in &databases {
|
||||
let db_path = config.data_dir.join(format!("{}.db", db_name));
|
||||
// Define target databases in the new layout
|
||||
let admin_dir = config.data_dir.join("admin");
|
||||
let legacy_user_dir = config.data_dir.join("users").join("1");
|
||||
|
||||
let dbs = vec![
|
||||
("admin", admin_dir.join("admin.db")),
|
||||
("system", admin_dir.join("system.db")),
|
||||
("users", admin_dir.join("users.db")),
|
||||
("legacy content", legacy_user_dir.join("content.db")),
|
||||
("legacy analytics", legacy_user_dir.join("analytics.db")),
|
||||
];
|
||||
|
||||
for (db_name, db_path) in dbs {
|
||||
// Skip legacy databases if they don't exist
|
||||
if db_name.starts_with("legacy") && !db_path.exists() {
|
||||
continue;
|
||||
}
|
||||
|
||||
if !db_path.exists() {
|
||||
println!("Database: {}", db_name);
|
||||
@@ -75,6 +89,95 @@ pub async fn run(
|
||||
println!();
|
||||
}
|
||||
|
||||
// Global Slug Registry Integrity Check
|
||||
println!("Global Slug Registry Integrity Check");
|
||||
println!("====================================");
|
||||
let system_db_path = admin_dir.join("system.db");
|
||||
let users_db_path = admin_dir.join("users.db");
|
||||
|
||||
if system_db_path.exists() && users_db_path.exists() {
|
||||
match (
|
||||
Connection::open(&system_db_path),
|
||||
Connection::open(&users_db_path),
|
||||
) {
|
||||
(Ok(sys_conn), Ok(usr_conn)) => {
|
||||
match crate::services::registry_validator::RegistryValidator::scan(
|
||||
&sys_conn,
|
||||
&usr_conn,
|
||||
&config.data_dir,
|
||||
None,
|
||||
) {
|
||||
Ok(issues) => {
|
||||
if issues.is_empty() {
|
||||
println!(" Status: HEALTHY (no issues found)");
|
||||
} else {
|
||||
println!(" Status: ISSUES DETECTED");
|
||||
all_healthy = false;
|
||||
|
||||
for issue in &issues {
|
||||
println!();
|
||||
println!("ERROR");
|
||||
println!();
|
||||
println!("Slug:");
|
||||
println!(" {}", issue.slug);
|
||||
println!();
|
||||
println!("Type:");
|
||||
println!(
|
||||
" {}",
|
||||
if issue.target_type == "url" {
|
||||
"URL"
|
||||
} else if issue.target_type == "page" {
|
||||
"Landing Page"
|
||||
} else {
|
||||
&issue.target_type
|
||||
}
|
||||
);
|
||||
println!();
|
||||
println!("Owner:");
|
||||
println!(" User ID {}", issue.owner_user_id);
|
||||
println!();
|
||||
println!("Database:");
|
||||
println!(" {}", issue.database_path.display());
|
||||
println!();
|
||||
println!("Target UUID:");
|
||||
println!(" {}", issue.target_id);
|
||||
println!();
|
||||
println!("Issue:");
|
||||
println!(" {:?}", issue.issue_type);
|
||||
println!();
|
||||
println!("Description:");
|
||||
println!(" {}", issue.description);
|
||||
println!();
|
||||
println!("Suggested Repair:");
|
||||
println!();
|
||||
if issue.slug != "*" {
|
||||
println!(
|
||||
" bzod repair registry --slug {} --dry-run",
|
||||
issue.slug
|
||||
);
|
||||
} else {
|
||||
println!(" bzod repair registry --dry-run");
|
||||
}
|
||||
println!("--------------------");
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
println!(" Status: ERROR running registry scan: {}", e);
|
||||
all_healthy = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
println!(" Status: ERROR opening system.db or users.db for integrity check");
|
||||
all_healthy = false;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
println!(" Status: SKIPPED (system.db/users.db not found)");
|
||||
}
|
||||
println!();
|
||||
|
||||
println!("--------------------");
|
||||
if all_healthy {
|
||||
println!("Overall status: HEALTHY");
|
||||
|
||||
+50
-9
@@ -1,27 +1,30 @@
|
||||
use clap::{Parser, Subcommand};
|
||||
|
||||
pub mod admin_migrate;
|
||||
pub mod audit_destinations;
|
||||
pub mod backup;
|
||||
pub mod backup_user;
|
||||
pub mod create_admin;
|
||||
pub mod create_user;
|
||||
pub mod delete_user;
|
||||
pub mod disable_user;
|
||||
pub mod doctor;
|
||||
pub mod enable_user;
|
||||
pub mod expand;
|
||||
pub mod list_users;
|
||||
pub mod migrate;
|
||||
pub mod repair;
|
||||
pub mod reset_password;
|
||||
pub mod restore;
|
||||
pub mod restore_user;
|
||||
pub mod serve;
|
||||
pub mod shorten;
|
||||
pub mod stats;
|
||||
pub mod validate;
|
||||
|
||||
pub mod backup_user;
|
||||
pub mod create_user;
|
||||
pub mod delete_user;
|
||||
pub mod disable_user;
|
||||
pub mod enable_user;
|
||||
pub mod list_users;
|
||||
pub mod reset_password;
|
||||
pub mod restore_user;
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(name = "bzod")]
|
||||
#[command(version)]
|
||||
#[command(about = "BZOD - Personal Redirector & Landing Page Platform")]
|
||||
pub struct Cli {
|
||||
#[command(subcommand)]
|
||||
@@ -71,6 +74,11 @@ pub enum Commands {
|
||||
#[arg(long)]
|
||||
data_dir: Option<String>,
|
||||
},
|
||||
/// Read-only audit of stored redirect destinations (schemes, control chars, malformed)
|
||||
AuditDestinations {
|
||||
#[arg(long)]
|
||||
data_dir: Option<String>,
|
||||
},
|
||||
/// Create a new administrator user in the database
|
||||
CreateAdmin {
|
||||
#[arg(long)]
|
||||
@@ -165,4 +173,37 @@ pub enum Commands {
|
||||
#[arg(long)]
|
||||
data_dir: Option<String>,
|
||||
},
|
||||
/// FUTURE: Migrate legacy admin content to a specific admin tenant database
|
||||
AdminMigrate {
|
||||
/// Target Admin ID
|
||||
target_admin_id: i64,
|
||||
#[arg(long)]
|
||||
data_dir: Option<String>,
|
||||
/// Preview what would be moved without making changes
|
||||
#[arg(long)]
|
||||
dry_run: bool,
|
||||
/// Force the migration to execute
|
||||
#[arg(long)]
|
||||
force: bool,
|
||||
},
|
||||
/// Repair registry and database inconsistencies
|
||||
Repair {
|
||||
#[command(subcommand)]
|
||||
command: RepairCommands,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(clap::Subcommand)]
|
||||
pub enum RepairCommands {
|
||||
/// Repair Global Slug Registry inconsistencies
|
||||
Registry {
|
||||
#[arg(long)]
|
||||
dry_run: bool,
|
||||
#[arg(long)]
|
||||
force: bool,
|
||||
#[arg(long)]
|
||||
slug: Option<String>,
|
||||
#[arg(long)]
|
||||
data_dir: Option<String>,
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
use crate::cli::RepairCommands;
|
||||
use crate::config::Config;
|
||||
use crate::services::registry_validator::{RegistryIssueType, RegistryValidator};
|
||||
use rusqlite::Connection;
|
||||
use std::path::PathBuf;
|
||||
use tracing::info;
|
||||
|
||||
pub async fn run(
|
||||
command: RepairCommands,
|
||||
mut config: Config,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
match command {
|
||||
RepairCommands::Registry {
|
||||
dry_run,
|
||||
force,
|
||||
slug,
|
||||
data_dir,
|
||||
} => {
|
||||
if let Some(d) = data_dir {
|
||||
config.data_dir = PathBuf::from(d);
|
||||
}
|
||||
|
||||
if !dry_run && !force {
|
||||
println!("Error: You must specify either --dry-run or --force");
|
||||
return Ok(());
|
||||
}
|
||||
if dry_run && force {
|
||||
println!("Error: Cannot specify both --dry-run and --force");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let start_time = std::time::Instant::now();
|
||||
let admin_dir = config.data_dir.join("admin");
|
||||
let system_db_path = admin_dir.join("system.db");
|
||||
let users_db_path = admin_dir.join("users.db");
|
||||
|
||||
if !system_db_path.exists() || !users_db_path.exists() {
|
||||
println!("Error: system.db or users.db not found.");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut sys_conn = Connection::open(&system_db_path)?;
|
||||
let usr_conn = Connection::open(&users_db_path)?;
|
||||
|
||||
let slug_filter = slug.as_deref();
|
||||
|
||||
if dry_run {
|
||||
println!("BZOD Registry Repair\n");
|
||||
println!("Scanning Global Slug Registry...");
|
||||
|
||||
let issues =
|
||||
RegistryValidator::scan(&sys_conn, &usr_conn, &config.data_dir, slug_filter)?;
|
||||
let orphaned = issues
|
||||
.into_iter()
|
||||
.filter(|i| {
|
||||
matches!(
|
||||
i.issue_type,
|
||||
RegistryIssueType::MissingTarget
|
||||
| RegistryIssueType::MissingDatabase
|
||||
| RegistryIssueType::MissingOwner
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let orphaned_pages = orphaned.iter().filter(|i| i.target_type == "page").count();
|
||||
let orphaned_urls = orphaned.iter().filter(|i| i.target_type == "url").count();
|
||||
|
||||
println!("\nDetected:");
|
||||
println!("\nPages:\n {} orphaned", orphaned_pages);
|
||||
println!("\nURLs:\n {} orphaned", orphaned_urls);
|
||||
|
||||
if !orphaned.is_empty() {
|
||||
println!("\nThe following entries would be removed:");
|
||||
for issue in &orphaned {
|
||||
println!("\n{}\n {}", issue.target_type.to_uppercase(), issue.slug);
|
||||
}
|
||||
}
|
||||
|
||||
println!("\nNo changes have been made.");
|
||||
println!(
|
||||
"\nRun again with:\n\n bzod repair registry --force{}",
|
||||
if let Some(s) = slug_filter {
|
||||
format!(" --slug {}", s)
|
||||
} else {
|
||||
"".to_string()
|
||||
}
|
||||
);
|
||||
|
||||
info!(
|
||||
"Registry Repair Started. Scanned. Orphaned Pages: {}, Orphaned URLs: {}. Duration: {:?}",
|
||||
orphaned_pages, orphaned_urls, start_time.elapsed()
|
||||
);
|
||||
} else if force {
|
||||
let tx = sys_conn.transaction()?;
|
||||
|
||||
let issues =
|
||||
RegistryValidator::scan(&tx, &usr_conn, &config.data_dir, slug_filter)?;
|
||||
let orphaned = issues
|
||||
.into_iter()
|
||||
.filter(|i| {
|
||||
matches!(
|
||||
i.issue_type,
|
||||
RegistryIssueType::MissingTarget
|
||||
| RegistryIssueType::MissingDatabase
|
||||
| RegistryIssueType::MissingOwner
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let orphaned_pages = orphaned.iter().filter(|i| i.target_type == "page").count();
|
||||
let orphaned_urls = orphaned.iter().filter(|i| i.target_type == "url").count();
|
||||
|
||||
if orphaned.is_empty() {
|
||||
println!("No repairs required.");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if let Some(s) = slug_filter {
|
||||
println!("Checking slug:\n\n{}\n", s);
|
||||
if let Some(issue) = orphaned.first() {
|
||||
println!("Owner:\n\n{}\n", issue.owner_user_id);
|
||||
println!("Status:\n\nOrphaned\n");
|
||||
}
|
||||
}
|
||||
|
||||
let mut removed_count = 0;
|
||||
for issue in &orphaned {
|
||||
let rows = tx.execute(
|
||||
"DELETE FROM global_slugs WHERE slug = ?1",
|
||||
rusqlite::params![issue.slug],
|
||||
)?;
|
||||
removed_count += rows;
|
||||
}
|
||||
|
||||
tx.commit()?;
|
||||
|
||||
if slug_filter.is_some() {
|
||||
println!("Removed:\n\nSUCCESS");
|
||||
} else {
|
||||
println!("Repair Complete\n");
|
||||
println!("Removed:\n");
|
||||
println!("Pages:\n {}\n", orphaned_pages);
|
||||
println!("URLs:\n {}\n", orphaned_urls);
|
||||
|
||||
let remaining: i64 =
|
||||
sys_conn
|
||||
.query_row("SELECT COUNT(*) FROM global_slugs;", [], |r| r.get(0))?;
|
||||
println!("Remaining Registry Entries:\n {}\n", remaining);
|
||||
|
||||
let post_issues =
|
||||
RegistryValidator::scan(&sys_conn, &usr_conn, &config.data_dir, None)?;
|
||||
let post_orphaned = post_issues
|
||||
.iter()
|
||||
.filter(|i| {
|
||||
matches!(
|
||||
i.issue_type,
|
||||
RegistryIssueType::MissingTarget
|
||||
| RegistryIssueType::MissingDatabase
|
||||
| RegistryIssueType::MissingOwner
|
||||
)
|
||||
})
|
||||
.count();
|
||||
|
||||
println!(
|
||||
"Integrity:\n {}",
|
||||
if post_orphaned == 0 { "PASS" } else { "FAIL" }
|
||||
);
|
||||
}
|
||||
|
||||
info!(
|
||||
"Registry Repair Started. Scanned. Orphaned Pages: {}, Orphaned URLs: {}. Removed: {}. Duration: {:?}",
|
||||
orphaned_pages, orphaned_urls, removed_count, start_time.elapsed()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
+365
-27
@@ -1,48 +1,386 @@
|
||||
use crate::config::Config;
|
||||
use crate::services::registry_validator::RegistryIssueType;
|
||||
use flate2::read::GzDecoder;
|
||||
use std::fs::File;
|
||||
use std::io::{self, Write};
|
||||
use std::path::PathBuf;
|
||||
use std::path::{Path, PathBuf};
|
||||
use tar::Archive;
|
||||
use tracing::{error, info};
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
/// Read backup_manifest.json and return true if this is a legacy_flat_backup.
|
||||
fn is_legacy_flat_backup(temp_dir: &Path) -> bool {
|
||||
let manifest_path = temp_dir.join("backup_manifest.json");
|
||||
if !manifest_path.exists() {
|
||||
return false;
|
||||
}
|
||||
match std::fs::read_to_string(&manifest_path) {
|
||||
Ok(contents) => match serde_json::from_str::<serde_json::Value>(&contents) {
|
||||
Ok(val) => val.get("type").and_then(|t| t.as_str()) == Some("legacy_flat_backup"),
|
||||
Err(_) => false,
|
||||
},
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Detect if the unpacked archive is in flat layout (files at root, not in admin/ subdirectory).
|
||||
fn is_flat_layout(temp_dir: &Path) -> bool {
|
||||
temp_dir.join("admin.db").exists() && !temp_dir.join("admin").join("admin.db").exists()
|
||||
}
|
||||
|
||||
/// Bootstrap users.db for a legacy backup where users.db is empty/unmigrated.
|
||||
///
|
||||
/// This function:
|
||||
/// 1. Runs USERS_MIGRATIONS on users.db to create the required schema.
|
||||
/// 2. Reads the actual administrator identity from admin.db (preserving
|
||||
/// the original username and argon2id password hash — no manufacturing).
|
||||
/// 3. Creates a legacy_admin system placeholder (id=1) for tenant ownership.
|
||||
/// 4. Creates an admin account with the original credentials.
|
||||
/// 5. Scans global_slugs for owner_user_ids and creates disabled placeholder
|
||||
/// accounts for any missing tenants.
|
||||
fn bootstrap_legacy_users_db(temp_dir: &Path) -> Result<(), Box<dyn std::error::Error>> {
|
||||
use crate::db::migrations::{run_migrations, USERS_MIGRATIONS};
|
||||
|
||||
let users_db_path = temp_dir.join("admin").join("users.db");
|
||||
let admin_db_path = temp_dir.join("admin").join("admin.db");
|
||||
let system_db_path = temp_dir.join("admin").join("system.db");
|
||||
|
||||
// Check if users.db already has the users table (i.e., not a legacy backup)
|
||||
{
|
||||
let conn = rusqlite::Connection::open(&users_db_path)?;
|
||||
let has_users_table: bool = conn
|
||||
.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name='users');",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap_or(false);
|
||||
if has_users_table {
|
||||
info!("users.db already has users table; skipping legacy bootstrap");
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
info!("Legacy users.db detected (empty/unmigrated). Bootstrapping current schema...");
|
||||
|
||||
// Step 1: Run migrations to create the users.db schema
|
||||
let mut users_conn = rusqlite::Connection::open(&users_db_path)?;
|
||||
crate::db::sqlite::enable_wal(&users_conn, "users")?;
|
||||
crate::db::sqlite::enable_foreign_keys(&users_conn, "users")?;
|
||||
run_migrations(&mut users_conn, "users", USERS_MIGRATIONS, None)?;
|
||||
|
||||
// Step 2: Read the actual administrator identity from admin.db
|
||||
let (admin_username, admin_password_hash) = {
|
||||
let admin_conn = rusqlite::Connection::open(&admin_db_path)?;
|
||||
|
||||
// The legacy admin.db users table has schema:
|
||||
// id TEXT PRIMARY KEY (UUID), username TEXT, password_hash TEXT, created_at TEXT
|
||||
// Read the actual admin — typically the first (and often only) user.
|
||||
let result: Result<(String, String), _> = admin_conn.query_row(
|
||||
"SELECT username, password_hash FROM users ORDER BY created_at ASC LIMIT 1;",
|
||||
[],
|
||||
|row| Ok((row.get(0)?, row.get(1)?)),
|
||||
);
|
||||
|
||||
match result {
|
||||
Ok((username, hash)) => {
|
||||
info!(
|
||||
"Preserved administrator identity from legacy admin.db: username='{}'",
|
||||
username
|
||||
);
|
||||
(username, hash)
|
||||
}
|
||||
Err(e) => {
|
||||
return Err(format!(
|
||||
"Failed to read administrator credentials from legacy admin.db: {}",
|
||||
e
|
||||
)
|
||||
.into());
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Step 3: Create legacy_admin system placeholder (id=1) for tenant content ownership
|
||||
// This account owns the content.db/analytics.db from the flat backup (users/1/).
|
||||
// It uses the original admin's password hash so no synthetic credentials are introduced.
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
users_conn.execute(
|
||||
"INSERT INTO users (id, username, password_hash, status, created_at, account_type)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6);",
|
||||
rusqlite::params![
|
||||
1i64,
|
||||
"legacy_admin",
|
||||
&admin_password_hash,
|
||||
"disabled",
|
||||
&now,
|
||||
"system"
|
||||
],
|
||||
)?;
|
||||
users_conn.execute("INSERT INTO quotas (user_id) VALUES (?1);", [1i64])?;
|
||||
info!("Created legacy_admin system account (id=1) for tenant content ownership");
|
||||
|
||||
// Step 4: Create the actual admin account with original credentials
|
||||
users_conn.execute(
|
||||
"INSERT INTO users (username, password_hash, status, created_at, account_type)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5);",
|
||||
rusqlite::params![
|
||||
&admin_username,
|
||||
&admin_password_hash,
|
||||
"active",
|
||||
&now,
|
||||
"admin"
|
||||
],
|
||||
)?;
|
||||
let admin_id = users_conn.last_insert_rowid();
|
||||
users_conn.execute("INSERT INTO quotas (user_id) VALUES (?1);", [admin_id])?;
|
||||
info!(
|
||||
"Created admin account '{}' (id={}) with original credentials",
|
||||
admin_username, admin_id
|
||||
);
|
||||
|
||||
// Step 5: Scan global_slugs for owner_user_ids and create placeholders for missing tenants
|
||||
if system_db_path.exists() {
|
||||
let system_conn = rusqlite::Connection::open(&system_db_path)?;
|
||||
let has_global_slugs: bool = system_conn
|
||||
.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name='global_slugs');",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap_or(false);
|
||||
|
||||
if has_global_slugs {
|
||||
let mut stmt =
|
||||
system_conn.prepare("SELECT DISTINCT owner_user_id FROM global_slugs;")?;
|
||||
let mut rows = stmt.query([])?;
|
||||
while let Some(row) = rows.next()? {
|
||||
let owner_id: i64 = row.get(0)?;
|
||||
// Skip user 1 (legacy_admin) and the admin we just created
|
||||
if owner_id == 1 || owner_id == admin_id {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if this user already exists in users.db
|
||||
let exists: bool = users_conn
|
||||
.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM users WHERE id = ?1);",
|
||||
[owner_id],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap_or(false);
|
||||
|
||||
if !exists {
|
||||
// Create a disabled placeholder so RegistryValidator can resolve ownership.
|
||||
// The tenant's actual databases were not included in the flat backup.
|
||||
let placeholder_name = format!("restored_user_{}", owner_id);
|
||||
users_conn.execute(
|
||||
"INSERT INTO users (id, username, password_hash, status, created_at, account_type, metadata)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7);",
|
||||
rusqlite::params![
|
||||
owner_id,
|
||||
&placeholder_name,
|
||||
&admin_password_hash,
|
||||
"disabled",
|
||||
&now,
|
||||
"standard",
|
||||
"Placeholder created during legacy_flat_backup restore. Original tenant databases were not included in the flat backup."
|
||||
],
|
||||
)?;
|
||||
users_conn.execute("INSERT INTO quotas (user_id) VALUES (?1);", [owner_id])?;
|
||||
warn!(
|
||||
"Created placeholder account for user_id={} (referenced in global_slugs but tenant databases not in backup)",
|
||||
owner_id
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Classify registry issues into hard errors vs warnings for legacy restore.
|
||||
///
|
||||
/// Hard errors: DuplicateSlug, InvalidTargetType, InvalidStatus
|
||||
/// Warnings: MissingDatabase, MissingTarget, MissingOwner, StaleReservation,
|
||||
/// TenantAdminHasIsolatedContent
|
||||
fn classify_registry_issues(
|
||||
issues: &[crate::services::registry_validator::RegistryIssue],
|
||||
is_legacy: bool,
|
||||
) -> (
|
||||
Vec<&crate::services::registry_validator::RegistryIssue>,
|
||||
Vec<&crate::services::registry_validator::RegistryIssue>,
|
||||
) {
|
||||
let mut errors = Vec::new();
|
||||
let mut warnings = Vec::new();
|
||||
|
||||
for issue in issues {
|
||||
match issue.issue_type {
|
||||
RegistryIssueType::DuplicateSlug
|
||||
| RegistryIssueType::InvalidTargetType
|
||||
| RegistryIssueType::InvalidStatus => {
|
||||
errors.push(issue);
|
||||
}
|
||||
RegistryIssueType::MissingOwner if !is_legacy => {
|
||||
errors.push(issue);
|
||||
}
|
||||
_ => {
|
||||
// For legacy restores: MissingDatabase, MissingTarget, MissingOwner,
|
||||
// StaleReservation, TenantAdminHasIsolatedContent are warnings.
|
||||
// These represent pre-existing inconsistencies in the backup data,
|
||||
// not restore corruption.
|
||||
warnings.push(issue);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
(errors, warnings)
|
||||
}
|
||||
|
||||
pub fn perform_restore(
|
||||
file_path: &std::path::Path,
|
||||
data_dir: &std::path::Path,
|
||||
file_path: &Path,
|
||||
data_dir: &Path,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
// 1. Open the archive
|
||||
// 1. Open and unpack the archive to a temporary directory
|
||||
let f = File::open(file_path)?;
|
||||
let tar_gz = GzDecoder::new(f);
|
||||
let mut archive = Archive::new(tar_gz);
|
||||
|
||||
// 2. Validate that the archive contains the expected BZOD database files
|
||||
let mut has_admin = false;
|
||||
let mut has_content = false;
|
||||
let mut has_analytics = false;
|
||||
let mut has_system = false;
|
||||
let temp_dir =
|
||||
std::env::temp_dir().join(format!("bzod_system_restore_{}", uuid::Uuid::new_v4()));
|
||||
std::fs::create_dir_all(&temp_dir)?;
|
||||
|
||||
for entry_res in archive.entries()? {
|
||||
let entry = entry_res?;
|
||||
let path = entry.path()?;
|
||||
let file_name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
|
||||
match file_name {
|
||||
"admin.db" => has_admin = true,
|
||||
"content.db" => has_content = true,
|
||||
"analytics.db" => has_analytics = true,
|
||||
"system.db" => has_system = true,
|
||||
_ => {}
|
||||
if let Err(e) = archive.unpack(&temp_dir) {
|
||||
let _ = std::fs::remove_dir_all(&temp_dir);
|
||||
return Err(e.into());
|
||||
}
|
||||
|
||||
// 2. Detect backup format
|
||||
let is_legacy = is_legacy_flat_backup(&temp_dir);
|
||||
let needs_normalization = is_flat_layout(&temp_dir);
|
||||
|
||||
if is_legacy {
|
||||
info!("Detected legacy_flat_backup format — using legacy-aware restore path");
|
||||
}
|
||||
|
||||
// 3. Normalize flat layout into multi-tenant structure BEFORE any validation
|
||||
if needs_normalization {
|
||||
info!("Normalizing flat database layout into multi-tenant structure...");
|
||||
if let Err(e) = crate::services::backup_layout::normalize_restored_layout(&temp_dir) {
|
||||
let _ = std::fs::remove_dir_all(&temp_dir);
|
||||
return Err(format!("Failed to normalize legacy layout: {}", e).into());
|
||||
}
|
||||
}
|
||||
|
||||
if !has_admin || !has_content || !has_analytics || !has_system {
|
||||
return Err("Archive is missing one or more required database files (admin.db, content.db, analytics.db, system.db)".into());
|
||||
// 4. For legacy backups: bootstrap the empty users.db with the current schema
|
||||
// and populate it from admin.db credentials
|
||||
if is_legacy {
|
||||
if let Err(e) = bootstrap_legacy_users_db(&temp_dir) {
|
||||
let _ = std::fs::remove_dir_all(&temp_dir);
|
||||
return Err(format!("Failed to bootstrap legacy users database: {}", e).into());
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Unpack archive to data_dir
|
||||
let f2 = File::open(file_path)?;
|
||||
let tar_gz2 = GzDecoder::new(f2);
|
||||
let mut archive2 = Archive::new(tar_gz2);
|
||||
archive2.unpack(data_dir)?;
|
||||
// 5. Run validation on the normalized temp_dir
|
||||
let mut temp_config = Config::load();
|
||||
temp_config.data_dir = temp_dir.clone();
|
||||
|
||||
// Namespace audit
|
||||
match crate::db::users::audit_slug_namespace(&temp_config) {
|
||||
Ok(report) => {
|
||||
if !report.duplicates.is_empty() {
|
||||
let _ = std::fs::remove_dir_all(&temp_dir);
|
||||
return Err(
|
||||
format!("Slug conflicts detected in backup: {:?}", report.duplicates).into(),
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = std::fs::remove_dir_all(&temp_dir);
|
||||
return Err(format!("Failed to audit slug namespace in backup: {}", e).into());
|
||||
}
|
||||
}
|
||||
|
||||
// Registry integrity check
|
||||
let system_db_path = temp_dir.join("admin").join("system.db");
|
||||
let users_db_path = temp_dir.join("admin").join("users.db");
|
||||
|
||||
if system_db_path.exists() && users_db_path.exists() {
|
||||
let system_conn = rusqlite::Connection::open(&system_db_path)?;
|
||||
let users_conn = rusqlite::Connection::open(&users_db_path)?;
|
||||
match crate::services::registry_validator::RegistryValidator::scan(
|
||||
&system_conn,
|
||||
&users_conn,
|
||||
&temp_dir,
|
||||
None,
|
||||
) {
|
||||
Ok(issues) => {
|
||||
if !issues.is_empty() {
|
||||
let (hard_errors, warnings) = classify_registry_issues(&issues, is_legacy);
|
||||
|
||||
// Log all warnings
|
||||
for w in &warnings {
|
||||
warn!(
|
||||
"Legacy restore warning: {:?} — {}",
|
||||
w.issue_type, w.description
|
||||
);
|
||||
}
|
||||
|
||||
// Abort only on hard errors
|
||||
if !hard_errors.is_empty() {
|
||||
let descriptions: Vec<String> = hard_errors
|
||||
.iter()
|
||||
.map(|e| format!("{:?}: {}", e.issue_type, e.description))
|
||||
.collect();
|
||||
let _ = std::fs::remove_dir_all(&temp_dir);
|
||||
return Err(format!(
|
||||
"Registry integrity errors in backup ({} critical): {}",
|
||||
hard_errors.len(),
|
||||
descriptions.join("; ")
|
||||
)
|
||||
.into());
|
||||
}
|
||||
|
||||
if !warnings.is_empty() {
|
||||
info!(
|
||||
"Registry validation completed with {} warnings (pre-existing backup inconsistencies)",
|
||||
warnings.len()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = std::fs::remove_dir_all(&temp_dir);
|
||||
return Err(format!("Failed to verify registry integrity in backup: {}", e).into());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 6. If validation succeeds, atomically replace data_dir contents
|
||||
if data_dir.exists() {
|
||||
let _ = std::fs::remove_dir_all(data_dir);
|
||||
}
|
||||
std::fs::create_dir_all(data_dir)?;
|
||||
|
||||
fn copy_dir_all(src: &Path, dst: &Path) -> std::io::Result<()> {
|
||||
std::fs::create_dir_all(dst)?;
|
||||
for entry in std::fs::read_dir(src)? {
|
||||
let entry = entry?;
|
||||
let ty = entry.file_type()?;
|
||||
if ty.is_dir() {
|
||||
copy_dir_all(&entry.path(), &dst.join(entry.file_name()))?;
|
||||
} else {
|
||||
std::fs::copy(entry.path(), dst.join(entry.file_name()))?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
if let Err(e) = copy_dir_all(&temp_dir, data_dir) {
|
||||
let _ = std::fs::remove_dir_all(&temp_dir);
|
||||
return Err(format!("Failed to copy restored files: {}", e).into());
|
||||
}
|
||||
|
||||
let _ = std::fs::remove_dir_all(&temp_dir);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
+7
-94
@@ -1,7 +1,5 @@
|
||||
use crate::config::Config;
|
||||
use crate::db::Db;
|
||||
use chrono::Utc;
|
||||
use rusqlite::OptionalExtension;
|
||||
use std::fs::File;
|
||||
use std::path::PathBuf;
|
||||
use tar::Archive;
|
||||
@@ -158,103 +156,18 @@ pub async fn run(
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Register slugs in global_slugs
|
||||
let restored_content_conn = rusqlite::Connection::open(dest_dir.join("content.db"))?;
|
||||
// 4. Register slugs in global_slugs using the shared helper
|
||||
{
|
||||
let mut system_conn = db.system.lock().unwrap();
|
||||
let tx = system_conn.transaction()?;
|
||||
|
||||
// Delete any existing global slugs owned by this user
|
||||
tx.execute(
|
||||
"DELETE FROM global_slugs WHERE owner_user_id = ?1;",
|
||||
[target_user_id],
|
||||
let system_conn = db.system.lock().unwrap();
|
||||
crate::db::users::register_restored_user_slugs(
|
||||
&system_conn,
|
||||
target_user_id,
|
||||
&dest_dir.join("content.db"),
|
||||
)?;
|
||||
|
||||
// Register URLs
|
||||
{
|
||||
let mut stmt =
|
||||
restored_content_conn.prepare("SELECT code, id, created_at, status FROM urls;")?;
|
||||
let mut rows = stmt.query([])?;
|
||||
while let Some(row) = rows.next()? {
|
||||
let slug: String = row.get(0)?;
|
||||
let target_id: String = row.get(1)?;
|
||||
let created_at: String = row.get(2)?;
|
||||
let status: String = row.get(3)?;
|
||||
let now = Utc::now().to_rfc3339();
|
||||
|
||||
let existing_owner: Option<i64> = tx
|
||||
.query_row(
|
||||
"SELECT owner_user_id FROM global_slugs WHERE slug = ?1;",
|
||||
[&slug],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.optional()?;
|
||||
|
||||
if let Some(owner) = existing_owner {
|
||||
if owner != target_user_id {
|
||||
error!(
|
||||
"Conflict: Slug '{}' is already owned by user ID {}. Skipping.",
|
||||
slug, owner
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
tx.execute(
|
||||
"INSERT OR REPLACE INTO global_slugs (slug, owner_user_id, target_type, target_id, created_at, updated_at, status)
|
||||
VALUES (?1, ?2, 'url', ?3, ?4, ?5, ?6);",
|
||||
rusqlite::params![slug, target_user_id, target_id, created_at, now, status],
|
||||
)?;
|
||||
}
|
||||
}
|
||||
|
||||
// Register Landing Pages
|
||||
{
|
||||
let mut stmt = restored_content_conn
|
||||
.prepare("SELECT code, id, created_at, state FROM landing_pages;")?;
|
||||
let mut rows = stmt.query([])?;
|
||||
while let Some(row) = rows.next()? {
|
||||
let slug: String = row.get(0)?;
|
||||
let target_id: String = row.get(1)?;
|
||||
let created_at: String = row.get(2)?;
|
||||
let state: String = row.get(3)?;
|
||||
let now = Utc::now().to_rfc3339();
|
||||
let status = if state == "published" {
|
||||
"active"
|
||||
} else {
|
||||
"disabled"
|
||||
};
|
||||
|
||||
let existing_owner: Option<i64> = tx
|
||||
.query_row(
|
||||
"SELECT owner_user_id FROM global_slugs WHERE slug = ?1;",
|
||||
[&slug],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.optional()?;
|
||||
|
||||
if let Some(owner) = existing_owner {
|
||||
if owner != target_user_id {
|
||||
error!(
|
||||
"Conflict: Slug '{}' is already owned by user ID {}. Skipping.",
|
||||
slug, owner
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
tx.execute(
|
||||
"INSERT OR REPLACE INTO global_slugs (slug, owner_user_id, target_type, target_id, created_at, updated_at, status)
|
||||
VALUES (?1, ?2, 'page', ?3, ?4, ?5, ?6);",
|
||||
rusqlite::params![slug, target_user_id, target_id, created_at, now, status],
|
||||
)?;
|
||||
}
|
||||
}
|
||||
|
||||
tx.commit()?;
|
||||
}
|
||||
|
||||
// 5. Reconcile quotas for restored user
|
||||
let restored_content_conn = rusqlite::Connection::open(dest_dir.join("content.db"))?;
|
||||
crate::db::users::reconcile_user_quotas(
|
||||
&db.users.lock().unwrap(),
|
||||
target_user_id,
|
||||
|
||||
+96
-20
@@ -7,6 +7,30 @@ use std::path::PathBuf;
|
||||
use std::time::Instant;
|
||||
use tracing::info;
|
||||
|
||||
async fn shutdown_signal() {
|
||||
let ctrl_c = async {
|
||||
tokio::signal::ctrl_c()
|
||||
.await
|
||||
.expect("failed to install Ctrl+C handler");
|
||||
};
|
||||
|
||||
#[cfg(unix)]
|
||||
let terminate = async {
|
||||
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
|
||||
.expect("failed to install signal handler")
|
||||
.recv()
|
||||
.await;
|
||||
};
|
||||
|
||||
#[cfg(not(unix))]
|
||||
let terminate = std::future::pending::<()>();
|
||||
|
||||
tokio::select! {
|
||||
_ = ctrl_c => {},
|
||||
_ = terminate => {},
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn run(
|
||||
host: Option<String>,
|
||||
port: Option<u16>,
|
||||
@@ -29,39 +53,63 @@ pub async fn run(
|
||||
// Init DBs
|
||||
let db = Db::init(&config)?;
|
||||
|
||||
let (shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(false);
|
||||
let mut join_handles = Vec::new();
|
||||
|
||||
// Init Queue
|
||||
let queue = AnalyticsQueue::new(db.clone(), 1000);
|
||||
let (queue, analytics_handle) = AnalyticsQueue::new(db.clone(), 1000, shutdown_rx.clone());
|
||||
join_handles.push(("analytics_worker", analytics_handle));
|
||||
|
||||
// Spawn background tasks
|
||||
let link_checker_db = db.clone();
|
||||
let link_checker_interval = config.link_check_interval_mins;
|
||||
tokio::spawn(async move {
|
||||
crate::jobs::run_link_checker(link_checker_db, link_checker_interval).await;
|
||||
});
|
||||
let rx = shutdown_rx.clone();
|
||||
join_handles.push((
|
||||
"link_checker",
|
||||
tokio::spawn(async move {
|
||||
crate::jobs::run_link_checker(link_checker_db, link_checker_interval, rx).await;
|
||||
}),
|
||||
));
|
||||
|
||||
let aggregator_db = db.clone();
|
||||
let aggregator_interval = config.aggregation_interval_mins;
|
||||
tokio::spawn(async move {
|
||||
crate::jobs::run_aggregator(aggregator_db, aggregator_interval).await;
|
||||
});
|
||||
let rx = shutdown_rx.clone();
|
||||
join_handles.push((
|
||||
"aggregator",
|
||||
tokio::spawn(async move {
|
||||
crate::jobs::run_aggregator(aggregator_db, aggregator_interval, rx).await;
|
||||
}),
|
||||
));
|
||||
|
||||
let retention_db = db.clone();
|
||||
let retention_days = config.data_retention_days;
|
||||
tokio::spawn(async move {
|
||||
crate::jobs::run_retention_cleaner(retention_db, retention_days).await;
|
||||
});
|
||||
let rx = shutdown_rx.clone();
|
||||
join_handles.push((
|
||||
"retention_cleaner",
|
||||
tokio::spawn(async move {
|
||||
crate::jobs::run_retention_cleaner(retention_db, retention_days, rx).await;
|
||||
}),
|
||||
));
|
||||
|
||||
// Spawn optional backup scheduler
|
||||
let backup_db = db.clone();
|
||||
let backup_config = config.clone();
|
||||
tokio::spawn(async move {
|
||||
crate::jobs::backup::run_backup_scheduler(backup_db, backup_config).await;
|
||||
});
|
||||
let rx = shutdown_rx.clone();
|
||||
join_handles.push((
|
||||
"backup_scheduler",
|
||||
tokio::spawn(async move {
|
||||
crate::jobs::backup::run_backup_scheduler(backup_db, backup_config, rx).await;
|
||||
}),
|
||||
));
|
||||
|
||||
let expiry_db = db.clone();
|
||||
tokio::spawn(async move {
|
||||
crate::jobs::run_expiry_checker(expiry_db).await;
|
||||
});
|
||||
let rx = shutdown_rx.clone();
|
||||
join_handles.push((
|
||||
"expiry_checker",
|
||||
tokio::spawn(async move {
|
||||
crate::jobs::run_expiry_checker(expiry_db, rx).await;
|
||||
}),
|
||||
));
|
||||
|
||||
let reconcile_db = db.clone();
|
||||
let reconcile_interval_hours = {
|
||||
@@ -75,9 +123,13 @@ pub async fn run(
|
||||
.and_then(|val| val.parse::<u64>().ok())
|
||||
.unwrap_or(24)
|
||||
};
|
||||
tokio::spawn(async move {
|
||||
crate::jobs::run_quota_reconciliation(reconcile_db, reconcile_interval_hours).await;
|
||||
});
|
||||
let rx = shutdown_rx.clone();
|
||||
join_handles.push((
|
||||
"quota_reconciliation",
|
||||
tokio::spawn(async move {
|
||||
crate::jobs::run_quota_reconciliation(reconcile_db, reconcile_interval_hours, rx).await;
|
||||
}),
|
||||
));
|
||||
|
||||
let state = AppState {
|
||||
admin_db: db.admin.clone(),
|
||||
@@ -98,7 +150,31 @@ pub async fn run(
|
||||
let listener = tokio::net::TcpListener::bind(&addr).await?;
|
||||
|
||||
info!("Listening for requests on http://{}", addr);
|
||||
axum::serve(listener, router).await?;
|
||||
|
||||
axum::serve(listener, router)
|
||||
.with_graceful_shutdown(async move {
|
||||
shutdown_signal().await;
|
||||
info!("Shutdown signal received");
|
||||
info!("Stopping HTTP server...");
|
||||
let _ = shutdown_tx.send(true);
|
||||
})
|
||||
.await?;
|
||||
|
||||
info!("Stopping background workers...");
|
||||
|
||||
let timeout_duration = std::time::Duration::from_secs(10);
|
||||
let deadline = tokio::time::Instant::now() + timeout_duration;
|
||||
|
||||
for (name, handle) in join_handles {
|
||||
match tokio::time::timeout_at(deadline, handle).await {
|
||||
Ok(Ok(_)) => {}
|
||||
Ok(Err(e)) => tracing::error!("Background task '{}' panicked: {:?}", name, e),
|
||||
Err(_) => tracing::warn!("Background task did not terminate: {}", name),
|
||||
}
|
||||
}
|
||||
|
||||
info!("Background workers stopped");
|
||||
info!("BZOD shutdown complete");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
+29
-7
@@ -33,7 +33,16 @@ pub async fn run(
|
||||
None => crate::utils::random::generate_token(3),
|
||||
};
|
||||
|
||||
// 3. Persist URL
|
||||
// 3. Register slug in system.db with status 'reserving' and check availability
|
||||
{
|
||||
let system_conn = db.system.lock().unwrap();
|
||||
if !crate::db::users::is_slug_available(&system_conn, &code)? {
|
||||
return Err("Short code/slug already exists".into());
|
||||
}
|
||||
crate::db::users::register_global_slug(&system_conn, &code, 1, "url", "", "reserving")?;
|
||||
}
|
||||
|
||||
// 4. Persist URL
|
||||
let conn = db.content.lock().unwrap();
|
||||
let res = crate::db::content::create_url_extended(
|
||||
&conn,
|
||||
@@ -48,7 +57,21 @@ pub async fn run(
|
||||
);
|
||||
|
||||
match res {
|
||||
Ok(_) => {
|
||||
Ok(url) => {
|
||||
// Activate slug in system.db
|
||||
{
|
||||
let system_conn = db.system.lock().unwrap();
|
||||
system_conn.execute(
|
||||
"UPDATE global_slugs SET target_id = ?1, status = 'active', updated_at = ?2 WHERE slug = ?3;",
|
||||
rusqlite::params![url.id, chrono::Utc::now().to_rfc3339(), code],
|
||||
)?;
|
||||
}
|
||||
// Increment quota for user ID 1
|
||||
{
|
||||
let users_conn = db.users.lock().unwrap();
|
||||
crate::db::users::increment_quota_counter(&users_conn, 1, "urls")?;
|
||||
}
|
||||
|
||||
let proto = if config.cookie_secure {
|
||||
"https"
|
||||
} else {
|
||||
@@ -63,11 +86,10 @@ pub async fn run(
|
||||
println!("{}/{}", base_url, code);
|
||||
Ok(())
|
||||
}
|
||||
Err(rusqlite::Error::SqliteFailure(err, _))
|
||||
if err.code == rusqlite::ErrorCode::ConstraintViolation =>
|
||||
{
|
||||
Err("Short code/slug already exists".into())
|
||||
Err(e) => {
|
||||
let system_conn = db.system.lock().unwrap();
|
||||
let _ = crate::db::users::release_global_slug(&system_conn, &code, 1);
|
||||
Err(e.into())
|
||||
}
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
}
|
||||
+18
-6
@@ -14,14 +14,26 @@ pub async fn run(
|
||||
println!("=== BZOD Database Stats ===");
|
||||
println!("Storage Directory: {:?}", config.data_dir);
|
||||
|
||||
let files = vec!["admin.db", "content.db", "analytics.db", "system.db"];
|
||||
for f in files {
|
||||
let p = config.data_dir.join(f);
|
||||
if p.exists() {
|
||||
let sz = std::fs::metadata(&p)?.len();
|
||||
let files = vec![
|
||||
("admin.db", config.data_dir.join("admin/admin.db")),
|
||||
("system.db", config.data_dir.join("admin/system.db")),
|
||||
("users.db", config.data_dir.join("admin/users.db")),
|
||||
(
|
||||
"legacy content.db",
|
||||
config.data_dir.join("users/1/content.db"),
|
||||
),
|
||||
(
|
||||
"legacy analytics.db",
|
||||
config.data_dir.join("users/1/analytics.db"),
|
||||
),
|
||||
];
|
||||
|
||||
for (name, path) in files {
|
||||
if path.exists() {
|
||||
let sz = std::fs::metadata(&path)?.len();
|
||||
println!(
|
||||
" File: {} - Size: {} bytes ({:.2} MB)",
|
||||
f,
|
||||
name,
|
||||
sz,
|
||||
sz as f64 / 1_048_576.0
|
||||
);
|
||||
|
||||
+63
-7
@@ -50,7 +50,7 @@ impl Db {
|
||||
|
||||
// 1. If legacy admin.db exists at root, move admin/system DBs to config.data_dir/admin/
|
||||
if legacy_admin_db.exists() {
|
||||
info!("Legacy admin.db found at root. Moving administrative databases to admin/ subfolder...");
|
||||
tracing::warn!("LEGACY DETECTED: admin.db found at root. Moving administrative databases to multi-tenant admin/ subfolder...");
|
||||
let files = vec![
|
||||
"admin.db",
|
||||
"admin.db-wal",
|
||||
@@ -68,6 +68,26 @@ impl Db {
|
||||
}
|
||||
}
|
||||
|
||||
// Pre-migration safety net: audit slug namespace for duplicates / format errors
|
||||
match crate::db::users::audit_slug_namespace(config) {
|
||||
Ok(report) => {
|
||||
if !report.duplicates.is_empty() {
|
||||
tracing::error!(
|
||||
"Namespace conflicts detected before database migration: {:?}",
|
||||
report.duplicates
|
||||
);
|
||||
return Err(format!(
|
||||
"Database upgrade aborted due to slug conflicts: {:?}",
|
||||
report.duplicates
|
||||
)
|
||||
.into());
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to audit slug namespace before migration: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
let admin_path = admin_dir.join("admin.db");
|
||||
let system_path = admin_dir.join("system.db");
|
||||
let users_db_path = admin_dir.join("users.db");
|
||||
@@ -199,7 +219,7 @@ impl Db {
|
||||
fs::create_dir_all(&legacy_user_dir)?;
|
||||
|
||||
if legacy_content_db.exists() || legacy_analytics_db.exists() {
|
||||
info!("Legacy content/analytics databases found at root. Moving to user ID 1 directory...");
|
||||
tracing::warn!("LEGACY DETECTED: content/analytics databases found at root. Moving to multi-tenant user ID 1 directory...");
|
||||
let content_files = vec!["content.db", "content.db-wal", "content.db-shm"];
|
||||
for f in content_files {
|
||||
let src = config.data_dir.join(f);
|
||||
@@ -317,6 +337,46 @@ impl Db {
|
||||
|
||||
let _ = db.reconcile_global_slugs(config);
|
||||
|
||||
// Post-init: Clean up stale reservations
|
||||
{
|
||||
let system_conn = db.system.lock().unwrap();
|
||||
match crate::db::users::cleanup_stale_reservations(&system_conn, &config.data_dir) {
|
||||
Ok(count) => {
|
||||
if count > 0 {
|
||||
tracing::info!("Cleaned up {} stale reserving slugs", count);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to clean up stale reservations: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Post-init: Verify global registry integrity
|
||||
{
|
||||
let system_conn = db.system.lock().unwrap();
|
||||
let users_conn = db.users.lock().unwrap();
|
||||
match crate::services::registry_validator::RegistryValidator::scan(
|
||||
&system_conn,
|
||||
&users_conn,
|
||||
&config.data_dir,
|
||||
None,
|
||||
) {
|
||||
Ok(issues) => {
|
||||
for issue in issues {
|
||||
tracing::error!(
|
||||
"Global registry integrity issue: {:?} for slug {}",
|
||||
issue.issue_type,
|
||||
issue.slug
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to verify global registry integrity: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(db)
|
||||
}
|
||||
|
||||
@@ -403,11 +463,7 @@ impl Db {
|
||||
|
||||
for user_id in user_ids {
|
||||
let user_dir = config.data_dir.join("users").join(user_id.to_string());
|
||||
let content_path = if user_id == 1 {
|
||||
config.data_dir.join("content.db") // legacy admin content db path
|
||||
} else {
|
||||
user_dir.join("content.db")
|
||||
};
|
||||
let content_path = user_dir.join("content.db");
|
||||
|
||||
if content_path.exists() {
|
||||
let content_conn = Connection::open(&content_path)?;
|
||||
|
||||
+326
-2
@@ -303,6 +303,19 @@ pub fn get_user_quotas(conn: &Connection, user_id: i64) -> rusqlite::Result<Opti
|
||||
.optional()
|
||||
}
|
||||
|
||||
pub fn check_quota_limit(conn: &Connection, user_id: i64, field: &str) -> rusqlite::Result<bool> {
|
||||
if let Some(quotas) = get_user_quotas(conn, user_id)? {
|
||||
match field {
|
||||
"urls" => Ok(quotas.current_urls < quotas.max_urls),
|
||||
"landings" => Ok(quotas.current_landings < quotas.max_landings),
|
||||
"api_tokens" => Ok(quotas.current_api_tokens < quotas.max_api_tokens),
|
||||
_ => Ok(false),
|
||||
}
|
||||
} else {
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update_user_quotas(
|
||||
conn: &Connection,
|
||||
user_id: i64,
|
||||
@@ -458,12 +471,13 @@ pub fn register_global_slug(
|
||||
owner_user_id: i64,
|
||||
target_type: &str,
|
||||
target_id: &str,
|
||||
status: &str,
|
||||
) -> rusqlite::Result<()> {
|
||||
let now = Utc::now().to_rfc3339();
|
||||
system_conn.execute(
|
||||
"INSERT INTO global_slugs (slug, owner_user_id, target_type, target_id, created_at, updated_at, status)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7);",
|
||||
rusqlite::params![slug, owner_user_id, target_type, target_id, now, now, "active"],
|
||||
rusqlite::params![slug, owner_user_id, target_type, target_id, now, now, status],
|
||||
)?;
|
||||
|
||||
// Insert history
|
||||
@@ -501,7 +515,7 @@ pub fn soft_delete_global_slug(
|
||||
) -> rusqlite::Result<()> {
|
||||
let now = Utc::now().to_rfc3339();
|
||||
system_conn.execute(
|
||||
"UPDATE global_slugs SET status = 'soft_deleted', deleted_at = ?1 WHERE slug = ?2;",
|
||||
"UPDATE global_slugs SET status = 'disabled', deleted_at = ?1 WHERE slug = ?2;",
|
||||
rusqlite::params![now, slug],
|
||||
)?;
|
||||
|
||||
@@ -515,6 +529,316 @@ pub fn soft_delete_global_slug(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
|
||||
pub struct SlugAuditReport {
|
||||
pub duplicates: Vec<String>,
|
||||
pub invalid_entries: Vec<String>,
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
pub fn audit_slug_namespace(
|
||||
config: &crate::config::Config,
|
||||
) -> Result<SlugAuditReport, Box<dyn std::error::Error>> {
|
||||
use std::collections::HashMap;
|
||||
let mut duplicates = Vec::new();
|
||||
let mut invalid_entries = Vec::new();
|
||||
let warnings = Vec::new();
|
||||
|
||||
let mut slug_owners: HashMap<String, Vec<i64>> = HashMap::new();
|
||||
|
||||
// 1. Scan legacy content.db if it exists
|
||||
let legacy_content_path = config.data_dir.join("content.db");
|
||||
if legacy_content_path.exists() {
|
||||
if let Ok(conn) = Connection::open(&legacy_content_path) {
|
||||
// URLs
|
||||
if let Ok(mut stmt) = conn.prepare("SELECT code FROM urls;") {
|
||||
if let Ok(mut rows) = stmt.query([]) {
|
||||
while let Some(row) = rows.next().unwrap_or(None) {
|
||||
if let Ok(code) = row.get::<_, String>(0) {
|
||||
slug_owners.entry(code).or_default().push(1); // 1 = legacy admin
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Landing Pages
|
||||
if let Ok(mut stmt) = conn.prepare("SELECT code FROM landing_pages;") {
|
||||
if let Ok(mut rows) = stmt.query([]) {
|
||||
while let Some(row) = rows.next().unwrap_or(None) {
|
||||
if let Ok(code) = row.get::<_, String>(0) {
|
||||
slug_owners.entry(code).or_default().push(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Scan all tenant databases in data_dir/users/<id>/content.db
|
||||
let users_dir = config.data_dir.join("users");
|
||||
if users_dir.exists() {
|
||||
for entry in std::fs::read_dir(users_dir)? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
if let Some(name_str) = path.file_name().and_then(|n| n.to_str()) {
|
||||
if let Ok(user_id) = name_str.parse::<i64>() {
|
||||
let content_db_path = path.join("content.db");
|
||||
if content_db_path.exists() {
|
||||
if let Ok(conn) = Connection::open(&content_db_path) {
|
||||
// URLs
|
||||
if let Ok(mut stmt) = conn.prepare("SELECT code FROM urls;") {
|
||||
if let Ok(mut rows) = stmt.query([]) {
|
||||
while let Some(row) = rows.next().unwrap_or(None) {
|
||||
if let Ok(code) = row.get::<_, String>(0) {
|
||||
slug_owners.entry(code).or_default().push(user_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Landing pages
|
||||
if let Ok(mut stmt) =
|
||||
conn.prepare("SELECT code FROM landing_pages;")
|
||||
{
|
||||
if let Ok(mut rows) = stmt.query([]) {
|
||||
while let Some(row) = rows.next().unwrap_or(None) {
|
||||
if let Ok(code) = row.get::<_, String>(0) {
|
||||
slug_owners.entry(code).or_default().push(user_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Populate report
|
||||
for (slug, owners) in slug_owners {
|
||||
if owners.len() > 1 {
|
||||
duplicates.push(format!(
|
||||
"Slug '{}' is defined in multiple content databases by owners {:?}",
|
||||
slug, owners
|
||||
));
|
||||
}
|
||||
// Validate slug format
|
||||
let valid_url = crate::utils::validation::validate_redirect_code(&slug);
|
||||
let valid_page = crate::utils::validation::validate_page_code(&slug);
|
||||
if !valid_url && !valid_page {
|
||||
invalid_entries.push(format!("Slug '{}' is format-invalid", slug));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(SlugAuditReport {
|
||||
duplicates,
|
||||
invalid_entries,
|
||||
warnings,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn cleanup_stale_reservations(
|
||||
system_conn: &Connection,
|
||||
data_dir: &std::path::Path,
|
||||
) -> Result<usize, Box<dyn std::error::Error>> {
|
||||
use chrono::{DateTime, Utc};
|
||||
let mut cleaned_count = 0;
|
||||
|
||||
let mut stmt = system_conn.prepare(
|
||||
"SELECT slug, owner_user_id, target_type, created_at FROM global_slugs WHERE status = 'reserving';"
|
||||
)?;
|
||||
let mut rows = stmt.query([])?;
|
||||
let mut stale_slugs = Vec::new();
|
||||
|
||||
while let Some(row) = rows.next()? {
|
||||
let slug: String = row.get(0)?;
|
||||
let owner_user_id: i64 = row.get(1)?;
|
||||
let target_type: String = row.get(2)?;
|
||||
let created_at_str: String = row.get(3)?;
|
||||
|
||||
if let Ok(created_at) = DateTime::parse_from_rfc3339(&created_at_str) {
|
||||
let age = Utc::now().signed_duration_since(created_at.with_timezone(&Utc));
|
||||
if age > chrono::Duration::minutes(15) {
|
||||
// Check if target record exists by looking up code = slug in owner's content.db
|
||||
let content_db_path = if owner_user_id == 1 {
|
||||
let p1 = data_dir.join("users").join("1").join("content.db");
|
||||
if p1.exists() {
|
||||
p1
|
||||
} else {
|
||||
data_dir.join("content.db")
|
||||
}
|
||||
} else {
|
||||
data_dir
|
||||
.join("users")
|
||||
.join(owner_user_id.to_string())
|
||||
.join("content.db")
|
||||
};
|
||||
|
||||
let mut target_exists = false;
|
||||
if content_db_path.exists() {
|
||||
if let Ok(conn) = Connection::open(&content_db_path) {
|
||||
if target_type == "url" {
|
||||
target_exists = conn
|
||||
.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM urls WHERE code = ?1);",
|
||||
[&slug],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap_or(false);
|
||||
} else if target_type == "page" {
|
||||
target_exists = conn
|
||||
.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM landing_pages WHERE code = ?1);",
|
||||
[&slug],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap_or(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !target_exists {
|
||||
stale_slugs.push((slug, owner_user_id));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
drop(rows);
|
||||
drop(stmt);
|
||||
|
||||
for (slug, owner_user_id) in stale_slugs {
|
||||
system_conn.execute("DELETE FROM global_slugs WHERE slug = ?1;", [&slug])?;
|
||||
let now = Utc::now().to_rfc3339();
|
||||
system_conn.execute(
|
||||
"INSERT INTO slug_history (slug, old_owner_user_id, new_owner_user_id, action, timestamp)
|
||||
VALUES (?1, ?2, NULL, 'released', ?3);",
|
||||
rusqlite::params![slug, owner_user_id, now],
|
||||
)?;
|
||||
cleaned_count += 1;
|
||||
}
|
||||
|
||||
Ok(cleaned_count)
|
||||
}
|
||||
|
||||
pub fn register_restored_user_slugs(
|
||||
system_conn: &Connection,
|
||||
target_user_id: i64,
|
||||
restored_content_db_path: &std::path::Path,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let restored_content_conn = Connection::open(restored_content_db_path)?;
|
||||
|
||||
let mut urls = Vec::new();
|
||||
let mut landing_pages = Vec::new();
|
||||
|
||||
// 1. Read URLs
|
||||
{
|
||||
let mut stmt =
|
||||
restored_content_conn.prepare("SELECT code, id, created_at, status FROM urls;")?;
|
||||
let mut rows = stmt.query([])?;
|
||||
while let Some(row) = rows.next()? {
|
||||
let code: String = row.get(0)?;
|
||||
let id: String = row.get(1)?;
|
||||
let created_at: String = row.get(2)?;
|
||||
let status: String = row.get(3)?;
|
||||
urls.push((code, id, created_at, status));
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Read Landing Pages
|
||||
{
|
||||
let mut stmt = restored_content_conn
|
||||
.prepare("SELECT code, id, created_at, state FROM landing_pages;")?;
|
||||
let mut rows = stmt.query([])?;
|
||||
while let Some(row) = rows.next()? {
|
||||
let code: String = row.get(0)?;
|
||||
let id: String = row.get(1)?;
|
||||
let created_at: String = row.get(2)?;
|
||||
let state: String = row.get(3)?;
|
||||
landing_pages.push((code, id, created_at, state));
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Check for collisions across all URLs and landing pages
|
||||
let mut conflicting_slugs = Vec::new();
|
||||
for (slug, _, _, _) in &urls {
|
||||
let existing_owner: Option<i64> = system_conn
|
||||
.query_row(
|
||||
"SELECT owner_user_id FROM global_slugs WHERE slug = ?1;",
|
||||
[slug],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.optional()?;
|
||||
|
||||
if let Some(owner) = existing_owner {
|
||||
if owner != target_user_id {
|
||||
conflicting_slugs.push(slug.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (slug, _, _, _) in &landing_pages {
|
||||
let existing_owner: Option<i64> = system_conn
|
||||
.query_row(
|
||||
"SELECT owner_user_id FROM global_slugs WHERE slug = ?1;",
|
||||
[slug],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.optional()?;
|
||||
|
||||
if let Some(owner) = existing_owner {
|
||||
if owner != target_user_id {
|
||||
conflicting_slugs.push(slug.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !conflicting_slugs.is_empty() {
|
||||
return Err(format!(
|
||||
"Restore failed. Conflicting slugs: {}",
|
||||
conflicting_slugs.join(", ")
|
||||
)
|
||||
.into());
|
||||
}
|
||||
|
||||
// 4. Perform registration
|
||||
system_conn.execute(
|
||||
"DELETE FROM global_slugs WHERE owner_user_id = ?1;",
|
||||
[target_user_id],
|
||||
)?;
|
||||
|
||||
for (slug, target_id, created_at, status) in urls {
|
||||
let now = Utc::now().to_rfc3339();
|
||||
let global_status = if status == "dead" {
|
||||
"disabled"
|
||||
} else {
|
||||
"active"
|
||||
};
|
||||
system_conn.execute(
|
||||
"INSERT OR REPLACE INTO global_slugs (slug, owner_user_id, target_type, target_id, created_at, updated_at, status)
|
||||
VALUES (?1, ?2, 'url', ?3, ?4, ?5, ?6);",
|
||||
rusqlite::params![slug, target_user_id, target_id, created_at, now, global_status],
|
||||
)?;
|
||||
}
|
||||
|
||||
for (slug, target_id, created_at, state) in landing_pages {
|
||||
let now = Utc::now().to_rfc3339();
|
||||
let status = if state == "published" {
|
||||
"active"
|
||||
} else {
|
||||
"disabled"
|
||||
};
|
||||
system_conn.execute(
|
||||
"INSERT OR REPLACE INTO global_slugs (slug, owner_user_id, target_type, target_id, created_at, updated_at, status)
|
||||
VALUES (?1, ?2, 'page', ?3, ?4, ?5, ?6);",
|
||||
rusqlite::params![slug, target_user_id, target_id, created_at, now, status],
|
||||
)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn reconcile_user_quotas(
|
||||
users_conn: &Connection,
|
||||
user_id: i64,
|
||||
|
||||
+12
-2
@@ -5,9 +5,19 @@ use super::{log_job_end, log_job_start};
|
||||
use crate::analytics::aggregate_day;
|
||||
use crate::db::Db;
|
||||
|
||||
pub async fn run_aggregator(db: Db, interval_mins: u64) {
|
||||
pub async fn run_aggregator(
|
||||
db: Db,
|
||||
interval_mins: u64,
|
||||
mut shutdown_rx: tokio::sync::watch::Receiver<bool>,
|
||||
) {
|
||||
loop {
|
||||
tokio::time::sleep(Duration::from_secs(interval_mins * 60)).await;
|
||||
tokio::select! {
|
||||
_ = tokio::time::sleep(Duration::from_secs(interval_mins * 60)) => {}
|
||||
_ = shutdown_rx.changed() => {
|
||||
info!("Analytics aggregator shutting down...");
|
||||
break;
|
||||
}
|
||||
}
|
||||
info!("Running background analytics aggregator...");
|
||||
|
||||
let user_ids: Vec<i64> = {
|
||||
|
||||
+12
-2
@@ -4,7 +4,11 @@ use crate::db::Db;
|
||||
use std::time::Duration;
|
||||
use tracing::{error, info};
|
||||
|
||||
pub async fn run_backup_scheduler(db: Db, config: Config) {
|
||||
pub async fn run_backup_scheduler(
|
||||
db: Db,
|
||||
config: Config,
|
||||
mut shutdown_rx: tokio::sync::watch::Receiver<bool>,
|
||||
) {
|
||||
if !config.backup_enabled {
|
||||
info!("Background backup scheduler is disabled.");
|
||||
return;
|
||||
@@ -16,7 +20,13 @@ pub async fn run_backup_scheduler(db: Db, config: Config) {
|
||||
);
|
||||
loop {
|
||||
// Run backup every configured interval
|
||||
tokio::time::sleep(Duration::from_secs(config.backup_interval_mins * 60)).await;
|
||||
tokio::select! {
|
||||
_ = tokio::time::sleep(Duration::from_secs(config.backup_interval_mins * 60)) => {}
|
||||
_ = shutdown_rx.changed() => {
|
||||
info!("Backup scheduler shutting down...");
|
||||
break;
|
||||
}
|
||||
}
|
||||
info!("Running background database backup...");
|
||||
|
||||
let job_id = log_job_start(&db.system, "database_backup");
|
||||
|
||||
+49
-9
@@ -1,33 +1,73 @@
|
||||
use crate::db::Db;
|
||||
use std::time::Duration;
|
||||
use tracing::info;
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
/// Background job that marks expired URLs.
|
||||
///
|
||||
/// Runs every 60 seconds. Any URL with `expires_at < NOW()` and `expired = 0`
|
||||
/// gets flipped to `expired = 1`.
|
||||
pub async fn run_expiry_checker(db: Db) {
|
||||
///
|
||||
/// Correctness note: the redirect handler treats wall-clock `expires_at` as
|
||||
/// authoritative and returns 410 without depending on this sweeper. The sweeper
|
||||
/// is maintenance (persist `expired=1`) and must remain idempotent.
|
||||
pub async fn run_expiry_checker(db: Db, mut shutdown_rx: tokio::sync::watch::Receiver<bool>) {
|
||||
loop {
|
||||
tokio::time::sleep(Duration::from_secs(60)).await;
|
||||
tokio::select! {
|
||||
_ = tokio::time::sleep(Duration::from_secs(60)) => {}
|
||||
_ = shutdown_rx.changed() => {
|
||||
info!("Expiry checker shutting down...");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let user_ids: Vec<i64> = {
|
||||
let conn = db.users.lock().unwrap();
|
||||
let conn = match db.users.lock() {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
error!(error = %e, "expiry job: users_db mutex poisoned");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let mut stmt = match conn.prepare("SELECT id FROM users;") {
|
||||
Ok(s) => s,
|
||||
Err(_) => continue,
|
||||
Err(e) => {
|
||||
error!(error = %e, "expiry job: failed to list users");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let rows = match stmt.query_map([], |row| row.get(0)) {
|
||||
Ok(r) => r,
|
||||
Err(_) => continue,
|
||||
Err(e) => {
|
||||
error!(error = %e, "expiry job: failed to map user ids");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
rows.filter_map(|r| r.ok()).collect()
|
||||
};
|
||||
|
||||
let mut total_expired = 0;
|
||||
for user_id in user_ids {
|
||||
if let Ok(conn) = super::open_user_content_conn(&db, user_id) {
|
||||
let count = crate::db::content::expire_urls(&conn).unwrap_or(0);
|
||||
total_expired += count;
|
||||
match super::open_user_content_conn(&db, user_id) {
|
||||
Ok(conn) => match crate::db::content::expire_urls(&conn) {
|
||||
Ok(count) => total_expired += count,
|
||||
Err(e) => {
|
||||
warn!(
|
||||
owner_user_id = user_id,
|
||||
error = %e,
|
||||
"expiry job: expire_urls failed"
|
||||
);
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
// Missing content.db for a user is common; only log open errors that are unexpected.
|
||||
if !matches!(e, rusqlite::Error::SqliteFailure(_, _)) {
|
||||
warn!(
|
||||
owner_user_id = user_id,
|
||||
error = %e,
|
||||
"expiry job: could not open content.db"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+13
-2
@@ -8,7 +8,11 @@ use uuid::Uuid;
|
||||
use super::{log_job_end, log_job_start};
|
||||
use crate::db::Db;
|
||||
|
||||
pub async fn run_link_checker(db: Db, interval_mins: u64) {
|
||||
pub async fn run_link_checker(
|
||||
db: Db,
|
||||
interval_mins: u64,
|
||||
mut shutdown_rx: tokio::sync::watch::Receiver<bool>,
|
||||
) {
|
||||
let client = Client::builder()
|
||||
.timeout(Duration::from_secs(10))
|
||||
.user_agent("bzod-link-checker/0.1")
|
||||
@@ -18,7 +22,14 @@ pub async fn run_link_checker(db: Db, interval_mins: u64) {
|
||||
|
||||
loop {
|
||||
// Sleep first to give server time to start up
|
||||
tokio::time::sleep(Duration::from_secs(interval_mins * 60)).await;
|
||||
tokio::select! {
|
||||
_ = tokio::time::sleep(Duration::from_secs(interval_mins * 60)) => {}
|
||||
_ = shutdown_rx.changed() => {
|
||||
info!("Link checker shutting down...");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
info!("Running background link health check...");
|
||||
|
||||
let job_id = log_job_start(&db.system, "link_checker");
|
||||
|
||||
@@ -2,10 +2,20 @@ use crate::db::Db;
|
||||
use std::time::Duration;
|
||||
use tracing::{error, info};
|
||||
|
||||
pub async fn run_quota_reconciliation(db: Db, interval_hours: u64) {
|
||||
pub async fn run_quota_reconciliation(
|
||||
db: Db,
|
||||
interval_hours: u64,
|
||||
mut shutdown_rx: tokio::sync::watch::Receiver<bool>,
|
||||
) {
|
||||
loop {
|
||||
// Sleep first
|
||||
tokio::time::sleep(Duration::from_secs(interval_hours * 3600)).await;
|
||||
tokio::select! {
|
||||
_ = tokio::time::sleep(Duration::from_secs(interval_hours * 3600)) => {}
|
||||
_ = shutdown_rx.changed() => {
|
||||
info!("Quota reconciliation shutting down...");
|
||||
break;
|
||||
}
|
||||
}
|
||||
info!("Running background quota reconciliation...");
|
||||
|
||||
let user_ids: Vec<i64> = {
|
||||
|
||||
+12
-2
@@ -4,7 +4,11 @@ use tracing::{error, info};
|
||||
use super::{log_job_end, log_job_start};
|
||||
use crate::db::Db;
|
||||
|
||||
pub async fn run_retention_cleaner(db: Db, retention_days_opt: Option<i64>) {
|
||||
pub async fn run_retention_cleaner(
|
||||
db: Db,
|
||||
retention_days_opt: Option<i64>,
|
||||
mut shutdown_rx: tokio::sync::watch::Receiver<bool>,
|
||||
) {
|
||||
let retention_days = match retention_days_opt {
|
||||
Some(days) => days,
|
||||
None => return,
|
||||
@@ -12,7 +16,13 @@ pub async fn run_retention_cleaner(db: Db, retention_days_opt: Option<i64>) {
|
||||
|
||||
loop {
|
||||
// Check once every 24 hours
|
||||
tokio::time::sleep(Duration::from_secs(24 * 3600)).await;
|
||||
tokio::select! {
|
||||
_ = tokio::time::sleep(Duration::from_secs(24 * 3600)) => {}
|
||||
_ = shutdown_rx.changed() => {
|
||||
info!("Retention cleaner shutting down...");
|
||||
break;
|
||||
}
|
||||
}
|
||||
info!("Running background data retention cleanup...");
|
||||
|
||||
let user_ids: Vec<i64> = {
|
||||
|
||||
+15
@@ -38,6 +38,9 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
Commands::Validate { data_dir } => {
|
||||
bzod::cli::validate::run(data_dir, config).await?;
|
||||
}
|
||||
Commands::AuditDestinations { data_dir } => {
|
||||
bzod::cli::audit_destinations::run(data_dir, config).await?;
|
||||
}
|
||||
Commands::CreateAdmin { username, data_dir } => {
|
||||
bzod::cli::create_admin::run(username, data_dir, config).await?;
|
||||
}
|
||||
@@ -94,6 +97,18 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
Commands::RestoreUser { file, data_dir } => {
|
||||
bzod::cli::restore_user::run(file, data_dir, config).await?;
|
||||
}
|
||||
Commands::AdminMigrate {
|
||||
target_admin_id,
|
||||
data_dir,
|
||||
dry_run,
|
||||
force,
|
||||
} => {
|
||||
bzod::cli::admin_migrate::run(target_admin_id, data_dir, dry_run, force, config)
|
||||
.await?;
|
||||
}
|
||||
Commands::Repair { command } => {
|
||||
bzod::cli::repair::run(command, config).await?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
//! Post-restore filesystem layout normalization for multi-tenant BZOD data dirs.
|
||||
//!
|
||||
//! Extracted from admin restore handlers so path moves are testable without HTTP.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use tracing::warn;
|
||||
|
||||
/// Move flat legacy DB files into multi-tenant paths after tarball extract.
|
||||
///
|
||||
/// Layout:
|
||||
/// - `admin.db` / `system.db` / `users.db` (+ wal/shm) → `{data_dir}/admin/`
|
||||
/// - `content.db` / `analytics.db` (+ wal/shm) → `{data_dir}/users/1/`
|
||||
pub fn normalize_restored_layout(data_dir: &Path) -> std::io::Result<()> {
|
||||
let admin_dir = data_dir.join("admin");
|
||||
let users_1_dir = data_dir.join("users").join("1");
|
||||
std::fs::create_dir_all(&admin_dir)?;
|
||||
std::fs::create_dir_all(&users_1_dir)?;
|
||||
|
||||
let admin_files = [
|
||||
"admin.db",
|
||||
"admin.db-wal",
|
||||
"admin.db-shm",
|
||||
"system.db",
|
||||
"system.db-wal",
|
||||
"system.db-shm",
|
||||
"users.db",
|
||||
"users.db-wal",
|
||||
"users.db-shm",
|
||||
];
|
||||
for f in admin_files {
|
||||
let src = data_dir.join(f);
|
||||
if src.exists() {
|
||||
let dst = admin_dir.join(f);
|
||||
if let Err(e) = std::fs::rename(&src, &dst) {
|
||||
warn!(
|
||||
file = f,
|
||||
error = %e,
|
||||
"failed to move restored admin file into admin/"
|
||||
);
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let content_files = [
|
||||
"content.db",
|
||||
"content.db-wal",
|
||||
"content.db-shm",
|
||||
"analytics.db",
|
||||
"analytics.db-wal",
|
||||
"analytics.db-shm",
|
||||
];
|
||||
for f in content_files {
|
||||
let src = data_dir.join(f);
|
||||
if src.exists() {
|
||||
let dst = users_1_dir.join(f);
|
||||
if let Err(e) = std::fs::rename(&src, &dst) {
|
||||
warn!(
|
||||
file = f,
|
||||
error = %e,
|
||||
"failed to move restored content file into users/1/"
|
||||
);
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Paths used when reopening connections after restore.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RestoredDbPaths {
|
||||
pub admin: PathBuf,
|
||||
pub system: PathBuf,
|
||||
pub users: PathBuf,
|
||||
pub content: PathBuf,
|
||||
pub analytics: PathBuf,
|
||||
}
|
||||
|
||||
impl RestoredDbPaths {
|
||||
pub fn from_data_dir(data_dir: &Path) -> Self {
|
||||
Self {
|
||||
admin: data_dir.join("admin/admin.db"),
|
||||
system: data_dir.join("admin/system.db"),
|
||||
users: data_dir.join("admin/users.db"),
|
||||
content: data_dir.join("users/1/content.db"),
|
||||
analytics: data_dir.join("users/1/analytics.db"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
|
||||
#[test]
|
||||
fn moves_flat_files_into_tenant_layout() {
|
||||
let dir = std::env::temp_dir().join(format!("bzod_layout_{}", uuid::Uuid::new_v4()));
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
fs::write(dir.join("admin.db"), b"a").unwrap();
|
||||
fs::write(dir.join("system.db"), b"s").unwrap();
|
||||
fs::write(dir.join("users.db"), b"u").unwrap();
|
||||
fs::write(dir.join("content.db"), b"c").unwrap();
|
||||
fs::write(dir.join("analytics.db"), b"an").unwrap();
|
||||
|
||||
normalize_restored_layout(&dir).unwrap();
|
||||
|
||||
assert!(dir.join("admin/admin.db").exists());
|
||||
assert!(dir.join("admin/system.db").exists());
|
||||
assert!(dir.join("admin/users.db").exists());
|
||||
assert!(dir.join("users/1/content.db").exists());
|
||||
assert!(dir.join("users/1/analytics.db").exists());
|
||||
assert!(!dir.join("admin.db").exists());
|
||||
assert!(!dir.join("content.db").exists());
|
||||
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
//! Bulk URL creation business logic (transaction + slug reservation).
|
||||
//!
|
||||
//! Handlers own auth/HTTP; this module owns validation, reservation, and inserts.
|
||||
|
||||
use crate::auth::generate_token;
|
||||
use crate::auth::password::hash_password;
|
||||
use crate::models::Url;
|
||||
use crate::utils::validation::validate_redirect_destination;
|
||||
use rusqlite::{Connection, Transaction};
|
||||
use std::sync::Mutex;
|
||||
|
||||
/// One item in a bulk URL create request (mirrors the HTTP payload shape).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BulkUrlCreateItem {
|
||||
pub destination: String,
|
||||
pub code: Option<String>,
|
||||
pub title: Option<String>,
|
||||
pub description: Option<String>,
|
||||
pub tags: Option<Vec<String>>,
|
||||
pub expires_at: Option<String>,
|
||||
pub password: Option<String>,
|
||||
pub max_access_count: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum BulkUrlError {
|
||||
BadRequest(String),
|
||||
Conflict(String),
|
||||
Forbidden(String),
|
||||
Internal(String),
|
||||
}
|
||||
|
||||
impl BulkUrlError {
|
||||
pub fn message(&self) -> &str {
|
||||
match self {
|
||||
Self::BadRequest(m) | Self::Conflict(m) | Self::Forbidden(m) | Self::Internal(m) => m,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn release_reserved(system: &Connection, slugs: &[String], owner_user_id: i64) {
|
||||
for slug in slugs {
|
||||
let _ = crate::db::users::release_global_slug(system, slug, owner_user_id);
|
||||
}
|
||||
}
|
||||
|
||||
/// Check that the tenant can accept `additional` new URLs.
|
||||
pub fn ensure_url_quota(
|
||||
users_db: &Mutex<Connection>,
|
||||
user_id: i64,
|
||||
additional: i64,
|
||||
) -> Result<(), BulkUrlError> {
|
||||
let users_conn = crate::utils::lock_db(users_db, "users_db")
|
||||
.map_err(|e| BulkUrlError::Internal(e.to_string()))?;
|
||||
match crate::db::users::get_user_quotas(&users_conn, user_id) {
|
||||
Ok(Some(quotas)) => {
|
||||
if quotas.current_urls + additional > quotas.max_urls {
|
||||
Err(BulkUrlError::Forbidden("Quota limit exceeded".into()))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
Ok(None) => Err(BulkUrlError::Forbidden("User quota not found".into())),
|
||||
Err(e) => Err(BulkUrlError::Internal(format!("quota lookup failed: {e}"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create many URLs inside a single content transaction with global slug reservation.
|
||||
pub fn create_urls_bulk(
|
||||
content_db: &Mutex<Connection>,
|
||||
system_db: &Mutex<Connection>,
|
||||
users_db: &Mutex<Connection>,
|
||||
owner_user_id: i64,
|
||||
items: Vec<BulkUrlCreateItem>,
|
||||
) -> Result<Vec<Url>, BulkUrlError> {
|
||||
let mut conn = crate::utils::lock_db(content_db, "content_db")
|
||||
.map_err(|e| BulkUrlError::Internal(e.to_string()))?;
|
||||
let tx = conn.transaction().map_err(|e| {
|
||||
BulkUrlError::Internal(format!("Failed to start database transaction: {e}"))
|
||||
})?;
|
||||
|
||||
let mut created_urls = Vec::new();
|
||||
let mut reserved_slugs: Vec<String> = Vec::new();
|
||||
|
||||
for item in items {
|
||||
match create_one_in_tx(&tx, system_db, owner_user_id, item, &mut reserved_slugs) {
|
||||
Ok(url) => created_urls.push(url),
|
||||
Err(e) => {
|
||||
let _ = tx.rollback();
|
||||
if let Ok(system_conn) = crate::utils::lock_db(system_db, "system_db") {
|
||||
release_reserved(&system_conn, &reserved_slugs, owner_user_id);
|
||||
}
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Err(e) = tx.commit() {
|
||||
if let Ok(system_conn) = crate::utils::lock_db(system_db, "system_db") {
|
||||
release_reserved(&system_conn, &reserved_slugs, owner_user_id);
|
||||
}
|
||||
return Err(BulkUrlError::Internal(format!(
|
||||
"Failed to commit transaction: {e}"
|
||||
)));
|
||||
}
|
||||
|
||||
// Activate slugs
|
||||
{
|
||||
let system_conn = crate::utils::lock_db(system_db, "system_db")
|
||||
.map_err(|e| BulkUrlError::Internal(e.to_string()))?;
|
||||
for url in &created_urls {
|
||||
let _ = system_conn.execute(
|
||||
"UPDATE global_slugs SET target_id = ?1, status = 'active', updated_at = ?2 WHERE slug = ?3;",
|
||||
rusqlite::params![url.id, chrono::Utc::now().to_rfc3339(), url.code],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Increment quota counters
|
||||
{
|
||||
let users_conn = crate::utils::lock_db(users_db, "users_db")
|
||||
.map_err(|e| BulkUrlError::Internal(e.to_string()))?;
|
||||
for _ in 0..created_urls.len() {
|
||||
let _ = crate::db::users::increment_quota_counter(&users_conn, owner_user_id, "urls");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(created_urls)
|
||||
}
|
||||
|
||||
fn create_one_in_tx(
|
||||
tx: &Transaction<'_>,
|
||||
system_db: &Mutex<Connection>,
|
||||
owner_user_id: i64,
|
||||
item: BulkUrlCreateItem,
|
||||
reserved_slugs: &mut Vec<String>,
|
||||
) -> Result<Url, BulkUrlError> {
|
||||
let mut code = item.code.unwrap_or_default().trim().to_lowercase();
|
||||
if code.is_empty() {
|
||||
code = generate_token(3);
|
||||
} else if code.len() != 6 || !code.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
return Err(BulkUrlError::BadRequest(format!(
|
||||
"Short code '{code}' must be 6 hex characters"
|
||||
)));
|
||||
}
|
||||
|
||||
{
|
||||
let system_conn = crate::utils::lock_db(system_db, "system_db")
|
||||
.map_err(|e| BulkUrlError::Internal(e.to_string()))?;
|
||||
let available = crate::db::users::is_slug_available(&system_conn, &code).unwrap_or(false)
|
||||
&& !reserved_slugs.contains(&code);
|
||||
if !available {
|
||||
return Err(BulkUrlError::Conflict(format!(
|
||||
"Short code '{code}' already exists"
|
||||
)));
|
||||
}
|
||||
if let Err(e) = crate::db::users::register_global_slug(
|
||||
&system_conn,
|
||||
&code,
|
||||
owner_user_id,
|
||||
"url",
|
||||
"",
|
||||
"reserving",
|
||||
) {
|
||||
return Err(BulkUrlError::Internal(format!(
|
||||
"Failed to reserve slug '{code}': {e}"
|
||||
)));
|
||||
}
|
||||
reserved_slugs.push(code.clone());
|
||||
}
|
||||
|
||||
let password_hash = if let Some(ref pwd) = item.password {
|
||||
match hash_password(pwd) {
|
||||
Ok(h) => Some(h),
|
||||
Err(e) => {
|
||||
return Err(BulkUrlError::Internal(format!(
|
||||
"Password hashing error: {e}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if !validate_redirect_destination(&item.destination) {
|
||||
return Err(BulkUrlError::BadRequest(format!(
|
||||
"Invalid destination for item '{code}': must be a valid http(s) URL without control characters"
|
||||
)));
|
||||
}
|
||||
|
||||
let tags = item.tags.unwrap_or_default();
|
||||
crate::db::content::create_url_extended(
|
||||
tx,
|
||||
&code,
|
||||
&item.destination,
|
||||
item.title.as_deref(),
|
||||
item.description.as_deref(),
|
||||
&tags,
|
||||
item.expires_at.as_deref(),
|
||||
password_hash.as_deref(),
|
||||
item.max_access_count,
|
||||
)
|
||||
.map_err(|e| BulkUrlError::Internal(format!("Database insert error: {e}")))
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
//! Read-only audit of stored redirect destinations.
|
||||
//!
|
||||
//! Scans tenant content databases and classifies each `urls.destination` using
|
||||
//! the same rules as write-path validation. Never rewrites or deletes data.
|
||||
|
||||
use crate::db::Db;
|
||||
use crate::utils::validation::{classify_redirect_destination, DestinationClass};
|
||||
use rusqlite::Connection;
|
||||
use std::path::Path;
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
/// Summary counters for a destination audit run.
|
||||
#[derive(Debug, Default, Clone, PartialEq, Eq)]
|
||||
pub struct DestinationAuditReport {
|
||||
pub scanned_users: usize,
|
||||
pub total_urls: usize,
|
||||
pub valid_http: usize,
|
||||
pub valid_https: usize,
|
||||
pub invalid: usize,
|
||||
pub control_characters: usize,
|
||||
pub unsupported_scheme: usize,
|
||||
pub malformed: usize,
|
||||
pub empty: usize,
|
||||
pub too_long: usize,
|
||||
pub non_ascii: usize,
|
||||
/// Safe sample of invalid records: (owner_user_id, code, class_label).
|
||||
/// Destination bodies are never included (may contain control chars / secrets).
|
||||
pub invalid_samples: Vec<InvalidDestinationSample>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct InvalidDestinationSample {
|
||||
pub owner_user_id: i64,
|
||||
pub code: String,
|
||||
pub url_id: String,
|
||||
pub class: &'static str,
|
||||
pub destination_len: usize,
|
||||
}
|
||||
|
||||
const MAX_SAMPLES: usize = 50;
|
||||
|
||||
fn class_label(c: DestinationClass) -> &'static str {
|
||||
match c {
|
||||
DestinationClass::ValidHttp => "valid_http",
|
||||
DestinationClass::ValidHttps => "valid_https",
|
||||
DestinationClass::Empty => "empty",
|
||||
DestinationClass::TooLong => "too_long",
|
||||
DestinationClass::ControlCharacters => "control_characters",
|
||||
DestinationClass::NonAscii => "non_ascii",
|
||||
DestinationClass::UnsupportedScheme => "unsupported_scheme",
|
||||
DestinationClass::Malformed => "malformed",
|
||||
}
|
||||
}
|
||||
|
||||
/// Classify a single destination and update report counters.
|
||||
pub fn record_destination(
|
||||
report: &mut DestinationAuditReport,
|
||||
owner_user_id: i64,
|
||||
code: &str,
|
||||
url_id: &str,
|
||||
destination: &str,
|
||||
) {
|
||||
report.total_urls += 1;
|
||||
let class = classify_redirect_destination(destination);
|
||||
match class {
|
||||
DestinationClass::ValidHttp => report.valid_http += 1,
|
||||
DestinationClass::ValidHttps => report.valid_https += 1,
|
||||
DestinationClass::Empty => {
|
||||
report.empty += 1;
|
||||
report.invalid += 1;
|
||||
}
|
||||
DestinationClass::TooLong => {
|
||||
report.too_long += 1;
|
||||
report.invalid += 1;
|
||||
}
|
||||
DestinationClass::ControlCharacters => {
|
||||
report.control_characters += 1;
|
||||
report.invalid += 1;
|
||||
}
|
||||
DestinationClass::NonAscii => {
|
||||
report.non_ascii += 1;
|
||||
report.invalid += 1;
|
||||
}
|
||||
DestinationClass::UnsupportedScheme => {
|
||||
report.unsupported_scheme += 1;
|
||||
report.invalid += 1;
|
||||
}
|
||||
DestinationClass::Malformed => {
|
||||
report.malformed += 1;
|
||||
report.invalid += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if !class.is_valid() && report.invalid_samples.len() < MAX_SAMPLES {
|
||||
report.invalid_samples.push(InvalidDestinationSample {
|
||||
owner_user_id,
|
||||
code: code.to_string(),
|
||||
url_id: url_id.to_string(),
|
||||
class: class_label(class),
|
||||
destination_len: destination.len(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Scan one content database connection for URL destinations.
|
||||
pub fn audit_content_conn(
|
||||
conn: &Connection,
|
||||
owner_user_id: i64,
|
||||
report: &mut DestinationAuditReport,
|
||||
) -> rusqlite::Result<()> {
|
||||
let mut stmt = conn.prepare("SELECT id, code, destination FROM urls;")?;
|
||||
let rows = stmt.query_map([], |row| {
|
||||
Ok((
|
||||
row.get::<_, String>(0)?,
|
||||
row.get::<_, String>(1)?,
|
||||
row.get::<_, String>(2)?,
|
||||
))
|
||||
})?;
|
||||
|
||||
for row in rows {
|
||||
let (id, code, destination) = row?;
|
||||
record_destination(report, owner_user_id, &code, &id, &destination);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn open_user_content(data_dir: &Path, user_id: i64) -> Result<Connection, rusqlite::Error> {
|
||||
let path = data_dir
|
||||
.join("users")
|
||||
.join(user_id.to_string())
|
||||
.join("content.db");
|
||||
if !path.exists() {
|
||||
return Err(rusqlite::Error::InvalidPath(path));
|
||||
}
|
||||
let conn = Connection::open(path)?;
|
||||
crate::db::sqlite::enable_wal(&conn, "content")?;
|
||||
Ok(conn)
|
||||
}
|
||||
|
||||
/// Audit all tenant content databases found under the configured data directory.
|
||||
///
|
||||
/// Read-only: does not modify any records.
|
||||
pub fn audit_all_destinations(db: &Db) -> Result<DestinationAuditReport, String> {
|
||||
let mut report = DestinationAuditReport::default();
|
||||
|
||||
let user_ids: Vec<i64> = {
|
||||
let users = db
|
||||
.users
|
||||
.lock()
|
||||
.map_err(|e| format!("users_db lock poisoned: {}", e))?;
|
||||
let mut stmt = users
|
||||
.prepare("SELECT id FROM users;")
|
||||
.map_err(|e| e.to_string())?;
|
||||
let rows = stmt
|
||||
.query_map([], |row| row.get(0))
|
||||
.map_err(|e| e.to_string())?;
|
||||
rows.filter_map(|r| r.ok()).collect()
|
||||
};
|
||||
|
||||
for user_id in user_ids {
|
||||
match open_user_content(&db.data_dir, user_id) {
|
||||
Ok(conn) => {
|
||||
report.scanned_users += 1;
|
||||
if let Err(e) = audit_content_conn(&conn, user_id, &mut report) {
|
||||
error!(
|
||||
owner_user_id = user_id,
|
||||
error = %e,
|
||||
"destination audit failed for user content.db"
|
||||
);
|
||||
return Err(format!("audit user {} content.db: {}", user_id, e));
|
||||
}
|
||||
}
|
||||
Err(rusqlite::Error::InvalidPath(_)) => {
|
||||
// User has no content DB yet — skip.
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(
|
||||
owner_user_id = user_id,
|
||||
error = %e,
|
||||
"could not open user content.db for destination audit"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
info!(
|
||||
total_urls = report.total_urls,
|
||||
valid = report.valid_http + report.valid_https,
|
||||
invalid = report.invalid,
|
||||
"destination audit complete"
|
||||
);
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
/// Format a human-readable report for CLI output.
|
||||
pub fn format_report(report: &DestinationAuditReport) -> String {
|
||||
let mut out = String::new();
|
||||
out.push_str("BZOD Redirect Destination Audit (read-only)\n");
|
||||
out.push_str("===========================================\n");
|
||||
out.push_str(&format!("Users scanned: {}\n", report.scanned_users));
|
||||
out.push_str(&format!("Total URLs: {}\n", report.total_urls));
|
||||
out.push_str(&format!("Valid HTTP: {}\n", report.valid_http));
|
||||
out.push_str(&format!("Valid HTTPS: {}\n", report.valid_https));
|
||||
out.push_str(&format!("Invalid (total): {}\n", report.invalid));
|
||||
out.push_str(&format!(
|
||||
" control characters: {}\n",
|
||||
report.control_characters
|
||||
));
|
||||
out.push_str(&format!(
|
||||
" unsupported scheme: {}\n",
|
||||
report.unsupported_scheme
|
||||
));
|
||||
out.push_str(&format!(" malformed: {}\n", report.malformed));
|
||||
out.push_str(&format!(" empty: {}\n", report.empty));
|
||||
out.push_str(&format!(" too long: {}\n", report.too_long));
|
||||
out.push_str(&format!(" non-ascii: {}\n", report.non_ascii));
|
||||
|
||||
if !report.invalid_samples.is_empty() {
|
||||
out.push_str("\nInvalid samples (id/code only; destinations not printed):\n");
|
||||
for s in &report.invalid_samples {
|
||||
out.push_str(&format!(
|
||||
" user={} code={} id={} class={} dest_len={}\n",
|
||||
s.owner_user_id, s.code, s.url_id, s.class, s.destination_len
|
||||
));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn records_control_character_destination() {
|
||||
let mut report = DestinationAuditReport::default();
|
||||
record_destination(
|
||||
&mut report,
|
||||
1,
|
||||
"ab12cd",
|
||||
"id-1",
|
||||
"https://evil.example/\r\nX:1",
|
||||
);
|
||||
assert_eq!(report.total_urls, 1);
|
||||
assert_eq!(report.invalid, 1);
|
||||
assert_eq!(report.control_characters, 1);
|
||||
assert_eq!(report.invalid_samples.len(), 1);
|
||||
assert_eq!(report.invalid_samples[0].class, "control_characters");
|
||||
// Ensure we never store the destination body in the sample.
|
||||
assert!(!format!("{:?}", report.invalid_samples[0]).contains("evil"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn records_valid_https() {
|
||||
let mut report = DestinationAuditReport::default();
|
||||
record_destination(&mut report, 1, "ab12cd", "id-1", "https://example.com/ok");
|
||||
assert_eq!(report.valid_https, 1);
|
||||
assert_eq!(report.invalid, 0);
|
||||
assert!(report.invalid_samples.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,12 @@
|
||||
pub mod api_keys;
|
||||
pub mod audit;
|
||||
pub mod backup_layout;
|
||||
pub mod bulk;
|
||||
pub mod bulk_urls;
|
||||
pub mod destination_audit;
|
||||
pub mod landing_pages;
|
||||
pub mod qr;
|
||||
pub mod registry_validator;
|
||||
pub mod shortener;
|
||||
pub mod slug_transfer;
|
||||
pub mod urls;
|
||||
@@ -0,0 +1,285 @@
|
||||
use rusqlite::Connection;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum RegistryIssueType {
|
||||
DuplicateSlug,
|
||||
InvalidTargetType,
|
||||
InvalidStatus,
|
||||
MissingOwner,
|
||||
MissingDatabase,
|
||||
MissingTarget,
|
||||
StaleReservation,
|
||||
TenantAdminHasIsolatedContent,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RegistryIssue {
|
||||
pub slug: String,
|
||||
pub target_type: String,
|
||||
pub owner_user_id: i64,
|
||||
pub database_path: PathBuf,
|
||||
pub target_id: String,
|
||||
pub issue_type: RegistryIssueType,
|
||||
pub description: String,
|
||||
}
|
||||
|
||||
pub struct RegistryValidator;
|
||||
|
||||
impl RegistryValidator {
|
||||
/// Scans the global_slugs registry and returns a list of detected issues.
|
||||
pub fn scan(
|
||||
system_conn: &Connection,
|
||||
users_conn: &Connection,
|
||||
data_dir: &Path,
|
||||
slug_filter: Option<&str>,
|
||||
) -> Result<Vec<RegistryIssue>, Box<dyn std::error::Error>> {
|
||||
use chrono::{DateTime, Utc};
|
||||
let mut issues = Vec::new();
|
||||
|
||||
// 1. Check duplicate slugs (only if not filtering by single slug)
|
||||
if slug_filter.is_none() {
|
||||
let total_count: i64 =
|
||||
system_conn.query_row("SELECT COUNT(*) FROM global_slugs;", [], |r| r.get(0))?;
|
||||
let distinct_count: i64 = system_conn.query_row(
|
||||
"SELECT COUNT(DISTINCT slug) FROM global_slugs;",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)?;
|
||||
if total_count != distinct_count {
|
||||
issues.push(RegistryIssue {
|
||||
slug: "*".to_string(),
|
||||
target_type: "system".to_string(),
|
||||
owner_user_id: 0,
|
||||
database_path: data_dir.join("admin/system.db"),
|
||||
target_id: "".to_string(),
|
||||
issue_type: RegistryIssueType::DuplicateSlug,
|
||||
description: format!(
|
||||
"Duplicate slugs found in global_slugs table (total rows: {}, distinct slugs: {})",
|
||||
total_count, distinct_count
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Scan global slugs
|
||||
let (query, params_string) = if let Some(slug) = slug_filter {
|
||||
(
|
||||
"SELECT slug, owner_user_id, target_type, target_id, created_at, status FROM global_slugs WHERE slug = ?1;",
|
||||
vec![slug.to_string()],
|
||||
)
|
||||
} else {
|
||||
(
|
||||
"SELECT slug, owner_user_id, target_type, target_id, created_at, status FROM global_slugs;",
|
||||
vec![],
|
||||
)
|
||||
};
|
||||
|
||||
let mut stmt = system_conn.prepare(query)?;
|
||||
let mut rows = stmt.query(rusqlite::params_from_iter(params_string))?;
|
||||
|
||||
while let Some(row) = rows.next()? {
|
||||
let slug: String = row.get(0)?;
|
||||
let owner_user_id: i64 = row.get(1)?;
|
||||
let target_type: String = row.get(2)?;
|
||||
let target_id: String = row.get(3)?;
|
||||
let created_at_str: String = row.get(4)?;
|
||||
let status: String = row.get(5)?;
|
||||
|
||||
let content_db_path = if owner_user_id == 1 {
|
||||
data_dir.join("users").join("1").join("content.db")
|
||||
} else {
|
||||
data_dir
|
||||
.join("users")
|
||||
.join(owner_user_id.to_string())
|
||||
.join("content.db")
|
||||
};
|
||||
|
||||
// Target type check
|
||||
if target_type != "url" && target_type != "page" {
|
||||
issues.push(RegistryIssue {
|
||||
slug: slug.clone(),
|
||||
target_type: target_type.clone(),
|
||||
owner_user_id,
|
||||
database_path: content_db_path.clone(),
|
||||
target_id: target_id.clone(),
|
||||
issue_type: RegistryIssueType::InvalidTargetType,
|
||||
description: format!(
|
||||
"Slug '{}' has invalid target_type '{}'",
|
||||
slug, target_type
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
// Status check
|
||||
if status != "active" && status != "disabled" && status != "reserving" {
|
||||
issues.push(RegistryIssue {
|
||||
slug: slug.clone(),
|
||||
target_type: target_type.clone(),
|
||||
owner_user_id,
|
||||
database_path: content_db_path.clone(),
|
||||
target_id: target_id.clone(),
|
||||
issue_type: RegistryIssueType::InvalidStatus,
|
||||
description: format!("Slug '{}' has invalid status '{}'", slug, status),
|
||||
});
|
||||
}
|
||||
|
||||
// Check owner
|
||||
let owner_exists: bool = users_conn
|
||||
.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM users WHERE id = ?1);",
|
||||
[owner_user_id],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap_or(false);
|
||||
|
||||
if !owner_exists {
|
||||
issues.push(RegistryIssue {
|
||||
slug: slug.clone(),
|
||||
target_type: target_type.clone(),
|
||||
owner_user_id,
|
||||
database_path: content_db_path.clone(),
|
||||
target_id: target_id.clone(),
|
||||
issue_type: RegistryIssueType::MissingOwner,
|
||||
description: format!(
|
||||
"Slug '{}' references missing owner user ID {}",
|
||||
slug, owner_user_id
|
||||
),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Stale warning check
|
||||
if status == "reserving" {
|
||||
if let Ok(created_at) = DateTime::parse_from_rfc3339(&created_at_str) {
|
||||
let age = Utc::now().signed_duration_since(created_at.with_timezone(&Utc));
|
||||
if age > chrono::Duration::try_minutes(15).unwrap_or_default() {
|
||||
issues.push(RegistryIssue {
|
||||
slug: slug.clone(),
|
||||
target_type: target_type.clone(),
|
||||
owner_user_id,
|
||||
database_path: content_db_path.clone(),
|
||||
target_id: target_id.clone(),
|
||||
issue_type: RegistryIssueType::StaleReservation,
|
||||
description: format!(
|
||||
"Reserving slug '{}' has been stale for over 15 minutes",
|
||||
slug
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check target record exists for active / disabled (and reserving with target_id)
|
||||
if status == "active"
|
||||
|| status == "disabled"
|
||||
|| (status == "reserving" && !target_id.is_empty())
|
||||
{
|
||||
if !content_db_path.exists() {
|
||||
issues.push(RegistryIssue {
|
||||
slug: slug.clone(),
|
||||
target_type: target_type.clone(),
|
||||
owner_user_id,
|
||||
database_path: content_db_path.clone(),
|
||||
target_id: target_id.clone(),
|
||||
issue_type: RegistryIssueType::MissingDatabase,
|
||||
description: format!(
|
||||
"Slug '{}' owner content database does not exist at {:?}",
|
||||
slug, content_db_path
|
||||
),
|
||||
});
|
||||
} else {
|
||||
match Connection::open(&content_db_path) {
|
||||
Ok(conn) => {
|
||||
let exists = if target_type == "url" {
|
||||
conn.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM urls WHERE id = ?1);",
|
||||
[&target_id],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap_or(false)
|
||||
} else if target_type == "page" {
|
||||
conn.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM landing_pages WHERE id = ?1);",
|
||||
[&target_id],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap_or(false)
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
if !exists {
|
||||
issues.push(RegistryIssue {
|
||||
slug: slug.clone(),
|
||||
target_type: target_type.clone(),
|
||||
owner_user_id,
|
||||
database_path: content_db_path.clone(),
|
||||
target_id: target_id.clone(),
|
||||
issue_type: RegistryIssueType::MissingTarget,
|
||||
description: format!("Slug '{}' (type: '{}', id: '{}') references missing target record in owner's content database", slug, target_type, target_id),
|
||||
});
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
issues.push(RegistryIssue {
|
||||
slug: slug.clone(),
|
||||
target_type: target_type.clone(),
|
||||
owner_user_id,
|
||||
database_path: content_db_path.clone(),
|
||||
target_id: target_id.clone(),
|
||||
issue_type: RegistryIssueType::MissingDatabase,
|
||||
description: format!(
|
||||
"Slug '{}' owner content database could not be opened: {}",
|
||||
slug, e
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Admin Content Reverse Consistency Check (Legacy DB)
|
||||
// Check if tenant databases contain content for admin users incorrectly (isolated admin content)
|
||||
if slug_filter.is_none() {
|
||||
let mut stmt = users_conn.prepare(
|
||||
"SELECT id, username FROM users WHERE account_type = 'admin' AND id != 1;",
|
||||
)?;
|
||||
let mut admin_rows = stmt.query([])?;
|
||||
while let Some(row) = admin_rows.next()? {
|
||||
let id: i64 = row.get(0)?;
|
||||
let username: String = row.get(1)?;
|
||||
let tenant_db_path = data_dir
|
||||
.join("users")
|
||||
.join(id.to_string())
|
||||
.join("content.db");
|
||||
|
||||
if tenant_db_path.exists() {
|
||||
if let Ok(tenant_conn) = Connection::open(&tenant_db_path) {
|
||||
let url_count: i64 = tenant_conn
|
||||
.query_row("SELECT COUNT(*) FROM urls;", [], |r| r.get(0))
|
||||
.unwrap_or(0);
|
||||
let page_count: i64 = tenant_conn
|
||||
.query_row("SELECT COUNT(*) FROM landing_pages;", [], |r| r.get(0))
|
||||
.unwrap_or(0);
|
||||
|
||||
if url_count > 0 || page_count > 0 {
|
||||
issues.push(RegistryIssue {
|
||||
slug: "*".to_string(),
|
||||
target_type: "system".to_string(),
|
||||
owner_user_id: id,
|
||||
database_path: tenant_db_path.clone(),
|
||||
target_id: "".to_string(),
|
||||
issue_type: RegistryIssueType::TenantAdminHasIsolatedContent,
|
||||
description: format!("Admin user '{}' (ID {}) has content in isolated tenant DB ({} URLs, {} pages). Admin content should be in legacy DB 1.", username, id, url_count, page_count),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(issues)
|
||||
}
|
||||
}
|
||||
@@ -10,13 +10,24 @@ pub fn create_url(
|
||||
description: Option<&str>,
|
||||
tags: &[String],
|
||||
) -> Result<Url, AppError> {
|
||||
let conn = db.content.lock().unwrap();
|
||||
if !crate::utils::validation::validate_redirect_destination(destination) {
|
||||
return Err(AppError::BadRequest(
|
||||
"Destination must be a valid http(s) URL without control characters".into(),
|
||||
));
|
||||
}
|
||||
let conn = db
|
||||
.content
|
||||
.lock()
|
||||
.map_err(|e| AppError::Internal(format!("content_db mutex poisoned: {}", e)))?;
|
||||
let url = crate::db::content::create_url(&conn, code, destination, title, description, tags)?;
|
||||
Ok(url)
|
||||
}
|
||||
|
||||
pub fn get_url_by_code(db: &Db, code: &str) -> Result<Option<Url>, AppError> {
|
||||
let conn = db.content.lock().unwrap();
|
||||
let conn = db
|
||||
.content
|
||||
.lock()
|
||||
.map_err(|e| AppError::Internal(format!("content_db mutex poisoned: {}", e)))?;
|
||||
let url = crate::db::content::get_url_by_code(&conn, code)?;
|
||||
Ok(url)
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
//! Cross-tenant slug transfer business logic.
|
||||
//!
|
||||
//! Copies URL/page content between tenant content DBs, then updates global_slugs
|
||||
//! ownership. Handlers own admin auth and HTTP mapping.
|
||||
|
||||
use crate::state::{AppState, UserDbs};
|
||||
use crate::utils::lock_db;
|
||||
use chrono::Utc;
|
||||
use rusqlite::OptionalExtension;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum TransferError {
|
||||
NotFound(&'static str),
|
||||
BadRequest(String),
|
||||
Internal(String),
|
||||
}
|
||||
|
||||
impl TransferError {
|
||||
pub fn message(&self) -> String {
|
||||
match self {
|
||||
Self::NotFound(m) => (*m).to_string(),
|
||||
Self::BadRequest(m) | Self::Internal(m) => m.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SlugTransferRequest {
|
||||
pub slug: String,
|
||||
pub new_owner_user_id: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct SlugTransferResult {
|
||||
pub old_owner_user_id: i64,
|
||||
pub new_owner_user_id: i64,
|
||||
pub target_type: String,
|
||||
pub new_target_id: String,
|
||||
}
|
||||
|
||||
/// Look up slug ownership in `global_slugs`.
|
||||
pub fn lookup_slug(state: &AppState, slug: &str) -> Result<(i64, String, String), TransferError> {
|
||||
let system_conn = lock_db(&state.system_db, "system_db")
|
||||
.map_err(|e| TransferError::Internal(e.to_string()))?;
|
||||
let mut stmt = system_conn
|
||||
.prepare("SELECT owner_user_id, target_type, target_id FROM global_slugs WHERE slug = ?1;")
|
||||
.map_err(|e| TransferError::Internal(e.to_string()))?;
|
||||
let row_opt = stmt
|
||||
.query_row([slug], |row| {
|
||||
Ok((
|
||||
row.get::<_, i64>(0)?,
|
||||
row.get::<_, String>(1)?,
|
||||
row.get::<_, String>(2)?,
|
||||
))
|
||||
})
|
||||
.optional()
|
||||
.map_err(|e| TransferError::Internal(e.to_string()))?;
|
||||
|
||||
match row_opt {
|
||||
Some(r) => Ok(r),
|
||||
None => Err(TransferError::NotFound("Slug not found")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Copy content row between tenants and return the new target id.
|
||||
fn copy_content(
|
||||
state: &AppState,
|
||||
old_dbs: &UserDbs,
|
||||
new_dbs: &UserDbs,
|
||||
slug: &str,
|
||||
target_type: &str,
|
||||
new_owner_user_id: i64,
|
||||
) -> Result<String, TransferError> {
|
||||
let old_conn = lock_db(&old_dbs.content, "old_content_db")
|
||||
.map_err(|e| TransferError::Internal(e.to_string()))?;
|
||||
let new_conn = lock_db(&new_dbs.content, "new_content_db")
|
||||
.map_err(|e| TransferError::Internal(e.to_string()))?;
|
||||
|
||||
if target_type == "url" {
|
||||
let url = match crate::db::content::get_url_by_code(&old_conn, slug) {
|
||||
Ok(Some(u)) => u,
|
||||
Ok(None) => {
|
||||
return Err(TransferError::NotFound(
|
||||
"Content not found in owner database",
|
||||
))
|
||||
}
|
||||
Err(e) => return Err(TransferError::Internal(e.to_string())),
|
||||
};
|
||||
|
||||
{
|
||||
let new_users_conn = lock_db(&state.users_db, "users_db")
|
||||
.map_err(|e| TransferError::Internal(e.to_string()))?;
|
||||
if let Ok(Some(quota)) =
|
||||
crate::db::users::get_user_quotas(&new_users_conn, new_owner_user_id)
|
||||
{
|
||||
if quota.current_urls >= quota.max_urls {
|
||||
return Err(TransferError::BadRequest(
|
||||
"New owner has exceeded URL quota limit".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let new_url = crate::db::content::create_url_extended(
|
||||
&new_conn,
|
||||
&url.code,
|
||||
&url.destination,
|
||||
url.title.as_deref(),
|
||||
url.description.as_deref(),
|
||||
&url.tags,
|
||||
url.expires_at.as_deref(),
|
||||
url.password_hash.as_deref(),
|
||||
url.max_access_count,
|
||||
)
|
||||
.map_err(|e| TransferError::Internal(format!("Failed to copy URL to new owner: {e}")))?;
|
||||
let _ = crate::db::content::delete_url(&old_conn, &url.id);
|
||||
Ok(new_url.id)
|
||||
} else if target_type == "page" {
|
||||
let page = match crate::db::content::get_landing_page_by_code(&old_conn, slug) {
|
||||
Ok(Some(p)) => p,
|
||||
Ok(None) => {
|
||||
return Err(TransferError::NotFound(
|
||||
"Content not found in owner database",
|
||||
))
|
||||
}
|
||||
Err(e) => return Err(TransferError::Internal(e.to_string())),
|
||||
};
|
||||
|
||||
{
|
||||
let new_users_conn = lock_db(&state.users_db, "users_db")
|
||||
.map_err(|e| TransferError::Internal(e.to_string()))?;
|
||||
if let Ok(Some(quota)) =
|
||||
crate::db::users::get_user_quotas(&new_users_conn, new_owner_user_id)
|
||||
{
|
||||
if quota.current_landings >= quota.max_landings {
|
||||
return Err(TransferError::BadRequest(
|
||||
"New owner has exceeded landing page quota limit".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let new_page = crate::db::content::create_landing_page(
|
||||
&new_conn,
|
||||
&page.code,
|
||||
&page.slug,
|
||||
&page.title,
|
||||
&page.html_content,
|
||||
&page.state,
|
||||
)
|
||||
.map_err(|e| TransferError::Internal(format!("Failed to copy Page to new owner: {e}")))?;
|
||||
let _ = crate::db::content::delete_landing_page(&old_conn, &page.id);
|
||||
Ok(new_page.id)
|
||||
} else {
|
||||
Err(TransferError::NotFound(
|
||||
"Content not found in owner database",
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// Perform a full slug transfer (content + registry + quotas + history).
|
||||
pub fn transfer_slug(
|
||||
state: &AppState,
|
||||
req: &SlugTransferRequest,
|
||||
admin_username: &str,
|
||||
) -> Result<SlugTransferResult, TransferError> {
|
||||
let (old_owner_user_id, target_type, _target_id) = lookup_slug(state, &req.slug)?;
|
||||
|
||||
if old_owner_user_id == req.new_owner_user_id {
|
||||
return Err(TransferError::BadRequest(
|
||||
"New owner must be different from the current owner".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let old_dbs = state
|
||||
.get_user_dbs(old_owner_user_id)
|
||||
.map_err(|_| TransferError::Internal("Failed to load current owner's database".into()))?;
|
||||
let new_dbs = state
|
||||
.get_user_dbs(req.new_owner_user_id)
|
||||
.map_err(|_| TransferError::Internal("Failed to load new owner's database".into()))?;
|
||||
|
||||
let new_target_id = copy_content(
|
||||
state,
|
||||
&old_dbs,
|
||||
&new_dbs,
|
||||
&req.slug,
|
||||
&target_type,
|
||||
req.new_owner_user_id,
|
||||
)?;
|
||||
|
||||
{
|
||||
let system_conn = lock_db(&state.system_db, "system_db")
|
||||
.map_err(|e| TransferError::Internal(e.to_string()))?;
|
||||
let now = Utc::now().to_rfc3339();
|
||||
|
||||
let _ = system_conn.execute(
|
||||
"UPDATE global_slugs SET owner_user_id = ?1, target_id = ?2, updated_at = ?3 WHERE slug = ?4;",
|
||||
rusqlite::params![req.new_owner_user_id, new_target_id, now, req.slug],
|
||||
);
|
||||
|
||||
let _ = system_conn.execute(
|
||||
"INSERT INTO slug_history (slug, old_owner_user_id, new_owner_user_id, action, timestamp, admin_username)
|
||||
VALUES (?1, ?2, ?3, 'transferred', ?4, ?5);",
|
||||
rusqlite::params![
|
||||
req.slug,
|
||||
old_owner_user_id,
|
||||
req.new_owner_user_id,
|
||||
now,
|
||||
admin_username
|
||||
],
|
||||
);
|
||||
|
||||
let users_conn = lock_db(&state.users_db, "users_db")
|
||||
.map_err(|e| TransferError::Internal(e.to_string()))?;
|
||||
let field = if target_type == "url" {
|
||||
"urls"
|
||||
} else {
|
||||
"landings"
|
||||
};
|
||||
let _ = crate::db::users::decrement_quota_counter(&users_conn, old_owner_user_id, field);
|
||||
let _ =
|
||||
crate::db::users::increment_quota_counter(&users_conn, req.new_owner_user_id, field);
|
||||
|
||||
let _ = crate::db::audit_events::write_audit_event(
|
||||
&system_conn,
|
||||
admin_username,
|
||||
"SLUG_TRANSFER",
|
||||
"slug",
|
||||
&req.slug,
|
||||
Some(&format!(
|
||||
"From owner {} to owner {}",
|
||||
old_owner_user_id, req.new_owner_user_id
|
||||
)),
|
||||
);
|
||||
}
|
||||
|
||||
Ok(SlugTransferResult {
|
||||
old_owner_user_id,
|
||||
new_owner_user_id: req.new_owner_user_id,
|
||||
target_type,
|
||||
new_target_id,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
//! Shared URL write-path helpers used by admin UI, tenant UI, and REST API.
|
||||
//!
|
||||
//! Handlers remain responsible for auth/CSRF/quotas; this module owns pure
|
||||
//! destination preparation that must stay consistent across entry points.
|
||||
|
||||
use crate::utils::validation::validate_redirect_destination;
|
||||
|
||||
/// Optional UTM parameters applied to a destination URL.
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct UtmParams<'a> {
|
||||
pub source: Option<&'a str>,
|
||||
pub medium: Option<&'a str>,
|
||||
pub campaign: Option<&'a str>,
|
||||
}
|
||||
|
||||
/// Normalize and optionally append UTM parameters to a destination.
|
||||
///
|
||||
/// Returns `Err` when the base destination fails canonical validation.
|
||||
/// UTM appending only runs when the base parses as a URL (same as prior handlers).
|
||||
pub fn prepare_destination(raw: &str, utm: UtmParams<'_>) -> Result<String, &'static str> {
|
||||
let mut dest = raw.trim().to_string();
|
||||
if !validate_redirect_destination(&dest) {
|
||||
return Err("Destination must be a valid http(s) URL without control characters");
|
||||
}
|
||||
|
||||
if let Ok(mut parsed) = reqwest::Url::parse(&dest) {
|
||||
let mut has_utm = false;
|
||||
{
|
||||
let mut query = parsed.query_pairs_mut();
|
||||
if let Some(src) = utm.source {
|
||||
let src = src.trim();
|
||||
if !src.is_empty() {
|
||||
query.append_pair("utm_source", src);
|
||||
has_utm = true;
|
||||
}
|
||||
}
|
||||
if let Some(med) = utm.medium {
|
||||
let med = med.trim();
|
||||
if !med.is_empty() {
|
||||
query.append_pair("utm_medium", med);
|
||||
has_utm = true;
|
||||
}
|
||||
}
|
||||
if let Some(camp) = utm.campaign {
|
||||
let camp = camp.trim();
|
||||
if !camp.is_empty() {
|
||||
query.append_pair("utm_campaign", camp);
|
||||
has_utm = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if has_utm {
|
||||
dest = parsed.to_string();
|
||||
}
|
||||
}
|
||||
|
||||
Ok(dest)
|
||||
}
|
||||
|
||||
/// Parse HTML datetime-local / partial RFC3339 expiry input into RFC3339 if present.
|
||||
pub fn parse_expires_at_input(raw: &str) -> Option<String> {
|
||||
let trimmed = raw.trim();
|
||||
if trimmed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut rfc = trimmed.to_string();
|
||||
if rfc.len() == 16 {
|
||||
// HTML datetime-local → assume UTC seconds
|
||||
rfc.push_str(":00Z");
|
||||
}
|
||||
Some(rfc)
|
||||
}
|
||||
|
||||
/// Parse optional max-access-count form field.
|
||||
pub fn parse_max_access_count(raw: &str) -> Option<i64> {
|
||||
let trimmed = raw.trim();
|
||||
if trimmed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
trimmed.parse().ok()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn prepare_rejects_crlf() {
|
||||
let err = prepare_destination("https://x/\r\nY:1", UtmParams::default()).unwrap_err();
|
||||
assert!(err.contains("valid http"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prepare_appends_utm() {
|
||||
let dest = prepare_destination(
|
||||
"https://example.com/path",
|
||||
UtmParams {
|
||||
source: Some("newsletter"),
|
||||
medium: Some("email"),
|
||||
campaign: Some("spring"),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert!(dest.contains("utm_source=newsletter"));
|
||||
assert!(dest.contains("utm_medium=email"));
|
||||
assert!(dest.contains("utm_campaign=spring"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_expires_datetime_local() {
|
||||
assert_eq!(
|
||||
parse_expires_at_input("2030-01-01T12:00"),
|
||||
Some("2030-01-01T12:00:00Z".to_string())
|
||||
);
|
||||
assert_eq!(parse_expires_at_input(" "), None);
|
||||
}
|
||||
}
|
||||
+7
-7
@@ -29,7 +29,7 @@ pub struct AppState {
|
||||
|
||||
impl AppState {
|
||||
pub fn get_user_dbs(&self, user_id: i64) -> Result<UserDbs, crate::error::AppError> {
|
||||
let mut pool = self.user_dbs.lock().unwrap();
|
||||
let mut pool = crate::utils::lock_db(&self.user_dbs, "user_dbs")?;
|
||||
if let Some(dbs) = pool.get(&user_id) {
|
||||
return Ok(dbs.clone());
|
||||
}
|
||||
@@ -87,12 +87,12 @@ impl AppState {
|
||||
Ok(dbs)
|
||||
}
|
||||
|
||||
pub fn db_compact(&self) -> Result<(), rusqlite::Error> {
|
||||
self.admin_db.lock().unwrap().execute("VACUUM;", [])?;
|
||||
self.content_db.lock().unwrap().execute("VACUUM;", [])?;
|
||||
self.analytics_db.lock().unwrap().execute("VACUUM;", [])?;
|
||||
self.system_db.lock().unwrap().execute("VACUUM;", [])?;
|
||||
self.users_db.lock().unwrap().execute("VACUUM;", [])?;
|
||||
pub fn db_compact(&self) -> Result<(), crate::error::AppError> {
|
||||
crate::utils::lock_db(&self.admin_db, "admin_db")?.execute("VACUUM;", [])?;
|
||||
crate::utils::lock_db(&self.content_db, "content_db")?.execute("VACUUM;", [])?;
|
||||
crate::utils::lock_db(&self.analytics_db, "analytics_db")?.execute("VACUUM;", [])?;
|
||||
crate::utils::lock_db(&self.system_db, "system_db")?.execute("VACUUM;", [])?;
|
||||
crate::utils::lock_db(&self.users_db, "users_db")?.execute("VACUUM;", [])?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,7 @@ pub struct UrlAnalyticsTemplate {
|
||||
pub page_end: usize,
|
||||
pub date_from: Option<String>,
|
||||
pub date_to: Option<String>,
|
||||
pub is_admin: bool,
|
||||
}
|
||||
|
||||
impl UrlAnalyticsTemplate {
|
||||
@@ -84,6 +85,7 @@ pub struct PageAnalyticsTemplate {
|
||||
pub page_end: usize,
|
||||
pub date_from: Option<String>,
|
||||
pub date_to: Option<String>,
|
||||
pub is_admin: bool,
|
||||
}
|
||||
|
||||
impl PageAnalyticsTemplate {
|
||||
|
||||
+2
-46
@@ -276,6 +276,8 @@ pub struct HealthTemplate {
|
||||
pub tenants_db_size: String,
|
||||
pub job_history: Vec<crate::web::admin::JobHistoryRow>,
|
||||
pub health_checks: Vec<crate::web::admin::HealthCheckRow>,
|
||||
pub registry_errors: Vec<String>,
|
||||
pub registry_warnings: Vec<String>,
|
||||
pub csrf_token: String,
|
||||
pub success: Option<String>,
|
||||
pub error: Option<String>,
|
||||
@@ -372,49 +374,3 @@ impl IntoResponse for UserAnalyticsTemplate {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "user_url_analytics.html")]
|
||||
pub struct UserUrlAnalyticsTemplate {
|
||||
pub admin_username: String,
|
||||
pub username: String,
|
||||
pub url_code: String,
|
||||
pub destination: String,
|
||||
pub visits: Vec<VisitorLogEntry>,
|
||||
}
|
||||
|
||||
impl IntoResponse for UserUrlAnalyticsTemplate {
|
||||
fn into_response(self) -> Response {
|
||||
match self.render() {
|
||||
Ok(html) => Html(html).into_response(),
|
||||
Err(e) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Render error: {}", e),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "user_page_analytics.html")]
|
||||
pub struct UserPageAnalyticsTemplate {
|
||||
pub admin_username: String,
|
||||
pub username: String,
|
||||
pub page_code: String,
|
||||
pub title: String,
|
||||
pub visits: Vec<VisitorLogEntry>,
|
||||
}
|
||||
|
||||
impl IntoResponse for UserPageAnalyticsTemplate {
|
||||
fn into_response(self) -> Response {
|
||||
match self.render() {
|
||||
Ok(html) => Html(html).into_response(),
|
||||
Err(e) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Render error: {}", e),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
//! Poison-safe helpers for `std::sync::Mutex` around SQLite connections.
|
||||
//!
|
||||
//! Prefer these on request paths so a poisoned mutex returns a controlled error
|
||||
//! instead of panicking the worker thread.
|
||||
|
||||
use crate::error::AppError;
|
||||
use std::sync::{Mutex, MutexGuard};
|
||||
use tracing::error;
|
||||
|
||||
/// Acquire a database mutex, mapping poison to [`AppError::Internal`].
|
||||
///
|
||||
/// Logs the mutex name (not connection contents or secrets).
|
||||
pub fn lock_db<'a, T>(
|
||||
mutex: &'a Mutex<T>,
|
||||
name: &'static str,
|
||||
) -> Result<MutexGuard<'a, T>, AppError> {
|
||||
mutex.lock().map_err(|e| {
|
||||
error!(mutex = name, error = %e, "database mutex poisoned");
|
||||
AppError::Internal(format!("{name} mutex poisoned"))
|
||||
})
|
||||
}
|
||||
|
||||
/// Acquire a database mutex, mapping poison to a plain error string.
|
||||
///
|
||||
/// Useful for handlers that return `(StatusCode, String)` rather than `AppError`.
|
||||
pub fn lock_db_str<'a, T>(
|
||||
mutex: &'a Mutex<T>,
|
||||
name: &'static str,
|
||||
) -> Result<MutexGuard<'a, T>, String> {
|
||||
mutex.lock().map_err(|e| {
|
||||
error!(mutex = name, error = %e, "database mutex poisoned");
|
||||
format!("{name} mutex poisoned")
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::Mutex;
|
||||
|
||||
#[test]
|
||||
fn lock_db_succeeds_on_healthy_mutex() {
|
||||
let m = Mutex::new(42);
|
||||
let g = lock_db(&m, "test").unwrap();
|
||||
assert_eq!(*g, 42);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lock_db_maps_poison() {
|
||||
let m = Mutex::new(1);
|
||||
let _ = std::panic::catch_unwind(|| {
|
||||
let _g = m.lock().unwrap();
|
||||
panic!("poison");
|
||||
});
|
||||
let err = lock_db(&m, "poisoned_db").unwrap_err();
|
||||
match err {
|
||||
AppError::Internal(msg) => assert!(msg.contains("poisoned_db")),
|
||||
other => panic!("unexpected {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
+3
-1
@@ -1,3 +1,4 @@
|
||||
pub mod db_lock;
|
||||
pub mod hashing;
|
||||
pub mod network;
|
||||
pub mod random;
|
||||
@@ -5,8 +6,9 @@ pub mod system;
|
||||
pub mod time;
|
||||
pub mod validation;
|
||||
|
||||
pub use db_lock::{lock_db, lock_db_str};
|
||||
pub use hashing::sha256_hash;
|
||||
pub use network::get_client_ip;
|
||||
pub use network::{get_client_ip, resolve_cookie_secure};
|
||||
pub use random::generate_token;
|
||||
pub use system::{get_db_file_info, get_memory_usage};
|
||||
pub use time::format_duration;
|
||||
@@ -22,3 +22,119 @@ pub fn get_client_ip(headers: &HeaderMap, connect_info: Option<ConnectInfo<Socke
|
||||
}
|
||||
"127.0.0.1".to_string()
|
||||
}
|
||||
|
||||
// Helper to safely extract hostname from a Host header, handling IPv6 and ports.
|
||||
pub(crate) fn extract_hostname(host_header: &str) -> &str {
|
||||
if host_header.starts_with('[') {
|
||||
if let Some(end_idx) = host_header.find(']') {
|
||||
return &host_header[1..end_idx];
|
||||
}
|
||||
}
|
||||
host_header.split(':').next().unwrap_or(host_header)
|
||||
}
|
||||
|
||||
/// Determines whether to set the `Secure` flag on a cookie based on deployment context.
|
||||
///
|
||||
/// Policy:
|
||||
/// 1. If X-Forwarded-Proto is explicitly "https", always enforce Secure=true.
|
||||
/// (We assume X-Forwarded-Proto is from a trusted proxy. Forged "https" only makes
|
||||
/// the cookie safer. We never weaken based on X-Forwarded-Proto=http).
|
||||
/// 2. If the exact Host is a local loopback (localhost, 127.0.0.1, ::1) and we are not
|
||||
/// explicitly proxied via HTTPS, disable Secure. This prevents browsers from dropping
|
||||
/// the cookie during local development over cleartext HTTP.
|
||||
/// 3. Otherwise, fall back to the global `cookie_secure` config (which defaults to true
|
||||
/// to keep production secure-by-default even if the proxy strips X-Forwarded-Proto).
|
||||
pub fn resolve_cookie_secure(config_secure: bool, headers: &HeaderMap) -> bool {
|
||||
// 1. Explicit HTTPS via reverse proxy
|
||||
if let Some(proto) = headers
|
||||
.get("x-forwarded-proto")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
{
|
||||
if proto.eq_ignore_ascii_case("https") {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Exact loopback development (prevent cookie drop)
|
||||
if let Some(host_hdr) = headers.get("host").and_then(|h| h.to_str().ok()) {
|
||||
let hostname = extract_hostname(host_hdr);
|
||||
if matches!(hostname, "localhost" | "127.0.0.1" | "::1") {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Global config (normally true)
|
||||
config_secure
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use axum::http::HeaderValue;
|
||||
|
||||
#[test]
|
||||
fn test_extract_hostname() {
|
||||
assert_eq!(extract_hostname("localhost"), "localhost");
|
||||
assert_eq!(extract_hostname("localhost:8080"), "localhost");
|
||||
assert_eq!(extract_hostname("127.0.0.1"), "127.0.0.1");
|
||||
assert_eq!(extract_hostname("127.0.0.1:8080"), "127.0.0.1");
|
||||
assert_eq!(extract_hostname("[::1]"), "::1");
|
||||
assert_eq!(extract_hostname("[::1]:8080"), "::1");
|
||||
assert_eq!(extract_hostname("example.com"), "example.com");
|
||||
assert_eq!(extract_hostname("example.com:443"), "example.com");
|
||||
assert_eq!(
|
||||
extract_hostname("localhost.example.com"),
|
||||
"localhost.example.com"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_cookie_secure() {
|
||||
let mut headers = HeaderMap::new();
|
||||
|
||||
// No headers, global config is true -> Secure=true
|
||||
assert!(resolve_cookie_secure(true, &headers));
|
||||
// No headers, global config is false -> Secure=false
|
||||
assert!(!resolve_cookie_secure(false, &headers));
|
||||
|
||||
// Exact loopback matches -> Secure=false
|
||||
let loopback_hosts = [
|
||||
"localhost",
|
||||
"localhost:8080",
|
||||
"127.0.0.1",
|
||||
"127.0.0.1:8080",
|
||||
"[::1]",
|
||||
"[::1]:8080",
|
||||
];
|
||||
for host in loopback_hosts {
|
||||
headers.insert("host", HeaderValue::from_static(host));
|
||||
assert!(
|
||||
!resolve_cookie_secure(true, &headers),
|
||||
"Failed for host: {}",
|
||||
host
|
||||
);
|
||||
}
|
||||
|
||||
// Non-loopback localhost subdomains -> Secure=true (relying on config)
|
||||
let public_hosts = ["localhost.example.com", "127.0.0.2", "example.com"];
|
||||
for host in public_hosts {
|
||||
headers.insert("host", HeaderValue::from_static(host));
|
||||
assert!(
|
||||
resolve_cookie_secure(true, &headers),
|
||||
"Failed for host: {}",
|
||||
host
|
||||
);
|
||||
}
|
||||
|
||||
// X-Forwarded-Proto = https overrides loopback
|
||||
headers.insert("host", HeaderValue::from_static("localhost"));
|
||||
headers.insert("x-forwarded-proto", HeaderValue::from_static("https"));
|
||||
assert!(resolve_cookie_secure(true, &headers));
|
||||
assert!(resolve_cookie_secure(false, &headers)); // Overrides false config too
|
||||
|
||||
// X-Forwarded-Proto = http DOES NOT override global config
|
||||
headers.insert("host", HeaderValue::from_static("example.com"));
|
||||
headers.insert("x-forwarded-proto", HeaderValue::from_static("http"));
|
||||
assert!(resolve_cookie_secure(true, &headers)); // Still true because of config
|
||||
}
|
||||
}
|
||||
+5
-4
@@ -19,10 +19,11 @@ pub fn get_memory_usage() -> String {
|
||||
pub fn get_db_file_info(data_dir: &Path) -> String {
|
||||
let mut stats = String::new();
|
||||
let files = vec![
|
||||
("admin.db", "Admin DB"),
|
||||
("content.db", "Content DB"),
|
||||
("analytics.db", "Analytics DB"),
|
||||
("system.db", "System DB"),
|
||||
("admin/admin.db", "Admin DB"),
|
||||
("admin/system.db", "System DB"),
|
||||
("admin/users.db", "Users DB"),
|
||||
("users/1/content.db", "Legacy Content DB"),
|
||||
("users/1/analytics.db", "Legacy Analytics DB"),
|
||||
];
|
||||
|
||||
for (f, name) in files {
|
||||
|
||||
@@ -17,3 +17,144 @@ pub fn validate_redirect_code(code: &str) -> bool {
|
||||
pub fn validate_page_code(code: &str) -> bool {
|
||||
(code.len() == 4 && code.chars().all(|c| c.is_ascii_hexdigit())) || validate_custom_slug(code)
|
||||
}
|
||||
|
||||
/// Classification of a stored or proposed redirect destination.
|
||||
///
|
||||
/// Used by write-path validation and read-only legacy data audits. Order of checks
|
||||
/// matches `validate_redirect_destination` so both share the same rules.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum DestinationClass {
|
||||
ValidHttp,
|
||||
ValidHttps,
|
||||
Empty,
|
||||
TooLong,
|
||||
ControlCharacters,
|
||||
NonAscii,
|
||||
UnsupportedScheme,
|
||||
Malformed,
|
||||
}
|
||||
|
||||
impl DestinationClass {
|
||||
pub fn is_valid(self) -> bool {
|
||||
matches!(self, Self::ValidHttp | Self::ValidHttps)
|
||||
}
|
||||
}
|
||||
|
||||
fn scheme_prefix(dest: &str) -> Option<&str> {
|
||||
let end = dest.find(':')?;
|
||||
let scheme = &dest[..end];
|
||||
if scheme.is_empty() {
|
||||
return None;
|
||||
}
|
||||
if scheme
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || c == '+' || c == '.' || c == '-')
|
||||
{
|
||||
Some(scheme)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Classify a destination using the same rules as write-path validation.
|
||||
pub fn classify_redirect_destination(destination: &str) -> DestinationClass {
|
||||
let dest = destination.trim();
|
||||
if dest.is_empty() {
|
||||
return DestinationClass::Empty;
|
||||
}
|
||||
if dest.len() > 2048 {
|
||||
return DestinationClass::TooLong;
|
||||
}
|
||||
// HTTP header / response-splitting: no CR, LF, NUL, or other ASCII controls.
|
||||
if dest.bytes().any(|b| b < 0x20 || b == 0x7f) {
|
||||
return DestinationClass::ControlCharacters;
|
||||
}
|
||||
// HeaderValue also rejects non-visible ASCII in some cases; require pure ASCII.
|
||||
if !dest.is_ascii() {
|
||||
return DestinationClass::NonAscii;
|
||||
}
|
||||
// Reject non-http(s) schemes even when Url::parse fails (e.g. javascript:).
|
||||
if let Some(scheme) = scheme_prefix(dest) {
|
||||
let scheme_l = scheme.to_ascii_lowercase();
|
||||
if scheme_l != "http" && scheme_l != "https" {
|
||||
return DestinationClass::UnsupportedScheme;
|
||||
}
|
||||
}
|
||||
match reqwest::Url::parse(dest) {
|
||||
Ok(url) => {
|
||||
if url.host_str().is_none() {
|
||||
return DestinationClass::Malformed;
|
||||
}
|
||||
match url.scheme() {
|
||||
"http" => DestinationClass::ValidHttp,
|
||||
"https" => DestinationClass::ValidHttps,
|
||||
_ => DestinationClass::UnsupportedScheme,
|
||||
}
|
||||
}
|
||||
Err(_) => DestinationClass::Malformed,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true if `destination` is safe to store and emit as an HTTP Location value.
|
||||
///
|
||||
/// Rejects CR/LF and other ASCII control characters (response-splitting), empty values,
|
||||
/// and non-http(s) schemes. The redirect handler remains defensive even if invalid
|
||||
/// values already exist in older data.
|
||||
pub fn validate_redirect_destination(destination: &str) -> bool {
|
||||
classify_redirect_destination(destination).is_valid()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn rejects_crlf_destination() {
|
||||
assert!(!validate_redirect_destination(
|
||||
"https://example.com/\r\nX-Injected: 1"
|
||||
));
|
||||
assert!(!validate_redirect_destination(
|
||||
"https://example.com/\nX-Injected: 1"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_non_http_schemes() {
|
||||
assert!(!validate_redirect_destination("javascript:alert(1)"));
|
||||
assert!(!validate_redirect_destination("data:text/html,hi"));
|
||||
assert!(!validate_redirect_destination("/relative/path"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_normal_https() {
|
||||
assert!(validate_redirect_destination(
|
||||
"https://example.com/path?q=1#frag"
|
||||
));
|
||||
assert!(validate_redirect_destination("http://localhost:8080/x"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_destination_categories() {
|
||||
assert_eq!(
|
||||
classify_redirect_destination("https://ok.example/"),
|
||||
DestinationClass::ValidHttps
|
||||
);
|
||||
assert_eq!(
|
||||
classify_redirect_destination("http://ok.example/"),
|
||||
DestinationClass::ValidHttp
|
||||
);
|
||||
assert_eq!(
|
||||
classify_redirect_destination("javascript:alert(1)"),
|
||||
DestinationClass::UnsupportedScheme
|
||||
);
|
||||
assert_eq!(
|
||||
classify_redirect_destination("https://x/\r\nX:1"),
|
||||
DestinationClass::ControlCharacters
|
||||
);
|
||||
assert_eq!(
|
||||
classify_redirect_destination("not a url"),
|
||||
DestinationClass::Malformed
|
||||
);
|
||||
assert_eq!(classify_redirect_destination(""), DestinationClass::Empty);
|
||||
}
|
||||
}
|
||||
-6081
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,236 @@
|
||||
use super::*;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct CreateApiKeyForm {
|
||||
pub key_name: String,
|
||||
pub csrf_token: String,
|
||||
}
|
||||
|
||||
// POST /admin/settings/api-keys/create
|
||||
pub async fn create_api_key_post(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
headers: HeaderMap,
|
||||
connect_info: Option<ConnectInfo<SocketAddr>>,
|
||||
Form(form): Form<CreateApiKeyForm>,
|
||||
) -> Response {
|
||||
let (user, session_id) = match require_auth(&state, &jar).await {
|
||||
Ok(u) => u,
|
||||
Err(redir) => return redir.into_response(),
|
||||
};
|
||||
|
||||
if !verify_csrf(&session_id, &form.csrf_token) {
|
||||
return Redirect::to("/admin/settings?error=Invalid CSRF token").into_response();
|
||||
}
|
||||
|
||||
let ip = get_client_ip(&headers, connect_info);
|
||||
let key_secret = format!("bzo_{}", generate_token(16));
|
||||
|
||||
use sha2::{Digest, Sha256};
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(key_secret.as_bytes());
|
||||
let hashed_key = hex::encode(hasher.finalize());
|
||||
|
||||
let conn = state.admin_db.lock().unwrap();
|
||||
match create_api_key(&conn, &user.id, &form.key_name, &hashed_key) {
|
||||
Ok(api_key) => {
|
||||
let _ = write_audit_log(
|
||||
&conn,
|
||||
&state,
|
||||
&user.username,
|
||||
"API_KEY_CREATED",
|
||||
Some("api_key"),
|
||||
Some(&api_key.id),
|
||||
Some(&ip),
|
||||
headers.get("user-agent").and_then(|h| h.to_str().ok()),
|
||||
);
|
||||
Redirect::to(&format!(
|
||||
"/admin/settings?success=Token generated successfully. **IMPORTANT: Copy your token now, it will never be shown again!** Token value: {}",
|
||||
key_secret
|
||||
)).into_response()
|
||||
}
|
||||
Err(e) => {
|
||||
Redirect::to(&format!("/admin/settings?error=Database error: {}", e)).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// POST /admin/settings/api-keys/revoke/:id
|
||||
pub async fn revoke_api_key_post(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
headers: HeaderMap,
|
||||
connect_info: Option<ConnectInfo<SocketAddr>>,
|
||||
Path(id): Path<String>,
|
||||
Form(form): Form<std::collections::HashMap<String, String>>,
|
||||
) -> Response {
|
||||
let (user, session_id) = match require_auth(&state, &jar).await {
|
||||
Ok(u) => u,
|
||||
Err(redir) => return redir.into_response(),
|
||||
};
|
||||
|
||||
let csrf_token = form.get("csrf_token").cloned().unwrap_or_default();
|
||||
if !verify_csrf(&session_id, &csrf_token) {
|
||||
return Redirect::to("/admin/settings?error=Invalid CSRF token").into_response();
|
||||
}
|
||||
|
||||
let ip = get_client_ip(&headers, connect_info);
|
||||
|
||||
let conn = state.admin_db.lock().unwrap();
|
||||
match delete_api_key(&conn, &id) {
|
||||
Ok(_) => {
|
||||
let _ = write_audit_log(
|
||||
&conn,
|
||||
&state,
|
||||
&user.username,
|
||||
"API_KEY_REVOKED",
|
||||
Some("api_key"),
|
||||
Some(&id),
|
||||
Some(&ip),
|
||||
headers.get("user-agent").and_then(|h| h.to_str().ok()),
|
||||
);
|
||||
Redirect::to("/admin/settings?success=API Token revoked").into_response()
|
||||
}
|
||||
Err(e) => Redirect::to(&format!(
|
||||
"/admin/settings?error=Failed to revoke key: {}",
|
||||
e
|
||||
))
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api-tokens
|
||||
pub async fn api_tokens_get(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
Query(params): Query<HashMap<String, String>>,
|
||||
) -> Response {
|
||||
let (user, session_id) = match require_user_auth(&state, &jar).await {
|
||||
Ok(u) => u,
|
||||
Err(redir) => return redir.into_response(),
|
||||
};
|
||||
|
||||
let tokens = {
|
||||
let conn = state.users_db.lock().unwrap();
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT id, user_id, token_hash, created_at FROM api_tokens WHERE user_id = ?1;",
|
||||
)
|
||||
.unwrap();
|
||||
let rows = stmt
|
||||
.query_map([user.id], |row| {
|
||||
Ok(crate::models::UserApiToken {
|
||||
id: row.get(0)?,
|
||||
user_id: row.get(1)?,
|
||||
token_hash: row.get(2)?,
|
||||
created_at: row.get(3)?,
|
||||
})
|
||||
})
|
||||
.unwrap();
|
||||
rows.filter_map(|r| r.ok()).collect()
|
||||
};
|
||||
|
||||
let csrf_token = generate_csrf_token(&session_id);
|
||||
|
||||
let template = crate::templates::ApiTokensTemplate {
|
||||
admin_username: user.username.clone(),
|
||||
username: user.username,
|
||||
tokens,
|
||||
new_token: params.get("new_token").cloned(),
|
||||
csrf_token,
|
||||
success: params.get("success").cloned(),
|
||||
error: params.get("error").cloned(),
|
||||
};
|
||||
|
||||
template.into_response()
|
||||
}
|
||||
|
||||
// POST /api-tokens/create
|
||||
pub async fn api_tokens_create_post(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
Form(form): Form<HashMap<String, String>>,
|
||||
) -> Response {
|
||||
let (user, session_id) = match require_user_auth(&state, &jar).await {
|
||||
Ok(u) => u,
|
||||
Err(redir) => return redir.into_response(),
|
||||
};
|
||||
|
||||
let form_csrf = form.get("csrf_token").cloned().unwrap_or_default();
|
||||
if !verify_csrf(&session_id, &form_csrf) {
|
||||
return Redirect::to("/api-tokens?error=Invalid CSRF token").into_response();
|
||||
}
|
||||
|
||||
use sha2::Digest;
|
||||
let raw_token = format!("key_{}", generate_token(32));
|
||||
let mut hasher = sha2::Sha256::new();
|
||||
hasher.update(raw_token.as_bytes());
|
||||
let hashed_token = hex::encode(hasher.finalize());
|
||||
|
||||
{
|
||||
let conn = state.users_db.lock().unwrap();
|
||||
let now = Utc::now().to_rfc3339();
|
||||
let _ = conn.execute(
|
||||
"INSERT INTO api_tokens (user_id, token_hash, created_at) VALUES (?1, ?2, ?3);",
|
||||
rusqlite::params![user.id, hashed_token, now],
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
let conn_sys = state.system_db.lock().unwrap();
|
||||
let _ = crate::db::audit_events::write_audit_event(
|
||||
&conn_sys,
|
||||
&user.username,
|
||||
"API_TOKEN_CREATED",
|
||||
"api_token",
|
||||
"new",
|
||||
None,
|
||||
);
|
||||
}
|
||||
|
||||
Redirect::to(&format!(
|
||||
"/api-tokens?new_token={}&success=Token generated successfully",
|
||||
raw_token
|
||||
))
|
||||
.into_response()
|
||||
}
|
||||
|
||||
// POST /api-tokens/revoke/:id
|
||||
pub async fn api_tokens_revoke_post(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
Path(token_id): Path<i64>,
|
||||
Form(form): Form<HashMap<String, String>>,
|
||||
) -> Response {
|
||||
let (user, session_id) = match require_user_auth(&state, &jar).await {
|
||||
Ok(u) => u,
|
||||
Err(redir) => return redir.into_response(),
|
||||
};
|
||||
|
||||
let form_csrf = form.get("csrf_token").cloned().unwrap_or_default();
|
||||
if !verify_csrf(&session_id, &form_csrf) {
|
||||
return Redirect::to("/api-tokens?error=Invalid CSRF token").into_response();
|
||||
}
|
||||
|
||||
{
|
||||
let conn = state.users_db.lock().unwrap();
|
||||
let _ = conn.execute(
|
||||
"DELETE FROM api_tokens WHERE id = ?1 AND user_id = ?2;",
|
||||
[token_id, user.id],
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
let conn_sys = state.system_db.lock().unwrap();
|
||||
let _ = crate::db::audit_events::write_audit_event(
|
||||
&conn_sys,
|
||||
&user.username,
|
||||
"API_TOKEN_REVOKED",
|
||||
"api_token",
|
||||
&token_id.to_string(),
|
||||
None,
|
||||
);
|
||||
}
|
||||
|
||||
Redirect::to("/api-tokens?success=API token revoked").into_response()
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
use super::*;
|
||||
|
||||
// GET /admin/audit
|
||||
pub async fn audit_get(State(state): State<AppState>, jar: CookieJar) -> Response {
|
||||
let (user, _) = match require_auth(&state, &jar).await {
|
||||
Ok(u) => u,
|
||||
Err(redir) => return redir.into_response(),
|
||||
};
|
||||
|
||||
let logs = {
|
||||
let conn = state.system_db.lock().unwrap();
|
||||
let events = crate::db::audit_events::list_audit_events(&conn, 100, 0, None, None)
|
||||
.unwrap_or_default();
|
||||
events
|
||||
.into_iter()
|
||||
.map(|e| {
|
||||
let (ip, ua) = if let Some(ref m) = e.metadata {
|
||||
if m.starts_with("IP: ") {
|
||||
let parts: Vec<&str> = m.split(", UA: ").collect();
|
||||
let ip = parts[0]
|
||||
.trim_start_matches("IP: ")
|
||||
.trim_matches('"')
|
||||
.trim_matches('\'')
|
||||
.replace("Some(", "")
|
||||
.replace(")", "");
|
||||
let ua = if parts.len() > 1 {
|
||||
parts[1]
|
||||
.trim_matches('"')
|
||||
.trim_matches('\'')
|
||||
.replace("Some(", "")
|
||||
.replace(")", "")
|
||||
} else {
|
||||
"Unknown".to_string()
|
||||
};
|
||||
(Some(ip), Some(ua))
|
||||
} else {
|
||||
(None, None)
|
||||
}
|
||||
} else {
|
||||
(None, None)
|
||||
};
|
||||
|
||||
crate::models::AuditLog {
|
||||
id: e.id,
|
||||
timestamp: e.timestamp,
|
||||
username: e.actor,
|
||||
action: e.action,
|
||||
object_type: Some(e.object_type),
|
||||
object_id: Some(e.object_id),
|
||||
ip_address: ip,
|
||||
user_agent: ua,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
|
||||
let template = crate::templates::AuditTemplate {
|
||||
admin_username: user.username,
|
||||
logs,
|
||||
};
|
||||
|
||||
template.into_response()
|
||||
}
|
||||
|
||||
// GET /user/audit
|
||||
pub async fn user_audit_get(State(state): State<AppState>, jar: CookieJar) -> Response {
|
||||
let (user, _) = match require_user_auth(&state, &jar).await {
|
||||
Ok(u) => u,
|
||||
Err(redir) => return redir.into_response(),
|
||||
};
|
||||
|
||||
let logs = {
|
||||
let conn = state.system_db.lock().unwrap();
|
||||
let events =
|
||||
crate::db::audit_events::list_audit_events(&conn, 100, 0, Some(&user.username), None)
|
||||
.unwrap_or_default();
|
||||
events
|
||||
.into_iter()
|
||||
.map(|e| {
|
||||
let (ip, ua) = if let Some(ref m) = e.metadata {
|
||||
if m.starts_with("IP: ") {
|
||||
let parts: Vec<&str> = m.split(", UA: ").collect();
|
||||
let ip = parts[0]
|
||||
.trim_start_matches("IP: ")
|
||||
.trim_matches('"')
|
||||
.trim_matches('\'')
|
||||
.replace("Some(", "")
|
||||
.replace(")", "");
|
||||
let ua = if parts.len() > 1 {
|
||||
parts[1]
|
||||
.trim_matches('"')
|
||||
.trim_matches('\'')
|
||||
.replace("Some(", "")
|
||||
.replace(")", "")
|
||||
} else {
|
||||
"Unknown".to_string()
|
||||
};
|
||||
(Some(ip), Some(ua))
|
||||
} else {
|
||||
(None, None)
|
||||
}
|
||||
} else {
|
||||
(None, None)
|
||||
};
|
||||
|
||||
crate::models::AuditLog {
|
||||
id: e.id,
|
||||
timestamp: e.timestamp,
|
||||
username: e.actor,
|
||||
action: e.action,
|
||||
object_type: Some(e.object_type),
|
||||
object_id: Some(e.object_id),
|
||||
ip_address: ip,
|
||||
user_agent: ua,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
|
||||
let template = crate::templates::UserAuditTemplate {
|
||||
admin_username: user.username,
|
||||
logs,
|
||||
};
|
||||
|
||||
template.into_response()
|
||||
}
|
||||
@@ -0,0 +1,517 @@
|
||||
use super::*;
|
||||
|
||||
// GET /admin
|
||||
pub async fn admin_index(State(state): State<AppState>, jar: CookieJar) -> Response {
|
||||
match require_auth(&state, &jar).await {
|
||||
Ok(_) => Redirect::to("/admin/dashboard").into_response(),
|
||||
Err(redir) => redir.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
// GET /admin/login
|
||||
pub async fn login_get(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
headers: axum::http::HeaderMap,
|
||||
Query(params): Query<HashMap<String, String>>,
|
||||
) -> Response {
|
||||
let error = params.get("error").cloned();
|
||||
let csrf_token = generate_token(16);
|
||||
|
||||
let secure_flag = crate::utils::resolve_cookie_secure(state.config.cookie_secure, &headers);
|
||||
let cookie = Cookie::build(("bzod_temp_csrf", csrf_token.clone()))
|
||||
.path("/admin/login")
|
||||
.secure(secure_flag)
|
||||
.http_only(true)
|
||||
.same_site(axum_extra::extract::cookie::SameSite::Strict)
|
||||
.max_age(time::Duration::minutes(10))
|
||||
.build();
|
||||
|
||||
let new_jar = jar.add(cookie);
|
||||
let template = crate::templates::LoginTemplate {
|
||||
error,
|
||||
csrf_token,
|
||||
action: "/admin/login".to_string(),
|
||||
title: "Admin Login".to_string(),
|
||||
subtitle: "Administrative Access".to_string(),
|
||||
button_text: "Sign In".to_string(),
|
||||
};
|
||||
(new_jar, template).into_response()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct LoginForm {
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
pub csrf_token: String,
|
||||
}
|
||||
|
||||
/// Bootstrap is allowed only when the system has no real admin yet.
|
||||
pub(crate) fn is_bootstrap_allowed(
|
||||
user_count: i64,
|
||||
admin_count: i64,
|
||||
active_session_count: i64,
|
||||
) -> bool {
|
||||
user_count <= 1 && admin_count == 0 && active_session_count == 0
|
||||
}
|
||||
|
||||
fn count_login_bootstrap_state(
|
||||
conn: &rusqlite::Connection,
|
||||
) -> Result<(i64, i64, i64), rusqlite::Error> {
|
||||
let u_count: i64 = conn.query_row("SELECT COUNT(*) FROM users;", [], |r| r.get(0))?;
|
||||
let a_count: i64 = conn.query_row(
|
||||
"SELECT COUNT(*) FROM users WHERE account_type = 'admin';",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)?;
|
||||
let now = Utc::now().to_rfc3339();
|
||||
let s_count: i64 = conn.query_row(
|
||||
"SELECT COUNT(*) FROM sessions WHERE expires_at > ?1;",
|
||||
[now],
|
||||
|r| r.get(0),
|
||||
)?;
|
||||
Ok((u_count, a_count, s_count))
|
||||
}
|
||||
|
||||
/// Verify an existing admin tenant user may log into the admin UI.
|
||||
///
|
||||
/// Does not log the password. Rejection reasons are structured for observability.
|
||||
pub(crate) fn verify_admin_credentials(
|
||||
user: &crate::models::TenantUser,
|
||||
password: &str,
|
||||
) -> Result<(), &'static str> {
|
||||
if user.status != "active" {
|
||||
return Err("account_disabled");
|
||||
}
|
||||
if user.account_type != "admin" {
|
||||
return Err("insufficient_privileges");
|
||||
}
|
||||
if !verify_password(password, &user.password_hash) {
|
||||
return Err("invalid_credentials");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn load_tenant_user_by_username(
|
||||
conn: &rusqlite::Connection,
|
||||
username: &str,
|
||||
) -> Result<Option<crate::models::TenantUser>, rusqlite::Error> {
|
||||
conn.query_row(
|
||||
"SELECT id, username, password_hash, status, created_at, last_login, account_type, organization_id, metadata
|
||||
FROM users WHERE username = ?1;",
|
||||
[username],
|
||||
|row| {
|
||||
Ok(crate::models::TenantUser {
|
||||
id: row.get(0)?,
|
||||
username: row.get(1)?,
|
||||
password_hash: row.get(2)?,
|
||||
status: row.get(3)?,
|
||||
created_at: row.get(4)?,
|
||||
last_login: row.get(5)?,
|
||||
account_type: row.get(6)?,
|
||||
organization_id: row.get(7)?,
|
||||
metadata: row.get(8)?,
|
||||
})
|
||||
},
|
||||
)
|
||||
.optional()
|
||||
}
|
||||
|
||||
fn tenant_user_to_admin_user(u: crate::models::TenantUser) -> User {
|
||||
User {
|
||||
id: u.id.to_string(),
|
||||
username: u.username,
|
||||
password_hash: u.password_hash,
|
||||
created_at: u.created_at,
|
||||
}
|
||||
}
|
||||
|
||||
fn audit_meta(ip: &str, headers: &HeaderMap) -> String {
|
||||
format!(
|
||||
"IP: {:?}, UA: {:?}",
|
||||
ip,
|
||||
headers.get("user-agent").and_then(|h| h.to_str().ok())
|
||||
)
|
||||
}
|
||||
|
||||
// POST /admin/login
|
||||
pub async fn login_post(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
headers: HeaderMap,
|
||||
connect_info: Option<ConnectInfo<SocketAddr>>,
|
||||
Form(form): Form<LoginForm>,
|
||||
) -> Response {
|
||||
let temp_csrf = jar
|
||||
.get("bzod_temp_csrf")
|
||||
.map(|c| c.value().to_string())
|
||||
.unwrap_or_default();
|
||||
if temp_csrf.is_empty() || temp_csrf != form.csrf_token {
|
||||
return Redirect::to("/admin/login?error=Invalid CSRF token").into_response();
|
||||
}
|
||||
|
||||
let ip = get_client_ip(&headers, connect_info);
|
||||
|
||||
let bootstrap_allowed = {
|
||||
let conn = match state.users_db.lock() {
|
||||
Ok(c) => c,
|
||||
Err(_) => {
|
||||
return Redirect::to("/admin/login?error=Internal error").into_response();
|
||||
}
|
||||
};
|
||||
match count_login_bootstrap_state(&conn) {
|
||||
Ok((u, a, s)) => is_bootstrap_allowed(u, a, s),
|
||||
Err(e) => {
|
||||
tracing::error!(error = %e, "login bootstrap state query failed");
|
||||
false
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let user_opt = if bootstrap_allowed
|
||||
&& form.username == state.config.admin_username
|
||||
&& verify_sha256(&form.password, &state.config.bootstrap_password_sha256)
|
||||
{
|
||||
// Bootstrap Phase using BOOTSTRAP_PASSWORD_SHA256
|
||||
let hash = match hash_password(&form.password) {
|
||||
Ok(h) => h,
|
||||
Err(_) => {
|
||||
return Redirect::to("/admin/login?error=Internal hashing error").into_response()
|
||||
}
|
||||
};
|
||||
|
||||
let conn = match state.users_db.lock() {
|
||||
Ok(c) => c,
|
||||
Err(_) => {
|
||||
return Redirect::to("/admin/login?error=Internal error").into_response();
|
||||
}
|
||||
};
|
||||
match crate::db::users::create_admin_user(&conn, &form.username, &hash) {
|
||||
Ok(u) => {
|
||||
if let Err(e) = state.db.init_user_databases(u.id) {
|
||||
tracing::error!(
|
||||
"Failed to init user databases during admin bootstrap: {:?}",
|
||||
e
|
||||
);
|
||||
}
|
||||
|
||||
if let Ok(system_conn) = state.system_db.lock() {
|
||||
let metadata = audit_meta(&ip, &headers);
|
||||
let _ = crate::db::audit_events::write_audit_event(
|
||||
&system_conn,
|
||||
&u.username,
|
||||
"BOOTSTRAP_USER_PROVISIONED",
|
||||
"user",
|
||||
&u.id.to_string(),
|
||||
Some(&metadata),
|
||||
);
|
||||
}
|
||||
|
||||
Some(User {
|
||||
id: u.id.to_string(),
|
||||
username: u.username,
|
||||
password_hash: u.password_hash,
|
||||
created_at: u.created_at,
|
||||
})
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to create admin user during bootstrap: {:?}", e);
|
||||
None
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let conn = match state.users_db.lock() {
|
||||
Ok(c) => c,
|
||||
Err(_) => {
|
||||
return Redirect::to("/admin/login?error=Internal error").into_response();
|
||||
}
|
||||
};
|
||||
match load_tenant_user_by_username(&conn, &form.username) {
|
||||
Ok(Some(u)) => match verify_admin_credentials(&u, &form.password) {
|
||||
Ok(()) => Some(tenant_user_to_admin_user(u)),
|
||||
Err(reason) => {
|
||||
tracing::warn!(username = form.username, reason, "login rejected");
|
||||
None
|
||||
}
|
||||
},
|
||||
Ok(None) => {
|
||||
tracing::warn!(
|
||||
username = form.username,
|
||||
reason = "user_not_found",
|
||||
"login rejected"
|
||||
);
|
||||
None
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(error = %e, "login user lookup failed");
|
||||
None
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
match user_opt {
|
||||
Some(user) => {
|
||||
let session_token = generate_token(32);
|
||||
let expires = (Utc::now() + chrono::Duration::days(30)).to_rfc3339();
|
||||
|
||||
{
|
||||
let conn = match state.users_db.lock() {
|
||||
Ok(c) => c,
|
||||
Err(_) => {
|
||||
return Redirect::to("/admin/login?error=Internal error").into_response();
|
||||
}
|
||||
};
|
||||
let user_id_i64 = user.id.parse::<i64>().unwrap_or(0);
|
||||
let now = Utc::now().to_rfc3339();
|
||||
if let Err(e) = conn.execute(
|
||||
"INSERT INTO sessions (id, user_id, expires_at, created_at) VALUES (?1, ?2, ?3, ?4);",
|
||||
rusqlite::params![session_token, user_id_i64, expires, now],
|
||||
) {
|
||||
tracing::error!(error = %e, "failed to insert admin session");
|
||||
return Redirect::to("/admin/login?error=Internal error").into_response();
|
||||
}
|
||||
|
||||
if let Ok(system_conn) = state.system_db.lock() {
|
||||
let metadata = audit_meta(&ip, &headers);
|
||||
let _ = crate::db::audit_events::write_audit_event(
|
||||
&system_conn,
|
||||
&user.username,
|
||||
"USER_LOGIN",
|
||||
"session",
|
||||
&session_token,
|
||||
Some(&metadata),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let secure_flag =
|
||||
crate::utils::resolve_cookie_secure(state.config.cookie_secure, &headers);
|
||||
let cookie = Cookie::build(("bzod_session", session_token))
|
||||
.path("/")
|
||||
.secure(secure_flag)
|
||||
.http_only(true)
|
||||
.same_site(axum_extra::extract::cookie::SameSite::Strict)
|
||||
.max_age(time::Duration::days(30))
|
||||
.build();
|
||||
|
||||
let clear_temp = Cookie::build("bzod_temp_csrf")
|
||||
.path("/admin/login")
|
||||
.max_age(time::Duration::ZERO)
|
||||
.build();
|
||||
|
||||
let mut response_jar = jar.clone();
|
||||
response_jar = response_jar.add(cookie).add(clear_temp);
|
||||
|
||||
(response_jar, Redirect::to("/admin/dashboard")).into_response()
|
||||
}
|
||||
None => {
|
||||
if let Ok(system_conn) = state.system_db.lock() {
|
||||
let metadata = audit_meta(&ip, &headers);
|
||||
let _ = crate::db::audit_events::write_audit_event(
|
||||
&system_conn,
|
||||
"anonymous",
|
||||
"LOGIN_FAILED",
|
||||
"login",
|
||||
"",
|
||||
Some(&metadata),
|
||||
);
|
||||
}
|
||||
Redirect::to("/admin/login?error=Invalid username or password").into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// GET /logout
|
||||
pub async fn public_logout(State(_state): State<AppState>, jar: CookieJar) -> Response {
|
||||
let cookie = Cookie::build("bzod_user_session")
|
||||
.path("/")
|
||||
.max_age(time::Duration::ZERO)
|
||||
.build();
|
||||
|
||||
let mut response_jar = jar.clone();
|
||||
response_jar = response_jar.add(cookie);
|
||||
|
||||
(response_jar, Redirect::to("/login")).into_response()
|
||||
}
|
||||
|
||||
// GET /login
|
||||
pub async fn public_login_get(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
headers: axum::http::HeaderMap,
|
||||
Query(params): Query<HashMap<String, String>>,
|
||||
) -> Response {
|
||||
let error = params.get("error").cloned();
|
||||
let csrf_token = generate_token(16);
|
||||
|
||||
let secure_flag = crate::utils::resolve_cookie_secure(state.config.cookie_secure, &headers);
|
||||
let cookie = Cookie::build(("bzod_temp_csrf", csrf_token.clone()))
|
||||
.path("/login")
|
||||
.secure(secure_flag)
|
||||
.http_only(true)
|
||||
.same_site(axum_extra::extract::cookie::SameSite::Strict)
|
||||
.max_age(time::Duration::minutes(10))
|
||||
.build();
|
||||
|
||||
let new_jar = jar.add(cookie);
|
||||
let template = crate::templates::LoginTemplate {
|
||||
error,
|
||||
csrf_token,
|
||||
action: "/login".to_string(),
|
||||
title: "User Login".to_string(),
|
||||
subtitle: "Standard account access".to_string(),
|
||||
button_text: "Sign In".to_string(),
|
||||
};
|
||||
(new_jar, template).into_response()
|
||||
}
|
||||
|
||||
// POST /login
|
||||
pub async fn public_login_post(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
headers: HeaderMap,
|
||||
connect_info: Option<ConnectInfo<SocketAddr>>,
|
||||
Form(form): Form<LoginForm>,
|
||||
) -> Response {
|
||||
let temp_csrf = jar
|
||||
.get("bzod_temp_csrf")
|
||||
.map(|c| c.value().to_string())
|
||||
.unwrap_or_default();
|
||||
if temp_csrf.is_empty() || temp_csrf != form.csrf_token {
|
||||
return Redirect::to("/login?error=Invalid CSRF token").into_response();
|
||||
}
|
||||
|
||||
let ip = get_client_ip(&headers, connect_info);
|
||||
|
||||
let user_opt: Option<crate::models::TenantUser> = {
|
||||
let conn = state.users_db.lock().unwrap();
|
||||
let user_res: Result<Option<crate::models::TenantUser>, rusqlite::Error> = conn.query_row(
|
||||
"SELECT id, username, password_hash, status, created_at, last_login, account_type, organization_id, metadata \
|
||||
FROM users WHERE username = ?1;",
|
||||
[&form.username],
|
||||
|row| {
|
||||
Ok(crate::models::TenantUser {
|
||||
id: row.get(0)?,
|
||||
username: row.get(1)?,
|
||||
password_hash: row.get(2)?,
|
||||
status: row.get(3)?,
|
||||
created_at: row.get(4)?,
|
||||
last_login: row.get(5)?,
|
||||
account_type: row.get(6)?,
|
||||
organization_id: row.get(7)?,
|
||||
metadata: row.get(8)?,
|
||||
})
|
||||
}
|
||||
).optional();
|
||||
|
||||
match user_res {
|
||||
Ok(Some(u)) => {
|
||||
if u.status != "active" {
|
||||
None
|
||||
} else if verify_password(&form.password, &u.password_hash) {
|
||||
Some(u)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
};
|
||||
|
||||
match user_opt {
|
||||
Some(user) => {
|
||||
let session_token = generate_token(32);
|
||||
let expires = (Utc::now() + chrono::Duration::days(30)).to_rfc3339();
|
||||
|
||||
{
|
||||
let conn = state.users_db.lock().unwrap();
|
||||
let _ =
|
||||
crate::db::users::create_user_session(&conn, &session_token, user.id, &expires);
|
||||
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let metadata = format!(
|
||||
"IP: {:?}, UA: {:?}",
|
||||
ip,
|
||||
headers.get("user-agent").and_then(|h| h.to_str().ok())
|
||||
);
|
||||
let _ = crate::db::audit_events::write_audit_event(
|
||||
&system_conn,
|
||||
&user.username,
|
||||
"USER_LOGIN",
|
||||
"session",
|
||||
&session_token,
|
||||
Some(&metadata),
|
||||
);
|
||||
}
|
||||
|
||||
let secure_flag =
|
||||
crate::utils::resolve_cookie_secure(state.config.cookie_secure, &headers);
|
||||
let cookie = Cookie::build(("bzod_user_session", session_token))
|
||||
.path("/")
|
||||
.secure(secure_flag)
|
||||
.http_only(true)
|
||||
.same_site(axum_extra::extract::cookie::SameSite::Strict)
|
||||
.max_age(time::Duration::days(30))
|
||||
.build();
|
||||
|
||||
let clear_temp = Cookie::build("bzod_temp_csrf")
|
||||
.path("/login")
|
||||
.max_age(time::Duration::ZERO)
|
||||
.build();
|
||||
|
||||
let mut response_jar = jar.clone();
|
||||
response_jar = response_jar.add(cookie).add(clear_temp);
|
||||
|
||||
(response_jar, Redirect::to("/user/dashboard")).into_response()
|
||||
}
|
||||
None => {
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let metadata = format!(
|
||||
"IP: {:?}, UA: {:?}",
|
||||
ip,
|
||||
headers.get("user-agent").and_then(|h| h.to_str().ok())
|
||||
);
|
||||
let _ = crate::db::audit_events::write_audit_event(
|
||||
&system_conn,
|
||||
"anonymous",
|
||||
"LOGIN_FAILED",
|
||||
"login",
|
||||
"",
|
||||
Some(&metadata),
|
||||
);
|
||||
Redirect::to("/login?error=Invalid username or password").into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// GET /admin/logout
|
||||
pub async fn logout(State(state): State<AppState>, jar: CookieJar) -> Response {
|
||||
if let Ok((_, session_id)) = require_auth(&state, &jar).await {
|
||||
let conn = state.users_db.lock().unwrap();
|
||||
let _ = conn.execute("DELETE FROM sessions WHERE id = ?1;", [&session_id]);
|
||||
}
|
||||
|
||||
let cookie = Cookie::build("bzod_session")
|
||||
.path("/")
|
||||
.max_age(time::Duration::ZERO)
|
||||
.build();
|
||||
|
||||
let mut response_jar = jar.clone();
|
||||
response_jar = response_jar.add(cookie);
|
||||
|
||||
(response_jar, Redirect::to("/admin/login")).into_response()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod login_helpers_tests {
|
||||
use super::is_bootstrap_allowed;
|
||||
|
||||
#[test]
|
||||
fn bootstrap_only_when_no_admin() {
|
||||
assert!(is_bootstrap_allowed(0, 0, 0));
|
||||
assert!(is_bootstrap_allowed(1, 0, 0));
|
||||
assert!(!is_bootstrap_allowed(2, 0, 0));
|
||||
assert!(!is_bootstrap_allowed(1, 1, 0));
|
||||
assert!(!is_bootstrap_allowed(1, 0, 1));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
use super::*;
|
||||
|
||||
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
|
||||
pub struct BackupFileRow {
|
||||
pub filename: String,
|
||||
pub size_str: String,
|
||||
pub created_str: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
|
||||
pub struct BackupHistoryRow {
|
||||
pub id: String,
|
||||
pub backup_path: String,
|
||||
pub status: String,
|
||||
pub created_at: String,
|
||||
pub size_bytes: i64,
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
// GET /admin/backups
|
||||
pub async fn backups_get(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
Query(params): Query<HashMap<String, String>>,
|
||||
) -> Response {
|
||||
let (user, session_id) = match require_auth(&state, &jar).await {
|
||||
Ok(u) => u,
|
||||
Err(redir) => return redir.into_response(),
|
||||
};
|
||||
|
||||
let mut files = vec![];
|
||||
if let Ok(dir_entries) = std::fs::read_dir(&state.config.backup_dir) {
|
||||
for entry in dir_entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_file() {
|
||||
if let Some(filename) = path
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.map(|s| s.to_string())
|
||||
{
|
||||
if filename.ends_with(".tar.gz") {
|
||||
let meta = entry.metadata().unwrap();
|
||||
let size_str = format_size(meta.len());
|
||||
let created_str = meta
|
||||
.created()
|
||||
.ok()
|
||||
.map(|c| {
|
||||
let datetime: chrono::DateTime<chrono::Utc> = c.into();
|
||||
datetime.format("%Y-%m-%d %H:%M:%S").to_string()
|
||||
})
|
||||
.unwrap_or_else(|| "-".to_string());
|
||||
files.push(BackupFileRow {
|
||||
filename,
|
||||
size_str,
|
||||
created_str,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
files.sort_by(|a, b| b.filename.cmp(&a.filename));
|
||||
|
||||
let history = {
|
||||
let conn = state.system_db.lock().unwrap();
|
||||
let mut stmt = conn.prepare("SELECT id, backup_path, status, created_at, size_bytes, error_message FROM backup_history ORDER BY created_at DESC LIMIT 30;").unwrap();
|
||||
let rows = stmt
|
||||
.query_map([], |row| {
|
||||
Ok(BackupHistoryRow {
|
||||
id: row.get(0)?,
|
||||
backup_path: row.get(1)?,
|
||||
status: row.get(2)?,
|
||||
created_at: row.get(3)?,
|
||||
size_bytes: row.get(4)?,
|
||||
error_message: row.get(5)?,
|
||||
})
|
||||
})
|
||||
.unwrap();
|
||||
rows.filter_map(|r| r.ok()).collect()
|
||||
};
|
||||
|
||||
let csrf_token = generate_csrf_token(&session_id);
|
||||
|
||||
let template = crate::templates::BackupsTemplate {
|
||||
admin_username: user.username,
|
||||
files,
|
||||
history,
|
||||
csrf_token,
|
||||
success: params.get("success").cloned(),
|
||||
error: params.get("error").cloned(),
|
||||
};
|
||||
|
||||
template.into_response()
|
||||
}
|
||||
|
||||
// POST /admin/backups/create
|
||||
pub async fn backups_create_post(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
Form(form): Form<HashMap<String, String>>,
|
||||
) -> Response {
|
||||
let (_user, session_id) = match require_auth(&state, &jar).await {
|
||||
Ok(u) => u,
|
||||
Err(redir) => return redir.into_response(),
|
||||
};
|
||||
|
||||
let form_csrf = form.get("csrf_token").cloned().unwrap_or_default();
|
||||
if !verify_csrf(&session_id, &form_csrf) {
|
||||
return Redirect::to("/admin/backups?error=Invalid CSRF token").into_response();
|
||||
}
|
||||
|
||||
match crate::jobs::backup::perform_backup(&state.db, &state.config).await {
|
||||
Ok(path) => {
|
||||
let filename = std::path::Path::new(&path)
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("backup.tar.gz");
|
||||
Redirect::to(&format!(
|
||||
"/admin/backups?success=Backup created successfully: {}",
|
||||
filename
|
||||
))
|
||||
.into_response()
|
||||
}
|
||||
Err(e) => Redirect::to(&format!(
|
||||
"/admin/backups?error=Failed to generate backup: {}",
|
||||
e
|
||||
))
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
// GET /admin/backups/download/:filename
|
||||
pub async fn backups_download_get(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
Path(filename): Path<String>,
|
||||
) -> Response {
|
||||
if require_auth(&state, &jar).await.is_err() {
|
||||
return StatusCode::UNAUTHORIZED.into_response();
|
||||
}
|
||||
|
||||
if filename.contains('/') || filename.contains('\\') || filename.contains("..") {
|
||||
return StatusCode::BAD_REQUEST.into_response();
|
||||
}
|
||||
|
||||
let backup_path = state.config.backup_dir.join(&filename);
|
||||
if !backup_path.exists() {
|
||||
return StatusCode::NOT_FOUND.into_response();
|
||||
}
|
||||
|
||||
match std::fs::read(&backup_path) {
|
||||
Ok(bytes) => {
|
||||
let body = axum::body::Body::from(bytes);
|
||||
Response::builder()
|
||||
.header("content-type", "application/octet-stream")
|
||||
.header(
|
||||
"content-disposition",
|
||||
format!("attachment; filename=\"{}\"", filename),
|
||||
)
|
||||
.body(body)
|
||||
.unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response())
|
||||
}
|
||||
Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
// POST /admin/backups/delete/:filename
|
||||
pub async fn backups_delete_post(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
Path(filename): Path<String>,
|
||||
Form(form): Form<HashMap<String, String>>,
|
||||
) -> Response {
|
||||
let (user, session_id) = match require_auth(&state, &jar).await {
|
||||
Ok(u) => u,
|
||||
Err(redir) => return redir.into_response(),
|
||||
};
|
||||
|
||||
let form_csrf = form.get("csrf_token").cloned().unwrap_or_default();
|
||||
if !verify_csrf(&session_id, &form_csrf) {
|
||||
return Redirect::to("/admin/backups?error=Invalid CSRF token").into_response();
|
||||
}
|
||||
|
||||
if filename.contains('/') || filename.contains('\\') || filename.contains("..") {
|
||||
return Redirect::to("/admin/backups?error=Invalid filename").into_response();
|
||||
}
|
||||
|
||||
let backup_path = state.config.backup_dir.join(&filename);
|
||||
if !backup_path.exists() {
|
||||
return Redirect::to("/admin/backups?error=Backup file not found").into_response();
|
||||
}
|
||||
|
||||
match std::fs::remove_file(&backup_path) {
|
||||
Ok(_) => {
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let _ = crate::db::audit_events::write_audit_event(
|
||||
&system_conn,
|
||||
&user.username,
|
||||
"BACKUP_DELETE",
|
||||
"backup",
|
||||
&filename,
|
||||
None,
|
||||
);
|
||||
Redirect::to("/admin/backups?success=Backup archive deleted").into_response()
|
||||
}
|
||||
Err(e) => Redirect::to(&format!(
|
||||
"/admin/backups?error=Failed to delete backup file: {}",
|
||||
e
|
||||
))
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
// POST /admin/backups/restore
|
||||
pub async fn backups_restore_post(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
mut multipart: axum::extract::Multipart,
|
||||
) -> Response {
|
||||
let (user, session_id) = match require_auth(&state, &jar).await {
|
||||
Ok(u) => u,
|
||||
Err(redir) => return redir.into_response(),
|
||||
};
|
||||
|
||||
let mut backup_bytes = vec![];
|
||||
let mut confirm_text = String::new();
|
||||
let mut csrf_token = String::new();
|
||||
|
||||
while let Ok(Some(field)) = multipart.next_field().await {
|
||||
let name = field.name().unwrap_or_default().to_string();
|
||||
if name == "backup_file" {
|
||||
if let Ok(bytes) = field.bytes().await {
|
||||
backup_bytes = bytes.to_vec();
|
||||
}
|
||||
} else if name == "confirm_text" {
|
||||
if let Ok(text) = field.text().await {
|
||||
confirm_text = text.trim().to_string();
|
||||
}
|
||||
} else if name == "csrf_token" {
|
||||
if let Ok(text) = field.text().await {
|
||||
csrf_token = text.trim().to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !verify_csrf(&session_id, &csrf_token) {
|
||||
return Redirect::to("/admin/backups?error=Invalid CSRF token").into_response();
|
||||
}
|
||||
|
||||
if confirm_text != "RESTORE" {
|
||||
return Redirect::to(
|
||||
"/admin/backups?error=Confirmation text mismatch. Please type RESTORE.",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
if backup_bytes.is_empty() {
|
||||
return Redirect::to("/admin/backups?error=Backup file is empty or missing.")
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let temp_file_path = state.config.data_dir.join("temp-restore-upload.tar.gz");
|
||||
if let Err(e) = std::fs::write(&temp_file_path, &backup_bytes) {
|
||||
return Redirect::to(&format!(
|
||||
"/admin/backups?error=Failed to save uploaded file: {}",
|
||||
e
|
||||
))
|
||||
.into_response();
|
||||
}
|
||||
|
||||
match crate::cli::restore::run(
|
||||
temp_file_path.to_string_lossy().to_string(),
|
||||
None,
|
||||
state.config.clone(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_) => {
|
||||
let _ = std::fs::remove_file(&temp_file_path);
|
||||
let conn = state.users_db.lock().unwrap();
|
||||
let _ = conn.execute("DELETE FROM sessions;", []);
|
||||
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let _ = crate::db::audit_events::write_audit_event(
|
||||
&system_conn,
|
||||
&user.username,
|
||||
"RESTORE_EXECUTION",
|
||||
"backup",
|
||||
"upload",
|
||||
None,
|
||||
);
|
||||
Redirect::to("/admin/login?success=Restore successful. Please log in again.")
|
||||
.into_response()
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = std::fs::remove_file(&temp_file_path);
|
||||
Redirect::to(&format!("/admin/backups?error=Restore failed: {}", e)).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
use super::*;
|
||||
|
||||
// GET /user/dashboard
|
||||
pub async fn user_dashboard_get(State(state): State<AppState>, jar: CookieJar) -> Response {
|
||||
let (user, _session_id) = match require_user_auth(&state, &jar).await {
|
||||
Ok(u) => u,
|
||||
Err(redir) => return redir.into_response(),
|
||||
};
|
||||
|
||||
let user_dbs = match state.get_user_dbs(user.id) {
|
||||
Ok(dbs) => dbs,
|
||||
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
|
||||
};
|
||||
|
||||
let (total_urls, active_links, dead_links) = {
|
||||
let conn = user_dbs.content.lock().unwrap();
|
||||
get_url_counts(&conn).unwrap_or((0, 0, 0))
|
||||
};
|
||||
|
||||
let total_pages = {
|
||||
let conn = user_dbs.content.lock().unwrap();
|
||||
get_landing_page_count(&conn).unwrap_or(0)
|
||||
};
|
||||
|
||||
let total_clicks = {
|
||||
let conn = user_dbs.analytics.lock().unwrap();
|
||||
get_total_clicks(&conn).unwrap_or(0)
|
||||
};
|
||||
|
||||
let clicks_data = {
|
||||
let conn = user_dbs.analytics.lock().unwrap();
|
||||
get_clicks_trend(&conn, "url", "all", 30)
|
||||
.or_else(|_| get_clicks_trend_raw(&conn, "url", "all", 30))
|
||||
.unwrap_or_default()
|
||||
};
|
||||
|
||||
let mut trend_map = std::collections::BTreeMap::new();
|
||||
for i in (0..30).rev() {
|
||||
let date_str = (Utc::now() - chrono::Duration::days(i))
|
||||
.format("%Y-%m-%d")
|
||||
.to_string();
|
||||
trend_map.insert(date_str, 0i64);
|
||||
}
|
||||
for (d, c) in clicks_data {
|
||||
trend_map.insert(d, c);
|
||||
}
|
||||
let formatted_trend: Vec<(String, i64)> = trend_map.into_iter().collect();
|
||||
let traffic_chart = generate_line_chart(&formatted_trend);
|
||||
|
||||
let countries_data = {
|
||||
let conn = user_dbs.analytics.lock().unwrap();
|
||||
get_metric_rankings(&conn, "url", "all", "country", 5)
|
||||
.or_else(|_| get_metric_rankings_raw(&conn, "url", "all", "country", 5))
|
||||
.unwrap_or_default()
|
||||
};
|
||||
let countries_chart = generate_bar_chart(&countries_data);
|
||||
|
||||
let referrers_data = {
|
||||
let conn = user_dbs.analytics.lock().unwrap();
|
||||
get_metric_rankings(&conn, "url", "all", "referrer", 5)
|
||||
.or_else(|_| get_metric_rankings_raw(&conn, "url", "all", "referrer", 5))
|
||||
.unwrap_or_default()
|
||||
};
|
||||
let referrers_chart = generate_bar_chart(&referrers_data);
|
||||
|
||||
let browsers_data = {
|
||||
let conn = user_dbs.analytics.lock().unwrap();
|
||||
get_metric_rankings(&conn, "url", "all", "browser", 5)
|
||||
.or_else(|_| get_metric_rankings_raw(&conn, "url", "all", "browser", 5))
|
||||
.unwrap_or_default()
|
||||
};
|
||||
let browsers_chart = generate_bar_chart(&browsers_data);
|
||||
|
||||
let template = crate::templates::UserDashboardTemplate {
|
||||
admin_username: user.username,
|
||||
total_urls,
|
||||
total_pages,
|
||||
total_clicks,
|
||||
active_links,
|
||||
dead_links,
|
||||
traffic_chart,
|
||||
countries_chart,
|
||||
browsers_chart,
|
||||
referrers_chart,
|
||||
};
|
||||
|
||||
template.into_response()
|
||||
}
|
||||
|
||||
// GET /admin/dashboard
|
||||
pub async fn dashboard_get(State(state): State<AppState>, jar: CookieJar) -> Response {
|
||||
let (user, _) = match require_auth(&state, &jar).await {
|
||||
Ok(u) => u,
|
||||
Err(redir) => return redir.into_response(),
|
||||
};
|
||||
|
||||
let (total_urls, active_links, dead_links) = {
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
get_url_counts(&conn).unwrap_or((0, 0, 0))
|
||||
};
|
||||
|
||||
let total_pages = {
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
get_landing_page_count(&conn).unwrap_or(0)
|
||||
};
|
||||
|
||||
let total_clicks = {
|
||||
let conn = state.analytics_db.lock().unwrap();
|
||||
get_total_clicks(&conn).unwrap_or(0)
|
||||
};
|
||||
|
||||
let clicks_data = {
|
||||
let conn = state.analytics_db.lock().unwrap();
|
||||
get_clicks_trend(&conn, "url", "all", 30)
|
||||
.or_else(|_| get_clicks_trend_raw(&conn, "url", "all", 30))
|
||||
.unwrap_or_default()
|
||||
};
|
||||
|
||||
let mut trend_map = std::collections::BTreeMap::new();
|
||||
for i in (0..30).rev() {
|
||||
let date_str = (Utc::now() - chrono::Duration::days(i))
|
||||
.format("%Y-%m-%d")
|
||||
.to_string();
|
||||
trend_map.insert(date_str, 0i64);
|
||||
}
|
||||
for (d, c) in clicks_data {
|
||||
trend_map.insert(d, c);
|
||||
}
|
||||
let formatted_trend: Vec<(String, i64)> = trend_map.into_iter().collect();
|
||||
let traffic_chart = generate_line_chart(&formatted_trend);
|
||||
|
||||
let countries_data = {
|
||||
let conn = state.analytics_db.lock().unwrap();
|
||||
get_metric_rankings(&conn, "url", "all", "country", 5)
|
||||
.or_else(|_| get_metric_rankings_raw(&conn, "url", "all", "country", 5))
|
||||
.unwrap_or_default()
|
||||
};
|
||||
let countries_chart = generate_bar_chart(&countries_data);
|
||||
|
||||
let referrers_data = {
|
||||
let conn = state.analytics_db.lock().unwrap();
|
||||
get_metric_rankings(&conn, "url", "all", "referrer", 5)
|
||||
.or_else(|_| get_metric_rankings_raw(&conn, "url", "all", "referrer", 5))
|
||||
.unwrap_or_default()
|
||||
};
|
||||
let referrers_chart = generate_bar_chart(&referrers_data);
|
||||
|
||||
let browsers_data = {
|
||||
let conn = state.analytics_db.lock().unwrap();
|
||||
get_metric_rankings(&conn, "url", "all", "browser", 5)
|
||||
.or_else(|_| get_metric_rankings_raw(&conn, "url", "all", "browser", 5))
|
||||
.unwrap_or_default()
|
||||
};
|
||||
let browsers_chart = generate_bar_chart(&browsers_data);
|
||||
|
||||
let template = crate::templates::DashboardTemplate {
|
||||
admin_username: user.username,
|
||||
total_urls,
|
||||
total_pages,
|
||||
total_clicks,
|
||||
active_links,
|
||||
dead_links,
|
||||
traffic_chart,
|
||||
countries_chart,
|
||||
browsers_chart,
|
||||
referrers_chart,
|
||||
};
|
||||
|
||||
template.into_response()
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
use super::*;
|
||||
|
||||
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
|
||||
pub struct JobHistoryRow {
|
||||
pub id: String,
|
||||
pub job_name: String,
|
||||
pub status: String,
|
||||
pub started_at: String,
|
||||
pub finished_at: Option<String>,
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
|
||||
pub struct HealthCheckRow {
|
||||
pub id: String,
|
||||
pub object_type: String,
|
||||
pub object_id: String,
|
||||
pub checked_at: String,
|
||||
pub status_code: Option<i64>,
|
||||
pub error_message: Option<String>,
|
||||
pub is_healthy: i64,
|
||||
}
|
||||
|
||||
// GET /admin/health
|
||||
pub async fn health_get(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
Query(params): Query<HashMap<String, String>>,
|
||||
) -> Response {
|
||||
let (user, session_id) = match require_auth(&state, &jar).await {
|
||||
Ok(u) => u,
|
||||
Err(redir) => return redir.into_response(),
|
||||
};
|
||||
|
||||
let mut db_reports = vec![];
|
||||
if let Ok(r) =
|
||||
crate::db::sqlite::collect_health_report(&state.admin_db.lock().unwrap(), "admin")
|
||||
{
|
||||
db_reports.push(r);
|
||||
}
|
||||
if let Ok(r) =
|
||||
crate::db::sqlite::collect_health_report(&state.system_db.lock().unwrap(), "system")
|
||||
{
|
||||
db_reports.push(r);
|
||||
}
|
||||
if let Ok(r) =
|
||||
crate::db::sqlite::collect_health_report(&state.users_db.lock().unwrap(), "users")
|
||||
{
|
||||
db_reports.push(r);
|
||||
}
|
||||
|
||||
let system_db_path = state.config.data_dir.join("admin").join("system.db");
|
||||
let users_db_path = state.config.data_dir.join("admin").join("users.db");
|
||||
let admin_db_path = state.config.data_dir.join("admin").join("admin.db");
|
||||
|
||||
let system_db_size = format_size(
|
||||
std::fs::metadata(&system_db_path)
|
||||
.map(|m| m.len())
|
||||
.unwrap_or(0),
|
||||
);
|
||||
let users_db_size = format_size(
|
||||
std::fs::metadata(&users_db_path)
|
||||
.map(|m| m.len())
|
||||
.unwrap_or(0),
|
||||
);
|
||||
let admin_db_size = format_size(
|
||||
std::fs::metadata(&admin_db_path)
|
||||
.map(|m| m.len())
|
||||
.unwrap_or(0),
|
||||
);
|
||||
|
||||
let users_dir = state.config.data_dir.join("users");
|
||||
let tenants_db_size = format_size(get_dir_size(&users_dir).unwrap_or(0));
|
||||
let total_data_size = format_size(get_dir_size(&state.config.data_dir).unwrap_or(0));
|
||||
|
||||
let job_history = {
|
||||
let conn = state.system_db.lock().unwrap();
|
||||
let mut stmt = conn.prepare("SELECT id, job_name, status, started_at, finished_at, error_message FROM job_history ORDER BY started_at DESC LIMIT 20;").unwrap();
|
||||
let rows = stmt
|
||||
.query_map([], |row| {
|
||||
Ok(JobHistoryRow {
|
||||
id: row.get(0)?,
|
||||
job_name: row.get(1)?,
|
||||
status: row.get(2)?,
|
||||
started_at: row.get(3)?,
|
||||
finished_at: row.get(4)?,
|
||||
error_message: row.get(5)?,
|
||||
})
|
||||
})
|
||||
.unwrap();
|
||||
rows.filter_map(|r| r.ok()).collect()
|
||||
};
|
||||
|
||||
let health_checks = {
|
||||
let conn = state.system_db.lock().unwrap();
|
||||
let mut stmt = conn.prepare("SELECT id, object_type, object_id, checked_at, status_code, error_message, is_healthy FROM health_checks ORDER BY checked_at DESC LIMIT 20;").unwrap();
|
||||
let rows = stmt
|
||||
.query_map([], |row| {
|
||||
Ok(HealthCheckRow {
|
||||
id: row.get(0)?,
|
||||
object_type: row.get(1)?,
|
||||
object_id: row.get(2)?,
|
||||
checked_at: row.get(3)?,
|
||||
status_code: row.get(4)?,
|
||||
error_message: row.get(5)?,
|
||||
is_healthy: row.get(6)?,
|
||||
})
|
||||
})
|
||||
.unwrap();
|
||||
rows.filter_map(|r| r.ok()).collect()
|
||||
};
|
||||
|
||||
let csrf_token = generate_csrf_token(&session_id);
|
||||
|
||||
let (registry_errors, registry_warnings) = {
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let users_conn = state.users_db.lock().unwrap();
|
||||
match crate::services::registry_validator::RegistryValidator::scan(
|
||||
&system_conn,
|
||||
&users_conn,
|
||||
&state.config.data_dir,
|
||||
None,
|
||||
) {
|
||||
Ok(issues) => {
|
||||
let mut errors = Vec::new();
|
||||
let mut warnings = Vec::new();
|
||||
for issue in issues {
|
||||
use crate::services::registry_validator::RegistryIssueType;
|
||||
match issue.issue_type {
|
||||
RegistryIssueType::StaleReservation
|
||||
| RegistryIssueType::TenantAdminHasIsolatedContent => {
|
||||
warnings.push(format!(
|
||||
"Warning for slug {}: {}",
|
||||
issue.slug, issue.description
|
||||
));
|
||||
}
|
||||
_ => {
|
||||
errors.push(format!(
|
||||
"Error for slug {}: {}",
|
||||
issue.slug, issue.description
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
(errors, warnings)
|
||||
}
|
||||
Err(e) => (vec![format!("Failed to run registry scan: {}", e)], vec![]),
|
||||
}
|
||||
};
|
||||
|
||||
let template = crate::templates::HealthTemplate {
|
||||
admin_username: user.username,
|
||||
db_reports,
|
||||
total_data_size,
|
||||
system_db_size,
|
||||
users_db_size,
|
||||
admin_db_size,
|
||||
tenants_db_size,
|
||||
job_history,
|
||||
health_checks,
|
||||
registry_errors,
|
||||
registry_warnings,
|
||||
csrf_token,
|
||||
success: params.get("success").cloned(),
|
||||
error: params.get("error").cloned(),
|
||||
};
|
||||
|
||||
template.into_response()
|
||||
}
|
||||
@@ -0,0 +1,849 @@
|
||||
//! Admin web UI handlers (feature-split modules).
|
||||
//!
|
||||
//! Handlers are organized by domain; shared auth, audit, export, and helpers
|
||||
//! live in this module root so child modules can access them via `super`.
|
||||
|
||||
use crate::auth::{
|
||||
authenticate_admin_session, authenticate_user_session, generate_csrf_token, generate_token,
|
||||
hash_password, verify_csrf, verify_password, verify_sha256,
|
||||
};
|
||||
use crate::charts::{generate_bar_chart, generate_line_chart};
|
||||
use crate::db::admin::{
|
||||
create_api_key, delete_api_key, get_config, get_user_count, list_api_keys, set_config,
|
||||
write_audit_log as write_audit_log_legacy,
|
||||
};
|
||||
use crate::db::analytics::{
|
||||
clean_referrer, get_clicks_trend, get_clicks_trend_raw, get_metric_rankings,
|
||||
get_metric_rankings_raw, get_monthly_clicks_trend, get_target_unique_visitors,
|
||||
get_target_visit_total_filtered, get_target_visits_all_in_memory, get_target_visits_paginated,
|
||||
get_total_clicks, get_visits_schema_columns, parse_ua,
|
||||
};
|
||||
use crate::db::content::{
|
||||
create_landing_page, delete_landing_page, delete_url, get_landing_page_by_id,
|
||||
get_landing_page_count, get_url_by_id, get_url_count_by_tag, get_url_counts,
|
||||
list_landing_pages, list_urls,
|
||||
};
|
||||
use crate::models::User;
|
||||
use crate::state::AppState;
|
||||
use crate::utils::{get_client_ip, get_db_file_info, get_memory_usage};
|
||||
use axum::{
|
||||
extract::{ConnectInfo, Path, Query, State},
|
||||
http::{HeaderMap, StatusCode},
|
||||
response::{IntoResponse, Redirect, Response},
|
||||
Form,
|
||||
};
|
||||
use axum_extra::extract::cookie::Cookie;
|
||||
use axum_extra::extract::CookieJar;
|
||||
use chrono::Utc;
|
||||
use flate2::read::GzDecoder;
|
||||
use flate2::write::GzEncoder;
|
||||
use flate2::Compression;
|
||||
use rusqlite::{params, OptionalExtension};
|
||||
use serde::Deserialize;
|
||||
use std::collections::HashMap;
|
||||
use std::fs::File;
|
||||
use std::net::SocketAddr;
|
||||
use tar::Builder;
|
||||
use uuid::Uuid;
|
||||
|
||||
// --- Shared constants, auth, audit, and export helpers ---
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) fn write_audit_log(
|
||||
conn: &rusqlite::Connection,
|
||||
state: &AppState,
|
||||
username: &str,
|
||||
action: &str,
|
||||
object_type: Option<&str>,
|
||||
object_id: Option<&str>,
|
||||
ip_address: Option<&str>,
|
||||
user_agent: Option<&str>,
|
||||
) -> rusqlite::Result<crate::models::AuditLog> {
|
||||
let res = write_audit_log_legacy(
|
||||
conn,
|
||||
username,
|
||||
action,
|
||||
object_type,
|
||||
object_id,
|
||||
ip_address,
|
||||
user_agent,
|
||||
);
|
||||
|
||||
// Also write to unified audit events in system.db
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let metadata = format!("IP: {:?}, UA: {:?}", ip_address, user_agent);
|
||||
let _ = crate::db::audit_events::write_audit_event(
|
||||
&system_conn,
|
||||
username,
|
||||
action,
|
||||
object_type.unwrap_or(""),
|
||||
object_id.unwrap_or(""),
|
||||
Some(&metadata),
|
||||
);
|
||||
|
||||
res
|
||||
}
|
||||
pub(crate) const PAGE_SIZE: usize = 25;
|
||||
pub(crate) const ANALYTICS_PAGE_SIZE: usize = 50;
|
||||
pub(crate) const MAX_JSON_EXPORT_ROWS: usize = 50_000;
|
||||
// Helper: Verify admin session and return user or redirect to login
|
||||
pub(crate) async fn require_auth(
|
||||
state: &AppState,
|
||||
jar: &CookieJar,
|
||||
) -> Result<(User, String), Redirect> {
|
||||
let conn = match state.users_db.lock() {
|
||||
Ok(c) => c,
|
||||
Err(_) => return Err(Redirect::to("/admin/login")),
|
||||
};
|
||||
match authenticate_admin_session(&conn, jar) {
|
||||
Ok(Some((user, session_id))) => Ok((user, session_id)),
|
||||
_ => Err(Redirect::to("/admin/login")),
|
||||
}
|
||||
}
|
||||
// Helper: Verify tenant user session and return user or redirect to login
|
||||
pub(crate) async fn require_user_auth(
|
||||
state: &AppState,
|
||||
jar: &CookieJar,
|
||||
) -> Result<(crate::models::TenantUser, String), Redirect> {
|
||||
let conn = match state.users_db.lock() {
|
||||
Ok(c) => c,
|
||||
Err(_) => return Err(Redirect::to("/login")),
|
||||
};
|
||||
match authenticate_user_session(&conn, jar) {
|
||||
Ok(Some((user, session_id))) => Ok((user, session_id)),
|
||||
_ => Err(Redirect::to("/login")),
|
||||
}
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
pub struct AnalyticsQuery {
|
||||
pub analytics_page: Option<usize>,
|
||||
pub date_from: Option<String>,
|
||||
pub date_to: Option<String>,
|
||||
}
|
||||
pub(crate) fn validate_date_filters(
|
||||
date_from: Option<&str>,
|
||||
date_to: Option<&str>,
|
||||
) -> Result<(Option<String>, Option<String>), StatusCode> {
|
||||
let from_parsed = match date_from {
|
||||
Some(df) if !df.is_empty() => match chrono::NaiveDate::parse_from_str(df, "%Y-%m-%d") {
|
||||
Ok(d) => Some(d),
|
||||
Err(_) => return Err(StatusCode::BAD_REQUEST),
|
||||
},
|
||||
_ => None,
|
||||
};
|
||||
let to_parsed = match date_to {
|
||||
Some(dt) if !dt.is_empty() => match chrono::NaiveDate::parse_from_str(dt, "%Y-%m-%d") {
|
||||
Ok(d) => Some(d),
|
||||
Err(_) => return Err(StatusCode::BAD_REQUEST),
|
||||
},
|
||||
_ => None,
|
||||
};
|
||||
if let (Some(f), Some(t)) = (from_parsed, to_parsed) {
|
||||
if f > t {
|
||||
return Err(StatusCode::BAD_REQUEST);
|
||||
}
|
||||
}
|
||||
Ok((
|
||||
from_parsed.map(|d| d.format("%Y-%m-%d").to_string()),
|
||||
to_parsed.map(|d| d.format("%Y-%m-%d").to_string()),
|
||||
))
|
||||
}
|
||||
pub(crate) fn escape_csv_field(field: &str) -> String {
|
||||
let needs_escaping =
|
||||
field.contains(',') || field.contains('"') || field.contains('\n') || field.contains('\r');
|
||||
if needs_escaping {
|
||||
let escaped = field.replace('"', "\"\"");
|
||||
format!("\"{}\"", escaped)
|
||||
} else {
|
||||
field.to_string()
|
||||
}
|
||||
}
|
||||
pub(crate) struct DbExportStream {
|
||||
receiver: tokio::sync::mpsc::Receiver<Result<axum::body::Bytes, std::convert::Infallible>>,
|
||||
}
|
||||
|
||||
impl futures_util::stream::Stream for DbExportStream {
|
||||
type Item = Result<axum::body::Bytes, std::convert::Infallible>;
|
||||
|
||||
fn poll_next(
|
||||
mut self: std::pin::Pin<&mut Self>,
|
||||
cx: &mut std::task::Context<'_>,
|
||||
) -> std::task::Poll<Option<Self::Item>> {
|
||||
self.receiver.poll_recv(cx)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn perform_csv_export(
|
||||
state: AppState,
|
||||
target_type: &'static str,
|
||||
id: String,
|
||||
date_from: Option<String>,
|
||||
date_to: Option<String>,
|
||||
) -> Response {
|
||||
let (clean_date_from, clean_date_to) =
|
||||
match validate_date_filters(date_from.as_deref(), date_to.as_deref()) {
|
||||
Ok(res) => res,
|
||||
Err(status) => return status.into_response(),
|
||||
};
|
||||
|
||||
let target_exists = {
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
if target_type == "url" {
|
||||
get_url_by_id(&conn, &id)
|
||||
.map(|u| u.is_some())
|
||||
.unwrap_or(false)
|
||||
} else {
|
||||
get_landing_page_by_id(&conn, &id)
|
||||
.map(|p| p.is_some())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
};
|
||||
if !target_exists {
|
||||
return (StatusCode::NOT_FOUND, "Target not found").into_response();
|
||||
}
|
||||
|
||||
let count = {
|
||||
let conn = state.analytics_db.lock().unwrap();
|
||||
get_target_visit_total_filtered(
|
||||
&conn,
|
||||
target_type,
|
||||
&id,
|
||||
clean_date_from.as_deref(),
|
||||
clean_date_to.as_deref(),
|
||||
)
|
||||
.unwrap_or(0)
|
||||
};
|
||||
|
||||
let (has_utm_source, has_utm_campaign) = {
|
||||
let conn = state.analytics_db.lock().unwrap();
|
||||
let cols = get_visits_schema_columns(&conn).unwrap_or_default();
|
||||
(cols.contains("utm_source"), cols.contains("utm_campaign"))
|
||||
};
|
||||
|
||||
let (tx, rx) =
|
||||
tokio::sync::mpsc::channel::<Result<axum::body::Bytes, std::convert::Infallible>>(32);
|
||||
let analytics_db = state.analytics_db.clone();
|
||||
let target_id = id.clone();
|
||||
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let conn = analytics_db.lock().unwrap();
|
||||
|
||||
let mut header = "Timestamp,IP Address,Country,Referrer,Browser,User-Agent".to_string();
|
||||
if has_utm_source {
|
||||
header.push_str(",UTM Source");
|
||||
}
|
||||
if has_utm_campaign {
|
||||
header.push_str(",UTM Campaign");
|
||||
}
|
||||
header.push('\n');
|
||||
|
||||
if tx
|
||||
.blocking_send(Ok(axum::body::Bytes::from(header)))
|
||||
.is_err()
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
let select_fields = if has_utm_source && has_utm_campaign {
|
||||
"timestamp, ip_address, country, referer, user_agent, utm_source, utm_campaign"
|
||||
} else if has_utm_source {
|
||||
"timestamp, ip_address, country, referer, user_agent, utm_source"
|
||||
} else if has_utm_campaign {
|
||||
"timestamp, ip_address, country, referer, user_agent, utm_campaign"
|
||||
} else {
|
||||
"timestamp, ip_address, country, referer, user_agent"
|
||||
};
|
||||
|
||||
let mut sql = format!(
|
||||
"SELECT {} FROM visits WHERE target_type = ?1 AND target_id = ?2",
|
||||
select_fields
|
||||
);
|
||||
let mut params: Vec<Box<dyn rusqlite::ToSql>> =
|
||||
vec![Box::new(target_type.to_string()), Box::new(target_id)];
|
||||
|
||||
if let Some(df) = clean_date_from.as_deref() {
|
||||
sql.push_str(&format!(" AND timestamp >= ?{}", params.len() + 1));
|
||||
params.push(Box::new(format!("{}T00:00:00Z", df)));
|
||||
}
|
||||
|
||||
if let Some(dt) = clean_date_to.as_deref() {
|
||||
if let Ok(parsed_date) = chrono::NaiveDate::parse_from_str(dt, "%Y-%m-%d") {
|
||||
let next_day = parsed_date + chrono::Duration::days(1);
|
||||
sql.push_str(&format!(" AND timestamp < ?{}", params.len() + 1));
|
||||
params.push(Box::new(format!(
|
||||
"{}T00:00:00Z",
|
||||
next_day.format("%Y-%m-%d")
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
sql.push_str(" ORDER BY timestamp DESC, id DESC");
|
||||
|
||||
let mut stmt = match conn.prepare(&sql) {
|
||||
Ok(s) => s,
|
||||
Err(_) => return,
|
||||
};
|
||||
|
||||
let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect();
|
||||
let mut rows = match stmt.query(rusqlite::params_from_iter(param_refs)) {
|
||||
Ok(r) => r,
|
||||
Err(_) => return,
|
||||
};
|
||||
|
||||
let mut csv_buffer = String::new();
|
||||
|
||||
while let Ok(Some(row)) = rows.next() {
|
||||
let timestamp: String = row.get(0).unwrap_or_default();
|
||||
let ip_address: String = row.get(1).unwrap_or_default();
|
||||
let country: String = row.get(2).unwrap_or_default();
|
||||
let referer: String = row.get(3).unwrap_or_default();
|
||||
let user_agent: String = row.get(4).unwrap_or_default();
|
||||
|
||||
let (browser, _, _) = parse_ua(&user_agent);
|
||||
let referrer = clean_referrer(&referer);
|
||||
let country_display = if country.is_empty() {
|
||||
"Unknown".to_string()
|
||||
} else {
|
||||
country
|
||||
};
|
||||
|
||||
let mut line = format!(
|
||||
"{},{},{},{},{},{}",
|
||||
escape_csv_field(×tamp),
|
||||
escape_csv_field(&ip_address),
|
||||
escape_csv_field(&country_display),
|
||||
escape_csv_field(&referrer),
|
||||
escape_csv_field(&browser),
|
||||
escape_csv_field(&user_agent)
|
||||
);
|
||||
|
||||
let mut col_idx = 5;
|
||||
if has_utm_source {
|
||||
let utm_src: String = row.get(col_idx).unwrap_or_default();
|
||||
line.push_str(&format!(",{}", escape_csv_field(&utm_src)));
|
||||
col_idx += 1;
|
||||
}
|
||||
if has_utm_campaign {
|
||||
let utm_camp: String = row.get(col_idx).unwrap_or_default();
|
||||
line.push_str(&format!(",{}", escape_csv_field(&utm_camp)));
|
||||
}
|
||||
line.push('\n');
|
||||
|
||||
csv_buffer.push_str(&line);
|
||||
if csv_buffer.len() >= 8192 {
|
||||
let bytes = axum::body::Bytes::from(csv_buffer);
|
||||
if tx.blocking_send(Ok(bytes)).is_err() {
|
||||
return;
|
||||
}
|
||||
csv_buffer = String::new();
|
||||
}
|
||||
}
|
||||
|
||||
if !csv_buffer.is_empty() {
|
||||
let _ = tx.blocking_send(Ok(axum::body::Bytes::from(csv_buffer)));
|
||||
}
|
||||
});
|
||||
|
||||
let stream = DbExportStream { receiver: rx };
|
||||
let filename = if target_type == "url" {
|
||||
format!("url_{}_analytics.csv", id)
|
||||
} else {
|
||||
format!("page_{}_analytics.csv", id)
|
||||
};
|
||||
|
||||
(
|
||||
StatusCode::OK,
|
||||
[
|
||||
("Content-Type", "text/csv"),
|
||||
(
|
||||
"Content-Disposition",
|
||||
&format!("attachment; filename=\"{}\"", filename),
|
||||
),
|
||||
("X-BZOD-Export-Records", &count.to_string()),
|
||||
],
|
||||
axum::body::Body::from_stream(stream),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
pub(crate) async fn perform_json_export(
|
||||
state: AppState,
|
||||
target_type: &'static str,
|
||||
id: String,
|
||||
date_from: Option<String>,
|
||||
date_to: Option<String>,
|
||||
) -> Response {
|
||||
let (clean_date_from, clean_date_to) =
|
||||
match validate_date_filters(date_from.as_deref(), date_to.as_deref()) {
|
||||
Ok(res) => res,
|
||||
Err(status) => return status.into_response(),
|
||||
};
|
||||
|
||||
let target_exists = {
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
if target_type == "url" {
|
||||
get_url_by_id(&conn, &id)
|
||||
.map(|u| u.is_some())
|
||||
.unwrap_or(false)
|
||||
} else {
|
||||
get_landing_page_by_id(&conn, &id)
|
||||
.map(|p| p.is_some())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
};
|
||||
if !target_exists {
|
||||
return (StatusCode::NOT_FOUND, "Target not found").into_response();
|
||||
}
|
||||
|
||||
let count = {
|
||||
let conn = state.analytics_db.lock().unwrap();
|
||||
get_target_visit_total_filtered(
|
||||
&conn,
|
||||
target_type,
|
||||
&id,
|
||||
clean_date_from.as_deref(),
|
||||
clean_date_to.as_deref(),
|
||||
)
|
||||
.unwrap_or(0)
|
||||
};
|
||||
|
||||
if count > MAX_JSON_EXPORT_ROWS as i64 {
|
||||
return StatusCode::PAYLOAD_TOO_LARGE.into_response();
|
||||
}
|
||||
|
||||
let visits_raw = {
|
||||
let conn = state.analytics_db.lock().unwrap();
|
||||
match get_target_visits_all_in_memory(
|
||||
&conn,
|
||||
target_type,
|
||||
&id,
|
||||
clean_date_from.as_deref(),
|
||||
clean_date_to.as_deref(),
|
||||
) {
|
||||
Ok(v) => v,
|
||||
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
|
||||
}
|
||||
};
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
struct JsonExportRow {
|
||||
timestamp: String,
|
||||
ip_address: String,
|
||||
country: String,
|
||||
referrer: String,
|
||||
browser: String,
|
||||
user_agent: String,
|
||||
}
|
||||
|
||||
let export_rows: Vec<JsonExportRow> = visits_raw
|
||||
.into_iter()
|
||||
.map(|r| {
|
||||
let (browser, _, _) = parse_ua(&r.user_agent);
|
||||
let referrer = clean_referrer(&r.referer);
|
||||
let country_display = if r.country.is_empty() {
|
||||
"Unknown".to_string()
|
||||
} else {
|
||||
r.country
|
||||
};
|
||||
JsonExportRow {
|
||||
timestamp: r.timestamp,
|
||||
ip_address: r.ip_address,
|
||||
country: country_display,
|
||||
referrer,
|
||||
browser,
|
||||
user_agent: r.user_agent,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let body_str = match serde_json::to_string(&export_rows) {
|
||||
Ok(s) => s,
|
||||
Err(_) => {
|
||||
return (StatusCode::INTERNAL_SERVER_ERROR, "Serialization error").into_response()
|
||||
}
|
||||
};
|
||||
|
||||
let filename = if target_type == "url" {
|
||||
format!("url_{}_analytics.json", id)
|
||||
} else {
|
||||
format!("page_{}_analytics.json", id)
|
||||
};
|
||||
|
||||
(
|
||||
StatusCode::OK,
|
||||
[
|
||||
("Content-Type", "application/json"),
|
||||
(
|
||||
"Content-Disposition",
|
||||
&format!("attachment; filename=\"{}\"", filename),
|
||||
),
|
||||
("X-BZOD-Export-Records", &count.to_string()),
|
||||
],
|
||||
body_str,
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
// Helpers
|
||||
pub(crate) fn format_size(bytes: u64) -> String {
|
||||
if bytes < 1024 {
|
||||
format!("{} B", bytes)
|
||||
} else if bytes < 1024 * 1024 {
|
||||
format!("{:.2} KB", bytes as f64 / 1024.0)
|
||||
} else {
|
||||
format!("{:.2} MB", bytes as f64 / (1024.0 * 1024.0))
|
||||
}
|
||||
}
|
||||
pub(crate) fn get_dir_size(dir: &std::path::Path) -> std::io::Result<u64> {
|
||||
let mut total = 0;
|
||||
if dir.is_dir() {
|
||||
for entry in std::fs::read_dir(dir)? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
total += get_dir_size(&path)?;
|
||||
} else {
|
||||
total += entry.metadata()?.len();
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(total)
|
||||
}
|
||||
pub(crate) async fn perform_user_csv_export(
|
||||
user_dbs: crate::state::UserDbs,
|
||||
target_type: &'static str,
|
||||
id: String,
|
||||
date_from: Option<String>,
|
||||
date_to: Option<String>,
|
||||
) -> Response {
|
||||
let (clean_date_from, clean_date_to) =
|
||||
match validate_date_filters(date_from.as_deref(), date_to.as_deref()) {
|
||||
Ok(res) => res,
|
||||
Err(status) => return status.into_response(),
|
||||
};
|
||||
|
||||
let target_exists = {
|
||||
let conn = user_dbs.content.lock().unwrap();
|
||||
if target_type == "url" {
|
||||
get_url_by_id(&conn, &id)
|
||||
.map(|u| u.is_some())
|
||||
.unwrap_or(false)
|
||||
} else {
|
||||
get_landing_page_by_id(&conn, &id)
|
||||
.map(|p| p.is_some())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
};
|
||||
if !target_exists {
|
||||
return (StatusCode::NOT_FOUND, "Target not found").into_response();
|
||||
}
|
||||
|
||||
let count = {
|
||||
let conn = user_dbs.analytics.lock().unwrap();
|
||||
get_target_visit_total_filtered(
|
||||
&conn,
|
||||
target_type,
|
||||
&id,
|
||||
clean_date_from.as_deref(),
|
||||
clean_date_to.as_deref(),
|
||||
)
|
||||
.unwrap_or(0)
|
||||
};
|
||||
|
||||
let (has_utm_source, has_utm_campaign) = {
|
||||
let conn = user_dbs.analytics.lock().unwrap();
|
||||
let cols = get_visits_schema_columns(&conn).unwrap_or_default();
|
||||
(cols.contains("utm_source"), cols.contains("utm_campaign"))
|
||||
};
|
||||
|
||||
let (tx, rx) =
|
||||
tokio::sync::mpsc::channel::<Result<axum::body::Bytes, std::convert::Infallible>>(32);
|
||||
let analytics_db = user_dbs.analytics.clone();
|
||||
let target_id = id.clone();
|
||||
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let conn = analytics_db.lock().unwrap();
|
||||
|
||||
let mut header = "Timestamp,IP Address,Country,Referrer,Browser,User-Agent".to_string();
|
||||
if has_utm_source {
|
||||
header.push_str(",UTM Source");
|
||||
}
|
||||
if has_utm_campaign {
|
||||
header.push_str(",UTM Campaign");
|
||||
}
|
||||
header.push('\n');
|
||||
|
||||
if tx
|
||||
.blocking_send(Ok(axum::body::Bytes::from(header)))
|
||||
.is_err()
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
let select_fields = if has_utm_source && has_utm_campaign {
|
||||
"timestamp, ip_address, country, referer, user_agent, utm_source, utm_campaign"
|
||||
} else if has_utm_source {
|
||||
"timestamp, ip_address, country, referer, user_agent, utm_source"
|
||||
} else if has_utm_campaign {
|
||||
"timestamp, ip_address, country, referer, user_agent, utm_campaign"
|
||||
} else {
|
||||
"timestamp, ip_address, country, referer, user_agent"
|
||||
};
|
||||
|
||||
let mut sql = format!(
|
||||
"SELECT {} FROM visits WHERE target_type = ?1 AND target_id = ?2",
|
||||
select_fields
|
||||
);
|
||||
let mut params: Vec<Box<dyn rusqlite::ToSql>> =
|
||||
vec![Box::new(target_type.to_string()), Box::new(target_id)];
|
||||
|
||||
if let Some(df) = clean_date_from.as_deref() {
|
||||
sql.push_str(&format!(" AND timestamp >= ?{}", params.len() + 1));
|
||||
params.push(Box::new(format!("{}T00:00:00Z", df)));
|
||||
}
|
||||
|
||||
if let Some(dt) = clean_date_to.as_deref() {
|
||||
if let Ok(parsed_date) = chrono::NaiveDate::parse_from_str(dt, "%Y-%m-%d") {
|
||||
let next_day = parsed_date + chrono::Duration::days(1);
|
||||
sql.push_str(&format!(" AND timestamp < ?{}", params.len() + 1));
|
||||
params.push(Box::new(format!(
|
||||
"{}T00:00:00Z",
|
||||
next_day.format("%Y-%m-%d")
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
sql.push_str(" ORDER BY timestamp DESC, id DESC");
|
||||
|
||||
let mut stmt = match conn.prepare(&sql) {
|
||||
Ok(s) => s,
|
||||
Err(_) => return,
|
||||
};
|
||||
|
||||
let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect();
|
||||
let mut rows = match stmt.query(rusqlite::params_from_iter(param_refs)) {
|
||||
Ok(r) => r,
|
||||
Err(_) => return,
|
||||
};
|
||||
|
||||
let mut csv_buffer = String::new();
|
||||
|
||||
while let Ok(Some(row)) = rows.next() {
|
||||
let timestamp: String = row.get(0).unwrap_or_default();
|
||||
let ip_address: String = row.get(1).unwrap_or_default();
|
||||
let country: String = row.get(2).unwrap_or_default();
|
||||
let referer: String = row.get(3).unwrap_or_default();
|
||||
let user_agent: String = row.get(4).unwrap_or_default();
|
||||
|
||||
let (browser, _, _) = parse_ua(&user_agent);
|
||||
let referrer = clean_referrer(&referer);
|
||||
let country_display = if country.is_empty() {
|
||||
"Unknown".to_string()
|
||||
} else {
|
||||
country
|
||||
};
|
||||
|
||||
let mut line = format!(
|
||||
"{},{},{},{},{},{}",
|
||||
escape_csv_field(×tamp),
|
||||
escape_csv_field(&ip_address),
|
||||
escape_csv_field(&country_display),
|
||||
escape_csv_field(&referrer),
|
||||
escape_csv_field(&browser),
|
||||
escape_csv_field(&user_agent)
|
||||
);
|
||||
|
||||
let mut col_idx = 5;
|
||||
if has_utm_source {
|
||||
let utm_src: String = row.get(col_idx).unwrap_or_default();
|
||||
line.push_str(&format!(",{}", escape_csv_field(&utm_src)));
|
||||
col_idx += 1;
|
||||
}
|
||||
if has_utm_campaign {
|
||||
let utm_camp: String = row.get(col_idx).unwrap_or_default();
|
||||
line.push_str(&format!(",{}", escape_csv_field(&utm_camp)));
|
||||
}
|
||||
line.push('\n');
|
||||
|
||||
csv_buffer.push_str(&line);
|
||||
if csv_buffer.len() >= 8192 {
|
||||
let bytes = axum::body::Bytes::from(csv_buffer);
|
||||
if tx.blocking_send(Ok(bytes)).is_err() {
|
||||
return;
|
||||
}
|
||||
csv_buffer = String::new();
|
||||
}
|
||||
}
|
||||
|
||||
if !csv_buffer.is_empty() {
|
||||
let _ = tx.blocking_send(Ok(axum::body::Bytes::from(csv_buffer)));
|
||||
}
|
||||
});
|
||||
|
||||
let stream = DbExportStream { receiver: rx };
|
||||
let filename = if target_type == "url" {
|
||||
format!("url_{}_analytics.csv", id)
|
||||
} else {
|
||||
format!("page_{}_analytics.csv", id)
|
||||
};
|
||||
|
||||
(
|
||||
StatusCode::OK,
|
||||
[
|
||||
("Content-Type", "text/csv"),
|
||||
(
|
||||
"Content-Disposition",
|
||||
&format!("attachment; filename=\"{}\"", filename),
|
||||
),
|
||||
("X-BZOD-Export-Records", &count.to_string()),
|
||||
],
|
||||
axum::body::Body::from_stream(stream),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
pub(crate) async fn perform_user_json_export(
|
||||
user_dbs: crate::state::UserDbs,
|
||||
target_type: &'static str,
|
||||
id: String,
|
||||
date_from: Option<String>,
|
||||
date_to: Option<String>,
|
||||
) -> Response {
|
||||
let (clean_date_from, clean_date_to) =
|
||||
match validate_date_filters(date_from.as_deref(), date_to.as_deref()) {
|
||||
Ok(res) => res,
|
||||
Err(status) => return status.into_response(),
|
||||
};
|
||||
|
||||
let target_exists = {
|
||||
let conn = user_dbs.content.lock().unwrap();
|
||||
if target_type == "url" {
|
||||
get_url_by_id(&conn, &id)
|
||||
.map(|u| u.is_some())
|
||||
.unwrap_or(false)
|
||||
} else {
|
||||
get_landing_page_by_id(&conn, &id)
|
||||
.map(|p| p.is_some())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
};
|
||||
if !target_exists {
|
||||
return (StatusCode::NOT_FOUND, "Target not found").into_response();
|
||||
}
|
||||
|
||||
let count = {
|
||||
let conn = user_dbs.analytics.lock().unwrap();
|
||||
get_target_visit_total_filtered(
|
||||
&conn,
|
||||
target_type,
|
||||
&id,
|
||||
clean_date_from.as_deref(),
|
||||
clean_date_to.as_deref(),
|
||||
)
|
||||
.unwrap_or(0)
|
||||
};
|
||||
|
||||
if count > MAX_JSON_EXPORT_ROWS as i64 {
|
||||
return StatusCode::PAYLOAD_TOO_LARGE.into_response();
|
||||
}
|
||||
|
||||
let visits_raw = {
|
||||
let conn = user_dbs.analytics.lock().unwrap();
|
||||
match get_target_visits_all_in_memory(
|
||||
&conn,
|
||||
target_type,
|
||||
&id,
|
||||
clean_date_from.as_deref(),
|
||||
clean_date_to.as_deref(),
|
||||
) {
|
||||
Ok(v) => v,
|
||||
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
|
||||
}
|
||||
};
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
struct JsonExportRow {
|
||||
timestamp: String,
|
||||
ip_address: String,
|
||||
country: String,
|
||||
referrer: String,
|
||||
browser: String,
|
||||
user_agent: String,
|
||||
}
|
||||
|
||||
let export_rows: Vec<JsonExportRow> = visits_raw
|
||||
.into_iter()
|
||||
.map(|r| {
|
||||
let (browser, _, _) = parse_ua(&r.user_agent);
|
||||
let referrer = clean_referrer(&r.referer);
|
||||
let country_display = if r.country.is_empty() {
|
||||
"Unknown".to_string()
|
||||
} else {
|
||||
r.country
|
||||
};
|
||||
JsonExportRow {
|
||||
timestamp: r.timestamp,
|
||||
ip_address: r.ip_address,
|
||||
country: country_display,
|
||||
referrer,
|
||||
browser,
|
||||
user_agent: r.user_agent,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let body_str = match serde_json::to_string(&export_rows) {
|
||||
Ok(s) => s,
|
||||
Err(_) => {
|
||||
return (StatusCode::INTERNAL_SERVER_ERROR, "Serialization error").into_response()
|
||||
}
|
||||
};
|
||||
|
||||
let filename = if target_type == "url" {
|
||||
format!("url_{}_analytics.json", id)
|
||||
} else {
|
||||
format!("page_{}_analytics.json", id)
|
||||
};
|
||||
|
||||
(
|
||||
StatusCode::OK,
|
||||
[
|
||||
("Content-Type", "application/json"),
|
||||
(
|
||||
"Content-Disposition",
|
||||
&format!("attachment; filename=\"{}\"", filename),
|
||||
),
|
||||
("X-BZOD-Export-Records", &count.to_string()),
|
||||
],
|
||||
body_str,
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
// --- Feature modules ---
|
||||
|
||||
mod auth;
|
||||
pub use auth::*;
|
||||
mod dashboard;
|
||||
pub use dashboard::*;
|
||||
mod urls;
|
||||
pub use urls::*;
|
||||
mod pages;
|
||||
pub use pages::*;
|
||||
mod users;
|
||||
pub use users::*;
|
||||
mod analytics;
|
||||
pub use analytics::*;
|
||||
mod settings;
|
||||
pub use settings::*;
|
||||
mod audit;
|
||||
pub use audit::*;
|
||||
mod sessions;
|
||||
pub use sessions::*;
|
||||
mod quotas;
|
||||
pub use quotas::*;
|
||||
mod health;
|
||||
pub use health::*;
|
||||
mod backups;
|
||||
pub use backups::*;
|
||||
mod api_keys;
|
||||
pub use api_keys::*;
|
||||
mod moderation;
|
||||
pub use moderation::*;
|
||||
@@ -0,0 +1,639 @@
|
||||
use super::*;
|
||||
|
||||
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
|
||||
pub struct GlobalSlugRow {
|
||||
pub slug: String,
|
||||
pub owner_user_id: i64,
|
||||
pub target_type: String,
|
||||
pub target_id: String,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
pub status: String,
|
||||
pub deleted_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
|
||||
pub struct ModerationLogEntry {
|
||||
pub id: String,
|
||||
pub timestamp: String,
|
||||
pub admin_username: String,
|
||||
pub target_user_id: i64,
|
||||
pub target_username: Option<String>,
|
||||
pub resource_type: String,
|
||||
pub resource_identifier: String,
|
||||
pub action: String,
|
||||
pub severity: String,
|
||||
pub reason: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
|
||||
pub struct SlugHistoryRow {
|
||||
pub id: i64,
|
||||
pub slug: String,
|
||||
pub old_owner_user_id: Option<i64>,
|
||||
pub new_owner_user_id: Option<i64>,
|
||||
pub action: String,
|
||||
pub timestamp: String,
|
||||
pub admin_username: Option<String>,
|
||||
}
|
||||
|
||||
// GET /admin/moderation
|
||||
pub async fn moderation_get(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
Query(params): Query<HashMap<String, String>>,
|
||||
) -> Response {
|
||||
let (user, session_id) = match require_auth(&state, &jar).await {
|
||||
Ok(u) => u,
|
||||
Err(redir) => return redir.into_response(),
|
||||
};
|
||||
|
||||
let flagged_items = {
|
||||
let conn = state.system_db.lock().unwrap();
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT slug, owner_user_id, target_type, target_id, created_at, updated_at, status, deleted_at \
|
||||
FROM global_slugs WHERE status = 'flagged' OR status = 'disabled' ORDER BY updated_at DESC;"
|
||||
).unwrap();
|
||||
let rows = stmt
|
||||
.query_map([], |row| {
|
||||
Ok(GlobalSlugRow {
|
||||
slug: row.get(0)?,
|
||||
owner_user_id: row.get(1)?,
|
||||
target_type: row.get(2)?,
|
||||
target_id: row.get(3)?,
|
||||
created_at: row.get(4)?,
|
||||
updated_at: row.get(5)?,
|
||||
status: row.get(6)?,
|
||||
deleted_at: row.get(7)?,
|
||||
})
|
||||
})
|
||||
.unwrap();
|
||||
rows.filter_map(|r| r.ok()).collect()
|
||||
};
|
||||
|
||||
let logs = {
|
||||
let conn = state.system_db.lock().unwrap();
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, timestamp, admin_username, target_user_id, target_username, resource_type, resource_identifier, action, severity, reason \
|
||||
FROM moderation_events ORDER BY timestamp DESC LIMIT 50;"
|
||||
).unwrap();
|
||||
let rows = stmt
|
||||
.query_map([], |row| {
|
||||
Ok(ModerationLogEntry {
|
||||
id: row.get(0)?,
|
||||
timestamp: row.get(1)?,
|
||||
admin_username: row.get(2)?,
|
||||
target_user_id: row.get(3)?,
|
||||
target_username: row.get(4)?,
|
||||
resource_type: row.get(5)?,
|
||||
resource_identifier: row.get(6)?,
|
||||
action: row.get(7)?,
|
||||
severity: row.get(8)?,
|
||||
reason: row.get(9)?,
|
||||
})
|
||||
})
|
||||
.unwrap();
|
||||
rows.filter_map(|r| r.ok()).collect()
|
||||
};
|
||||
|
||||
let csrf_token = generate_csrf_token(&session_id);
|
||||
|
||||
let template = crate::templates::ModerationTemplate {
|
||||
admin_username: user.username,
|
||||
flagged_items,
|
||||
logs,
|
||||
csrf_token,
|
||||
success: params.get("success").cloned(),
|
||||
error: params.get("error").cloned(),
|
||||
};
|
||||
|
||||
template.into_response()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct AdminModerateForm {
|
||||
pub slug: String,
|
||||
pub action: String,
|
||||
pub severity: String,
|
||||
pub reason: String,
|
||||
pub csrf_token: String,
|
||||
}
|
||||
|
||||
// POST /admin/moderation
|
||||
pub async fn moderation_post(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
Form(form): Form<AdminModerateForm>,
|
||||
) -> Response {
|
||||
let (user, session_id) = match require_auth(&state, &jar).await {
|
||||
Ok(u) => u,
|
||||
Err(redir) => return redir.into_response(),
|
||||
};
|
||||
|
||||
if !verify_csrf(&session_id, &form.csrf_token) {
|
||||
return Redirect::to("/admin/moderation?error=Invalid CSRF token").into_response();
|
||||
}
|
||||
|
||||
let action = form.action.trim().to_lowercase();
|
||||
if !["flagged", "disabled", "active", "deleted"].contains(&action.as_str()) {
|
||||
return Redirect::to("/admin/moderation?error=Invalid moderation action").into_response();
|
||||
}
|
||||
|
||||
let (owner_user_id, target_type) = {
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let row_opt: Option<(i64, String)> = system_conn
|
||||
.query_row(
|
||||
"SELECT owner_user_id, target_type FROM global_slugs WHERE slug = ?1;",
|
||||
[&form.slug],
|
||||
|row| Ok((row.get(0)?, row.get(1)?)),
|
||||
)
|
||||
.optional()
|
||||
.unwrap_or(None);
|
||||
|
||||
match row_opt {
|
||||
Some(r) => r,
|
||||
None => return Redirect::to("/admin/moderation?error=Slug not found").into_response(),
|
||||
}
|
||||
};
|
||||
|
||||
let owner_username = {
|
||||
let users_conn = state.users_db.lock().unwrap();
|
||||
crate::db::users::get_user_by_id(&users_conn, owner_user_id)
|
||||
.unwrap_or(None)
|
||||
.map(|u| u.username)
|
||||
};
|
||||
|
||||
{
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let now = Utc::now().to_rfc3339();
|
||||
|
||||
if action == "deleted" {
|
||||
let _ = system_conn.execute("DELETE FROM global_slugs WHERE slug = ?1;", [&form.slug]);
|
||||
} else {
|
||||
let _ = system_conn.execute(
|
||||
"UPDATE global_slugs SET status = ?1, updated_at = ?2 WHERE slug = ?3;",
|
||||
rusqlite::params![action, now, form.slug],
|
||||
);
|
||||
}
|
||||
|
||||
let event_id = Uuid::new_v4().to_string();
|
||||
let _ = system_conn.execute(
|
||||
"INSERT INTO moderation_events (id, timestamp, admin_username, target_user_id, target_username, resource_type, resource_identifier, action, severity, reason)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10);",
|
||||
rusqlite::params![
|
||||
event_id,
|
||||
now,
|
||||
user.username,
|
||||
owner_user_id,
|
||||
owner_username,
|
||||
target_type,
|
||||
form.slug,
|
||||
action,
|
||||
form.severity,
|
||||
form.reason
|
||||
],
|
||||
);
|
||||
|
||||
let _ = crate::db::audit_events::write_audit_event(
|
||||
&system_conn,
|
||||
&user.username,
|
||||
"CONTENT_MODERATION",
|
||||
"slug",
|
||||
&form.slug,
|
||||
Some(&format!("Action: {}, Reason: {}", action, form.reason)),
|
||||
);
|
||||
}
|
||||
|
||||
Redirect::to(&format!(
|
||||
"/admin/moderation?success=Moderation action '{}' applied",
|
||||
action
|
||||
))
|
||||
.into_response()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct SlugsQuery {
|
||||
pub search: Option<String>,
|
||||
pub owner: Option<i64>,
|
||||
pub status: Option<String>,
|
||||
pub success: Option<String>,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
// GET /admin/slugs
|
||||
pub async fn slugs_get(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
Query(query): Query<SlugsQuery>,
|
||||
) -> Response {
|
||||
let (user, session_id) = match require_auth(&state, &jar).await {
|
||||
Ok(u) => u,
|
||||
Err(redir) => return redir.into_response(),
|
||||
};
|
||||
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
|
||||
let mut sql = "SELECT slug, owner_user_id, target_type, target_id, created_at, updated_at, status, deleted_at FROM global_slugs WHERE 1=1".to_string();
|
||||
let mut values = vec![];
|
||||
if let Some(ref s) = query.search {
|
||||
if !s.trim().is_empty() {
|
||||
sql.push_str(" AND slug LIKE ?");
|
||||
values.push(rusqlite::types::Value::Text(format!("%{}%", s.trim())));
|
||||
}
|
||||
}
|
||||
if let Some(o) = query.owner {
|
||||
sql.push_str(" AND owner_user_id = ?");
|
||||
values.push(rusqlite::types::Value::Integer(o));
|
||||
}
|
||||
if let Some(ref st) = query.status {
|
||||
if !st.trim().is_empty() {
|
||||
sql.push_str(" AND status = ?");
|
||||
values.push(rusqlite::types::Value::Text(st.trim().to_string()));
|
||||
}
|
||||
}
|
||||
sql.push_str(" ORDER BY created_at DESC LIMIT 100;");
|
||||
|
||||
let slugs = {
|
||||
let mut stmt = system_conn.prepare(&sql).unwrap();
|
||||
let rows = stmt
|
||||
.query_map(rusqlite::params_from_iter(values.iter()), |row| {
|
||||
Ok(GlobalSlugRow {
|
||||
slug: row.get(0)?,
|
||||
owner_user_id: row.get(1)?,
|
||||
target_type: row.get(2)?,
|
||||
target_id: row.get(3)?,
|
||||
created_at: row.get(4)?,
|
||||
updated_at: row.get(5)?,
|
||||
status: row.get(6)?,
|
||||
deleted_at: row.get(7)?,
|
||||
})
|
||||
})
|
||||
.unwrap();
|
||||
rows.filter_map(|r| r.ok()).collect()
|
||||
};
|
||||
|
||||
let history = {
|
||||
let mut stmt = system_conn.prepare(
|
||||
"SELECT id, slug, old_owner_user_id, new_owner_user_id, action, timestamp, admin_username \
|
||||
FROM slug_history ORDER BY timestamp DESC LIMIT 50;"
|
||||
).unwrap();
|
||||
let rows = stmt
|
||||
.query_map([], |row| {
|
||||
Ok(SlugHistoryRow {
|
||||
id: row.get(0)?,
|
||||
slug: row.get(1)?,
|
||||
old_owner_user_id: row.get(2)?,
|
||||
new_owner_user_id: row.get(3)?,
|
||||
action: row.get(4)?,
|
||||
timestamp: row.get(5)?,
|
||||
admin_username: row.get(6)?,
|
||||
})
|
||||
})
|
||||
.unwrap();
|
||||
rows.filter_map(|r| r.ok()).collect()
|
||||
};
|
||||
|
||||
let csrf_token = generate_csrf_token(&session_id);
|
||||
|
||||
let template = crate::templates::SlugsTemplate {
|
||||
admin_username: user.username,
|
||||
slugs,
|
||||
history,
|
||||
csrf_token,
|
||||
search_filter: query.search,
|
||||
owner_filter: query.owner,
|
||||
status_filter: query.status,
|
||||
success: query.success,
|
||||
error: query.error,
|
||||
};
|
||||
|
||||
template.into_response()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct AdminTransferForm {
|
||||
pub slug: String,
|
||||
pub new_owner_user_id: i64,
|
||||
pub csrf_token: String,
|
||||
}
|
||||
|
||||
// POST /admin/slugs/transfer
|
||||
pub async fn slugs_transfer_post(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
Form(form): Form<AdminTransferForm>,
|
||||
) -> Response {
|
||||
let (user, session_id) = match require_auth(&state, &jar).await {
|
||||
Ok(u) => u,
|
||||
Err(redir) => return redir.into_response(),
|
||||
};
|
||||
|
||||
if !verify_csrf(&session_id, &form.csrf_token) {
|
||||
return Redirect::to("/admin/slugs?error=Invalid CSRF token").into_response();
|
||||
}
|
||||
|
||||
let user_exists = {
|
||||
let conn = state.users_db.lock().unwrap();
|
||||
conn.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM users WHERE id = ?1);",
|
||||
[form.new_owner_user_id],
|
||||
|row| row.get::<_, bool>(0),
|
||||
)
|
||||
.unwrap_or(false)
|
||||
};
|
||||
|
||||
if !user_exists {
|
||||
return Redirect::to("/admin/slugs?error=New owner user ID does not exist").into_response();
|
||||
}
|
||||
|
||||
let (old_owner, target_type, mut new_target_id) =
|
||||
{
|
||||
let conn = state.system_db.lock().unwrap();
|
||||
let row_opt: Option<(i64, String, String)> = conn.query_row(
|
||||
"SELECT owner_user_id, target_type, target_id FROM global_slugs WHERE slug = ?1;",
|
||||
[&form.slug],
|
||||
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?))
|
||||
).optional().unwrap_or(None);
|
||||
match row_opt {
|
||||
Some(r) => r,
|
||||
None => return Redirect::to("/admin/slugs?error=Slug not found").into_response(),
|
||||
}
|
||||
};
|
||||
|
||||
if old_owner == form.new_owner_user_id {
|
||||
return Redirect::to("/admin/slugs?error=Slug is already owned by this user")
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let old_dbs = match state.get_user_dbs(old_owner) {
|
||||
Ok(dbs) => dbs,
|
||||
Err(_) => {
|
||||
return Redirect::to("/admin/slugs?error=Failed to load current owner's database")
|
||||
.into_response()
|
||||
}
|
||||
};
|
||||
let new_dbs = match state.get_user_dbs(form.new_owner_user_id) {
|
||||
Ok(dbs) => dbs,
|
||||
Err(_) => {
|
||||
return Redirect::to("/admin/slugs?error=Failed to load new owner's database")
|
||||
.into_response()
|
||||
}
|
||||
};
|
||||
|
||||
{
|
||||
let old_conn = old_dbs.content.lock().unwrap();
|
||||
let new_conn = new_dbs.content.lock().unwrap();
|
||||
|
||||
if target_type == "url" {
|
||||
let url_opt = match crate::db::content::get_url_by_code(&old_conn, &form.slug) {
|
||||
Ok(u) => u,
|
||||
Err(e) => {
|
||||
return Redirect::to(&format!(
|
||||
"/admin/slugs?error=Failed to retrieve old URL: {}",
|
||||
e
|
||||
))
|
||||
.into_response()
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(url) = url_opt {
|
||||
// Check quota
|
||||
let users_conn = state.users_db.lock().unwrap();
|
||||
let quota_opt =
|
||||
crate::db::users::get_user_quotas(&users_conn, form.new_owner_user_id)
|
||||
.unwrap_or(None);
|
||||
if let Some(quota) = quota_opt {
|
||||
if quota.current_urls >= quota.max_urls {
|
||||
return Redirect::to(
|
||||
"/admin/slugs?error=New owner has exceeded URL quota limit",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
|
||||
// Copy
|
||||
let new_url = match crate::db::content::create_url_extended(
|
||||
&new_conn,
|
||||
&url.code,
|
||||
&url.destination,
|
||||
url.title.as_deref(),
|
||||
url.description.as_deref(),
|
||||
&url.tags,
|
||||
url.expires_at.as_deref(),
|
||||
url.password_hash.as_deref(),
|
||||
url.max_access_count,
|
||||
) {
|
||||
Ok(u) => u,
|
||||
Err(e) => {
|
||||
return Redirect::to(&format!(
|
||||
"/admin/slugs?error=Failed to copy URL record: {}",
|
||||
e
|
||||
))
|
||||
.into_response()
|
||||
}
|
||||
};
|
||||
new_target_id = new_url.id;
|
||||
|
||||
// Delete old
|
||||
let _ = crate::db::content::delete_url(&old_conn, &url.id);
|
||||
}
|
||||
} else if target_type == "page" {
|
||||
let page_opt = match crate::db::content::get_landing_page_by_code(&old_conn, &form.slug)
|
||||
{
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
return Redirect::to(&format!(
|
||||
"/admin/slugs?error=Failed to retrieve old page: {}",
|
||||
e
|
||||
))
|
||||
.into_response()
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(page) = page_opt {
|
||||
// Check quota
|
||||
let users_conn = state.users_db.lock().unwrap();
|
||||
let quota_opt =
|
||||
crate::db::users::get_user_quotas(&users_conn, form.new_owner_user_id)
|
||||
.unwrap_or(None);
|
||||
if let Some(quota) = quota_opt {
|
||||
if quota.current_landings >= quota.max_landings {
|
||||
return Redirect::to(
|
||||
"/admin/slugs?error=New owner has exceeded landing page quota limit",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
|
||||
// Copy
|
||||
let new_page = match crate::db::content::create_landing_page(
|
||||
&new_conn,
|
||||
&page.code,
|
||||
&page.slug,
|
||||
&page.title,
|
||||
&page.html_content,
|
||||
&page.state,
|
||||
) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
return Redirect::to(&format!(
|
||||
"/admin/slugs?error=Failed to copy page record: {}",
|
||||
e
|
||||
))
|
||||
.into_response()
|
||||
}
|
||||
};
|
||||
new_target_id = new_page.id;
|
||||
|
||||
// Delete old
|
||||
let _ = crate::db::content::delete_landing_page(&old_conn, &page.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
let conn = state.system_db.lock().unwrap();
|
||||
let now = Utc::now().to_rfc3339();
|
||||
|
||||
let _ = conn.execute(
|
||||
"UPDATE global_slugs SET owner_user_id = ?1, target_id = ?2, updated_at = ?3 WHERE slug = ?4;",
|
||||
rusqlite::params![form.new_owner_user_id, new_target_id, now, form.slug],
|
||||
);
|
||||
|
||||
let _ = conn.execute(
|
||||
"INSERT INTO slug_history (slug, old_owner_user_id, new_owner_user_id, action, timestamp, admin_username) \
|
||||
VALUES (?1, ?2, ?3, 'transferred', ?4, ?5);",
|
||||
rusqlite::params![form.slug, old_owner, form.new_owner_user_id, now, user.username],
|
||||
);
|
||||
|
||||
let _ = crate::db::audit_events::write_audit_event(
|
||||
&conn,
|
||||
&user.username,
|
||||
"SLUG_TRANSFER",
|
||||
"slug",
|
||||
&form.slug,
|
||||
Some(&format!(
|
||||
"Transferred from {} to {}",
|
||||
old_owner, form.new_owner_user_id
|
||||
)),
|
||||
);
|
||||
}
|
||||
|
||||
Redirect::to(&format!(
|
||||
"/admin/slugs?success=Slug /{} successfully transferred to user {}",
|
||||
form.slug, form.new_owner_user_id
|
||||
))
|
||||
.into_response()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct AdminSlugStatusForm {
|
||||
pub slug: String,
|
||||
pub status: String,
|
||||
pub csrf_token: String,
|
||||
}
|
||||
|
||||
// POST /admin/slugs/status
|
||||
pub async fn slugs_status_post(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
Form(form): Form<AdminSlugStatusForm>,
|
||||
) -> Response {
|
||||
let (user, session_id) = match require_auth(&state, &jar).await {
|
||||
Ok(u) => u,
|
||||
Err(redir) => return redir.into_response(),
|
||||
};
|
||||
|
||||
if !verify_csrf(&session_id, &form.csrf_token) {
|
||||
return Redirect::to("/admin/slugs?error=Invalid CSRF token").into_response();
|
||||
}
|
||||
|
||||
let status = form.status.trim().to_lowercase();
|
||||
if !["active", "flagged", "disabled"].contains(&status.as_str()) {
|
||||
return Redirect::to("/admin/slugs?error=Invalid status").into_response();
|
||||
}
|
||||
|
||||
{
|
||||
let conn = state.system_db.lock().unwrap();
|
||||
let now = Utc::now().to_rfc3339();
|
||||
|
||||
let _ = conn.execute(
|
||||
"UPDATE global_slugs SET status = ?1, updated_at = ?2 WHERE slug = ?3;",
|
||||
rusqlite::params![status, now, form.slug],
|
||||
);
|
||||
|
||||
let _ = crate::db::audit_events::write_audit_event(
|
||||
&conn,
|
||||
&user.username,
|
||||
"SLUG_STATUS_UPDATE",
|
||||
"slug",
|
||||
&form.slug,
|
||||
Some(&format!("Status set to {}", status)),
|
||||
);
|
||||
}
|
||||
|
||||
Redirect::to(&format!(
|
||||
"/admin/slugs?success=Slug /{} status updated to {}",
|
||||
form.slug, status
|
||||
))
|
||||
.into_response()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct AdminSlugDeleteForm {
|
||||
pub slug: String,
|
||||
pub csrf_token: String,
|
||||
}
|
||||
|
||||
// POST /admin/slugs/delete
|
||||
pub async fn slugs_delete_post(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
Form(form): Form<AdminSlugDeleteForm>,
|
||||
) -> Response {
|
||||
let (user, session_id) = match require_auth(&state, &jar).await {
|
||||
Ok(u) => u,
|
||||
Err(redir) => return redir.into_response(),
|
||||
};
|
||||
|
||||
if !verify_csrf(&session_id, &form.csrf_token) {
|
||||
return Redirect::to("/admin/slugs?error=Invalid CSRF token").into_response();
|
||||
}
|
||||
|
||||
{
|
||||
let conn = state.system_db.lock().unwrap();
|
||||
let now = Utc::now().to_rfc3339();
|
||||
|
||||
let old_owner_user_id: Option<i64> = conn
|
||||
.query_row(
|
||||
"SELECT owner_user_id FROM global_slugs WHERE slug = ?1;",
|
||||
[&form.slug],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.optional()
|
||||
.unwrap_or(None);
|
||||
|
||||
let _ = conn.execute("DELETE FROM global_slugs WHERE slug = ?1;", [&form.slug]);
|
||||
|
||||
let _ = conn.execute(
|
||||
"INSERT INTO slug_history (slug, old_owner_user_id, new_owner_user_id, action, timestamp, admin_username) \
|
||||
VALUES (?1, ?2, NULL, 'deleted', ?3, ?4);",
|
||||
rusqlite::params![form.slug, old_owner_user_id, now, user.username],
|
||||
);
|
||||
|
||||
let _ = crate::db::audit_events::write_audit_event(
|
||||
&conn,
|
||||
&user.username,
|
||||
"SLUG_RELEASE",
|
||||
"slug",
|
||||
&form.slug,
|
||||
Some("Slug released/deleted from global index"),
|
||||
);
|
||||
}
|
||||
|
||||
Redirect::to(&format!(
|
||||
"/admin/slugs?success=Slug /{} released successfully",
|
||||
form.slug
|
||||
))
|
||||
.into_response()
|
||||
}
|
||||
@@ -0,0 +1,484 @@
|
||||
use super::*;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct UserPagesQuery {
|
||||
pub error: Option<String>,
|
||||
pub page: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct CreateUserPageForm {
|
||||
pub title: String,
|
||||
pub slug: String,
|
||||
pub code: String,
|
||||
pub custom_slug: String,
|
||||
pub state: String,
|
||||
pub html_content: String,
|
||||
pub csrf_token: String,
|
||||
}
|
||||
|
||||
pub async fn user_pages_get(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
Query(query): Query<UserPagesQuery>,
|
||||
) -> Response {
|
||||
let (user, session_id) = match require_user_auth(&state, &jar).await {
|
||||
Ok(u) => u,
|
||||
Err(redir) => return redir.into_response(),
|
||||
};
|
||||
|
||||
let user_dbs = match state.get_user_dbs(user.id) {
|
||||
Ok(dbs) => dbs,
|
||||
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
|
||||
};
|
||||
|
||||
let (pages, total_pages, page, visible_pages) = {
|
||||
let conn = user_dbs.content.lock().unwrap();
|
||||
let total_records = get_landing_page_count(&conn).unwrap_or(0);
|
||||
let calculated_total_pages = (total_records as usize).div_ceil(PAGE_SIZE);
|
||||
let total_pages = std::cmp::max(1, calculated_total_pages);
|
||||
let requested_page = query.page.unwrap_or(1);
|
||||
let current_page = if requested_page == 0 {
|
||||
1
|
||||
} else {
|
||||
requested_page
|
||||
}
|
||||
.clamp(1, total_pages);
|
||||
let offset = (current_page - 1) * PAGE_SIZE;
|
||||
|
||||
let pages = list_landing_pages(&conn, PAGE_SIZE as i64, offset as i64).unwrap_or_default();
|
||||
let start_page = current_page.saturating_sub(3).max(1);
|
||||
let end_page = std::cmp::min(total_pages, current_page + 3);
|
||||
let visible_pages: Vec<usize> = (start_page..=end_page).collect();
|
||||
(pages, total_pages, current_page, visible_pages)
|
||||
};
|
||||
|
||||
let csrf_token = generate_csrf_token(&session_id);
|
||||
|
||||
let template = crate::templates::UserPagesTemplate {
|
||||
admin_username: user.username.clone(),
|
||||
username: user.username,
|
||||
pages,
|
||||
csrf_token,
|
||||
error: query.error,
|
||||
current_page: page,
|
||||
total_pages,
|
||||
visible_pages,
|
||||
};
|
||||
|
||||
template.into_response()
|
||||
}
|
||||
|
||||
pub async fn user_pages_create(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
headers: HeaderMap,
|
||||
connect_info: Option<ConnectInfo<SocketAddr>>,
|
||||
Form(form): Form<CreateUserPageForm>,
|
||||
) -> Response {
|
||||
let (user, session_id) = match require_user_auth(&state, &jar).await {
|
||||
Ok(u) => u,
|
||||
Err(redir) => return redir.into_response(),
|
||||
};
|
||||
|
||||
if !verify_csrf(&session_id, &form.csrf_token) {
|
||||
return Redirect::to("/user/pages?error=Invalid CSRF token").into_response();
|
||||
}
|
||||
|
||||
let user_dbs = match state.get_user_dbs(user.id) {
|
||||
Ok(dbs) => dbs,
|
||||
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
|
||||
};
|
||||
|
||||
let mut code = form.custom_slug.trim().to_lowercase();
|
||||
if code.is_empty() {
|
||||
code = form.code.trim().to_lowercase();
|
||||
if code.is_empty() {
|
||||
code = generate_token(2);
|
||||
} else if code.len() != 4 || !code.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
return Redirect::to("/user/pages?error=Custom code must be exactly 4 hex characters")
|
||||
.into_response();
|
||||
}
|
||||
} else if !crate::utils::validation::validate_custom_slug(&code) {
|
||||
return Redirect::to(
|
||||
"/user/pages?error=Custom slug must start with ! followed by 1-24 a-z, 0-9, -, _",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let clean_slug = form.slug.trim().to_lowercase();
|
||||
if clean_slug.is_empty() {
|
||||
return Redirect::to("/user/pages?error=Slug is required").into_response();
|
||||
}
|
||||
|
||||
{
|
||||
let users_conn = state.users_db.lock().unwrap();
|
||||
if !crate::db::users::check_quota_limit(&users_conn, user.id, "landings").unwrap_or(false) {
|
||||
return Redirect::to("/user/pages?error=Quota limit exceeded").into_response();
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
if !crate::db::users::is_slug_available(&system_conn, &code).unwrap_or(false) {
|
||||
return Redirect::to("/user/pages?error=Short code/slug already exists")
|
||||
.into_response();
|
||||
}
|
||||
if let Err(e) = crate::db::users::register_global_slug(
|
||||
&system_conn,
|
||||
&code,
|
||||
user.id,
|
||||
"page",
|
||||
"",
|
||||
"reserving",
|
||||
) {
|
||||
return Redirect::to(&format!("/user/pages?error=Failed to reserve slug: {}", e))
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
|
||||
let res = {
|
||||
let conn = user_dbs.content.lock().unwrap();
|
||||
create_landing_page(
|
||||
&conn,
|
||||
&code,
|
||||
&clean_slug,
|
||||
&form.title,
|
||||
&form.html_content,
|
||||
&form.state,
|
||||
)
|
||||
};
|
||||
|
||||
match res {
|
||||
Ok(page) => {
|
||||
{
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let global_status = if form.state == "published" {
|
||||
"active"
|
||||
} else {
|
||||
"disabled"
|
||||
};
|
||||
let _ = system_conn.execute(
|
||||
"UPDATE global_slugs SET target_id = ?1, status = ?2, updated_at = ?3 WHERE slug = ?4;",
|
||||
rusqlite::params![page.id, global_status, chrono::Utc::now().to_rfc3339(), code],
|
||||
);
|
||||
}
|
||||
{
|
||||
let users_conn = state.users_db.lock().unwrap();
|
||||
let _ = crate::db::users::increment_quota_counter(&users_conn, user.id, "landings");
|
||||
}
|
||||
let ip = get_client_ip(&headers, connect_info);
|
||||
let _ = write_audit_log(
|
||||
&state.admin_db.lock().unwrap(),
|
||||
&state,
|
||||
&user.username,
|
||||
"USER_PAGE_CREATION",
|
||||
Some("page"),
|
||||
Some(&page.id),
|
||||
Some(&ip),
|
||||
headers.get("user-agent").and_then(|h| h.to_str().ok()),
|
||||
);
|
||||
Redirect::to("/user/pages").into_response()
|
||||
}
|
||||
Err(e) => {
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let _ = crate::db::users::release_global_slug(&system_conn, &code, user.id);
|
||||
Redirect::to(&format!("/user/pages?error=Database error: {}", e)).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn user_pages_delete(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
headers: HeaderMap,
|
||||
connect_info: Option<ConnectInfo<SocketAddr>>,
|
||||
Path(id): Path<String>,
|
||||
Form(form): Form<HashMap<String, String>>,
|
||||
) -> Response {
|
||||
let (user, session_id) = match require_user_auth(&state, &jar).await {
|
||||
Ok(u) => u,
|
||||
Err(redir) => return redir.into_response(),
|
||||
};
|
||||
|
||||
let user_dbs = match state.get_user_dbs(user.id) {
|
||||
Ok(dbs) => dbs,
|
||||
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
|
||||
};
|
||||
|
||||
let csrf_token = form.get("csrf_token").cloned().unwrap_or_default();
|
||||
if !verify_csrf(&session_id, &csrf_token) {
|
||||
return Redirect::to("/user/pages?error=Invalid CSRF token").into_response();
|
||||
}
|
||||
|
||||
let conn = user_dbs.content.lock().unwrap();
|
||||
match get_landing_page_by_id(&conn, &id) {
|
||||
Ok(Some(page)) => {
|
||||
let _ = crate::db::users::decrement_quota_counter(
|
||||
&state.users_db.lock().unwrap(),
|
||||
user.id,
|
||||
"landings",
|
||||
);
|
||||
match delete_landing_page(&conn, &id) {
|
||||
Ok(_) => {
|
||||
let _ = crate::db::users::release_global_slug(
|
||||
&state.system_db.lock().unwrap(),
|
||||
&page.code,
|
||||
user.id,
|
||||
);
|
||||
let ip = get_client_ip(&headers, connect_info);
|
||||
let _ = write_audit_log(
|
||||
&state.admin_db.lock().unwrap(),
|
||||
&state,
|
||||
&user.username,
|
||||
"USER_PAGE_DELETION",
|
||||
Some("page"),
|
||||
Some(&id),
|
||||
Some(&ip),
|
||||
headers.get("user-agent").and_then(|h| h.to_str().ok()),
|
||||
);
|
||||
Redirect::to("/user/pages").into_response()
|
||||
}
|
||||
Err(e) => Redirect::to(&format!("/user/pages?error=Failed to delete page: {}", e))
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
_ => Redirect::to("/user/pages?error=Page not found").into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
// GET /admin/pages
|
||||
#[derive(Deserialize)]
|
||||
pub struct PagesQuery {
|
||||
pub error: Option<String>,
|
||||
pub page: Option<usize>,
|
||||
}
|
||||
|
||||
pub async fn pages_get(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
Query(query): Query<PagesQuery>,
|
||||
) -> Response {
|
||||
let (user, session_id) = match require_auth(&state, &jar).await {
|
||||
Ok(u) => u,
|
||||
Err(redir) => return redir.into_response(),
|
||||
};
|
||||
|
||||
let (pages, total_pages, page, visible_pages) = {
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
let total_records = get_landing_page_count(&conn).unwrap_or(0);
|
||||
let calculated_total_pages = (total_records as usize).div_ceil(PAGE_SIZE);
|
||||
let total_pages = std::cmp::max(1, calculated_total_pages);
|
||||
let requested_page = query.page.unwrap_or(1);
|
||||
let current_page = if requested_page == 0 {
|
||||
1
|
||||
} else {
|
||||
requested_page
|
||||
}
|
||||
.clamp(1, total_pages);
|
||||
let offset = (current_page - 1) * PAGE_SIZE;
|
||||
|
||||
let pages = list_landing_pages(&conn, PAGE_SIZE as i64, offset as i64).unwrap_or_default();
|
||||
|
||||
let start_page = current_page.saturating_sub(3).max(1);
|
||||
let end_page = std::cmp::min(total_pages, current_page + 3);
|
||||
let visible_pages: Vec<usize> = (start_page..=end_page).collect();
|
||||
|
||||
(pages, total_pages, current_page, visible_pages)
|
||||
};
|
||||
|
||||
let csrf_token = generate_csrf_token(&session_id);
|
||||
|
||||
let template = crate::templates::PagesTemplate {
|
||||
admin_username: user.username,
|
||||
pages,
|
||||
csrf_token,
|
||||
error: query.error,
|
||||
current_page: page,
|
||||
total_pages,
|
||||
visible_pages,
|
||||
};
|
||||
|
||||
template.into_response()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct CreatePageForm {
|
||||
pub title: String,
|
||||
pub slug: String,
|
||||
pub code: String,
|
||||
pub custom_slug: String,
|
||||
pub state: String,
|
||||
pub html_content: String,
|
||||
pub csrf_token: String,
|
||||
}
|
||||
|
||||
// POST /admin/pages/create
|
||||
pub async fn pages_create(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
headers: HeaderMap,
|
||||
connect_info: Option<ConnectInfo<SocketAddr>>,
|
||||
Form(form): Form<CreatePageForm>,
|
||||
) -> Response {
|
||||
let (user, session_id) = match require_auth(&state, &jar).await {
|
||||
Ok(u) => u,
|
||||
Err(redir) => return redir.into_response(),
|
||||
};
|
||||
|
||||
if !verify_csrf(&session_id, &form.csrf_token) {
|
||||
return Redirect::to("/admin/pages?error=Invalid CSRF token").into_response();
|
||||
}
|
||||
|
||||
let ip = get_client_ip(&headers, connect_info);
|
||||
let admin_user_id = user.id.parse::<i64>().unwrap_or(1);
|
||||
|
||||
// Custom Slug takes priority if provided
|
||||
let mut code = form.custom_slug.trim().to_lowercase();
|
||||
if code.is_empty() {
|
||||
code = form.code.trim().to_lowercase();
|
||||
if code.is_empty() {
|
||||
code = generate_token(2);
|
||||
} else {
|
||||
if code.len() != 4 || !code.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
return Redirect::to(
|
||||
"/admin/pages?error=Custom code must be exactly 4 hex characters",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if !crate::utils::validation::validate_custom_slug(&code) {
|
||||
return Redirect::to("/admin/pages?error=Custom slug must start with ! followed by 1-24 characters of a-z, 0-9, -, _")
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
|
||||
let clean_slug = form.slug.trim().to_lowercase();
|
||||
if clean_slug.is_empty() {
|
||||
return Redirect::to("/admin/pages?error=Slug is required").into_response();
|
||||
}
|
||||
|
||||
{
|
||||
let users_conn = state.users_db.lock().unwrap();
|
||||
if !crate::db::users::check_quota_limit(&users_conn, admin_user_id, "landings")
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return Redirect::to("/admin/pages?error=Quota limit exceeded").into_response();
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
if !crate::db::users::is_slug_available(&system_conn, &code).unwrap_or(false) {
|
||||
return Redirect::to("/admin/pages?error=Short code already exists").into_response();
|
||||
}
|
||||
// Always use owner_user_id = 1 for admin content so it resolves via state.content_db
|
||||
if let Err(e) =
|
||||
crate::db::users::register_global_slug(&system_conn, &code, 1, "page", "", "reserving")
|
||||
{
|
||||
return Redirect::to(&format!("/admin/pages?error=Failed to reserve slug: {}", e))
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
|
||||
let res = {
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
create_landing_page(
|
||||
&conn,
|
||||
&code,
|
||||
&clean_slug,
|
||||
&form.title,
|
||||
&form.html_content,
|
||||
&form.state,
|
||||
)
|
||||
};
|
||||
|
||||
match res {
|
||||
Ok(page) => {
|
||||
{
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let global_status = if form.state == "published" {
|
||||
"active"
|
||||
} else {
|
||||
"disabled"
|
||||
};
|
||||
let _ = system_conn.execute(
|
||||
"UPDATE global_slugs SET target_id = ?1, status = ?2, updated_at = ?3 WHERE slug = ?4;",
|
||||
rusqlite::params![page.id, global_status, chrono::Utc::now().to_rfc3339(), code],
|
||||
);
|
||||
}
|
||||
{
|
||||
let users_conn = state.users_db.lock().unwrap();
|
||||
let _ = crate::db::users::increment_quota_counter(
|
||||
&users_conn,
|
||||
admin_user_id,
|
||||
"landings",
|
||||
);
|
||||
}
|
||||
{
|
||||
let conn_admin = state.admin_db.lock().unwrap();
|
||||
let _ = write_audit_log(
|
||||
&conn_admin,
|
||||
&state,
|
||||
&user.username,
|
||||
"PAGE_CREATION",
|
||||
Some("page"),
|
||||
Some(&page.id),
|
||||
Some(&ip),
|
||||
headers.get("user-agent").and_then(|h| h.to_str().ok()),
|
||||
);
|
||||
}
|
||||
Redirect::to("/admin/pages").into_response()
|
||||
}
|
||||
Err(e) => {
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let _ = crate::db::users::release_global_slug(&system_conn, &code, 1);
|
||||
Redirect::to(&format!("/admin/pages?error=Database error: {}", e)).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// POST /admin/pages/delete/:id
|
||||
pub async fn pages_delete(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
headers: HeaderMap,
|
||||
connect_info: Option<ConnectInfo<SocketAddr>>,
|
||||
Path(id): Path<String>,
|
||||
Form(form): Form<std::collections::HashMap<String, String>>,
|
||||
) -> Response {
|
||||
let (user, session_id) = match require_auth(&state, &jar).await {
|
||||
Ok(u) => u,
|
||||
Err(redir) => return redir.into_response(),
|
||||
};
|
||||
|
||||
let csrf_token = form.get("csrf_token").cloned().unwrap_or_default();
|
||||
if !verify_csrf(&session_id, &csrf_token) {
|
||||
return Redirect::to("/admin/pages?error=Invalid CSRF token").into_response();
|
||||
}
|
||||
|
||||
let ip = get_client_ip(&headers, connect_info);
|
||||
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
match delete_landing_page(&conn, &id) {
|
||||
Ok(_) => {
|
||||
{
|
||||
let conn_admin = state.admin_db.lock().unwrap();
|
||||
let _ = write_audit_log(
|
||||
&conn_admin,
|
||||
&state,
|
||||
&user.username,
|
||||
"PAGE_DELETION",
|
||||
Some("page"),
|
||||
Some(&id),
|
||||
Some(&ip),
|
||||
headers.get("user-agent").and_then(|h| h.to_str().ok()),
|
||||
);
|
||||
}
|
||||
Redirect::to("/admin/pages").into_response()
|
||||
}
|
||||
Err(e) => Redirect::to(&format!("/admin/pages?error=Failed to delete page: {}", e))
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
use super::*;
|
||||
|
||||
// GET /admin/quotas
|
||||
pub async fn quotas_get(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
Query(params): Query<HashMap<String, String>>,
|
||||
) -> Response {
|
||||
let (user, session_id) = match require_auth(&state, &jar).await {
|
||||
Ok(u) => u,
|
||||
Err(redir) => return redir.into_response(),
|
||||
};
|
||||
|
||||
let quotas = {
|
||||
let conn = state.users_db.lock().unwrap();
|
||||
let mut stmt = conn.prepare("SELECT user_id, max_urls, max_landings, max_api_tokens, max_storage_mb, current_urls, current_landings, current_api_tokens, current_storage_mb FROM quotas ORDER BY user_id ASC;").unwrap();
|
||||
let rows = stmt
|
||||
.query_map([], |row| {
|
||||
Ok(crate::models::UserQuotas {
|
||||
user_id: row.get(0)?,
|
||||
max_urls: row.get(1)?,
|
||||
max_landings: row.get(2)?,
|
||||
max_api_tokens: row.get(3)?,
|
||||
max_storage_mb: row.get(4)?,
|
||||
current_urls: row.get(5)?,
|
||||
current_landings: row.get(6)?,
|
||||
current_api_tokens: row.get(7)?,
|
||||
current_storage_mb: row.get(8)?,
|
||||
})
|
||||
})
|
||||
.unwrap();
|
||||
rows.filter_map(|r| r.ok()).collect()
|
||||
};
|
||||
|
||||
let csrf_token = generate_csrf_token(&session_id);
|
||||
|
||||
let template = crate::templates::QuotasTemplate {
|
||||
admin_username: user.username,
|
||||
quotas,
|
||||
csrf_token,
|
||||
success: params.get("success").cloned(),
|
||||
error: params.get("error").cloned(),
|
||||
};
|
||||
|
||||
template.into_response()
|
||||
}
|
||||
|
||||
// POST /admin/quotas
|
||||
pub async fn quotas_post(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
Form(form): Form<HashMap<String, String>>,
|
||||
) -> Response {
|
||||
let (user, session_id) = match require_auth(&state, &jar).await {
|
||||
Ok(u) => u,
|
||||
Err(redir) => return redir.into_response(),
|
||||
};
|
||||
|
||||
let form_csrf = form.get("csrf_token").cloned().unwrap_or_default();
|
||||
if !verify_csrf(&session_id, &form_csrf) {
|
||||
return Redirect::to("/admin/quotas?error=Invalid CSRF token").into_response();
|
||||
}
|
||||
|
||||
let action = form.get("action").cloned().unwrap_or_default();
|
||||
let users_conn = state.users_db.lock().unwrap();
|
||||
|
||||
if action == "reconcile_all" {
|
||||
let user_ids: Vec<i64> = {
|
||||
let mut stmt = users_conn.prepare("SELECT id FROM users;").unwrap();
|
||||
let rows = stmt.query_map([], |row| row.get(0)).unwrap();
|
||||
rows.filter_map(|r| r.ok()).collect()
|
||||
};
|
||||
|
||||
for uid in user_ids {
|
||||
if let Ok(user_dbs) = state.get_user_dbs(uid) {
|
||||
let user_content_conn = user_dbs.content.lock().unwrap();
|
||||
let _ =
|
||||
crate::db::users::reconcile_user_quotas(&users_conn, uid, &user_content_conn);
|
||||
}
|
||||
}
|
||||
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let _ = crate::db::audit_events::write_audit_event(
|
||||
&system_conn,
|
||||
&user.username,
|
||||
"QUOTAS_RECONCILE_ALL",
|
||||
"quota",
|
||||
"all",
|
||||
None,
|
||||
);
|
||||
|
||||
Redirect::to("/admin/quotas?success=All user quotas reconciled successfully")
|
||||
.into_response()
|
||||
} else if action == "reconcile" {
|
||||
let uid_str = form.get("user_id").cloned().unwrap_or_default();
|
||||
let uid = uid_str.parse::<i64>().unwrap_or(0);
|
||||
if let Ok(user_dbs) = state.get_user_dbs(uid) {
|
||||
let user_content_conn = user_dbs.content.lock().unwrap();
|
||||
let _ = crate::db::users::reconcile_user_quotas(&users_conn, uid, &user_content_conn);
|
||||
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let _ = crate::db::audit_events::write_audit_event(
|
||||
&system_conn,
|
||||
&user.username,
|
||||
"QUOTAS_RECONCILE",
|
||||
"quota",
|
||||
&uid.to_string(),
|
||||
None,
|
||||
);
|
||||
Redirect::to(&format!("/admin/users/{}?success=Quotas reconciled", uid)).into_response()
|
||||
} else {
|
||||
Redirect::to("/admin/quotas?error=User databases not found").into_response()
|
||||
}
|
||||
} else {
|
||||
Redirect::to("/admin/quotas?error=Invalid action").into_response()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
use super::*;
|
||||
|
||||
// GET /admin/sessions
|
||||
pub async fn sessions_get(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
Query(params): Query<HashMap<String, String>>,
|
||||
) -> Response {
|
||||
let (user, session_id) = match require_auth(&state, &jar).await {
|
||||
Ok(u) => u,
|
||||
Err(redir) => return redir.into_response(),
|
||||
};
|
||||
|
||||
let sessions = {
|
||||
let conn = state.users_db.lock().unwrap();
|
||||
let mut stmt = conn.prepare("SELECT id, user_id, expires_at, created_at FROM sessions ORDER BY created_at DESC;").unwrap();
|
||||
let rows = stmt
|
||||
.query_map([], |row| {
|
||||
Ok(crate::models::UserSession {
|
||||
id: row.get(0)?,
|
||||
user_id: row.get(1)?,
|
||||
expires_at: row.get(2)?,
|
||||
created_at: row.get(3)?,
|
||||
})
|
||||
})
|
||||
.unwrap();
|
||||
rows.filter_map(|r| r.ok()).collect()
|
||||
};
|
||||
|
||||
let csrf_token = generate_csrf_token(&session_id);
|
||||
|
||||
let template = crate::templates::SessionsTemplate {
|
||||
admin_username: user.username,
|
||||
sessions,
|
||||
csrf_token,
|
||||
success: params.get("success").cloned(),
|
||||
error: params.get("error").cloned(),
|
||||
};
|
||||
|
||||
template.into_response()
|
||||
}
|
||||
|
||||
// POST /admin/sessions/revoke/:id
|
||||
pub async fn sessions_revoke_post(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
Path(id): Path<String>,
|
||||
Form(form): Form<HashMap<String, String>>,
|
||||
) -> Response {
|
||||
let (user, session_id) = match require_auth(&state, &jar).await {
|
||||
Ok(u) => u,
|
||||
Err(redir) => return redir.into_response(),
|
||||
};
|
||||
|
||||
let form_csrf = form.get("csrf_token").cloned().unwrap_or_default();
|
||||
if !verify_csrf(&session_id, &form_csrf) {
|
||||
return Redirect::to("/admin/sessions?error=Invalid CSRF token").into_response();
|
||||
}
|
||||
|
||||
{
|
||||
let conn = state.users_db.lock().unwrap();
|
||||
let _ = conn.execute("DELETE FROM sessions WHERE id = ?1;", [&id]);
|
||||
}
|
||||
|
||||
{
|
||||
let conn_sys = state.system_db.lock().unwrap();
|
||||
let _ = crate::db::audit_events::write_audit_event(
|
||||
&conn_sys,
|
||||
&user.username,
|
||||
"SESSION_REVOKED",
|
||||
"session",
|
||||
&id,
|
||||
None,
|
||||
);
|
||||
}
|
||||
|
||||
Redirect::to("/admin/sessions?success=Session revoked").into_response()
|
||||
}
|
||||
|
||||
// POST /admin/sessions/revoke-all
|
||||
pub async fn sessions_revoke_all_post(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
Form(form): Form<HashMap<String, String>>,
|
||||
) -> Response {
|
||||
let (user, session_id) = match require_auth(&state, &jar).await {
|
||||
Ok(u) => u,
|
||||
Err(redir) => return redir.into_response(),
|
||||
};
|
||||
|
||||
let form_csrf = form.get("csrf_token").cloned().unwrap_or_default();
|
||||
if !verify_csrf(&session_id, &form_csrf) {
|
||||
return Redirect::to("/admin/sessions?error=Invalid CSRF token").into_response();
|
||||
}
|
||||
|
||||
{
|
||||
let conn = state.users_db.lock().unwrap();
|
||||
let _ = conn.execute("DELETE FROM sessions;", []);
|
||||
}
|
||||
|
||||
{
|
||||
let conn_sys = state.system_db.lock().unwrap();
|
||||
let _ = crate::db::audit_events::write_audit_event(
|
||||
&conn_sys,
|
||||
&user.username,
|
||||
"SESSIONS_ALL_REVOKED",
|
||||
"session",
|
||||
"all",
|
||||
None,
|
||||
);
|
||||
}
|
||||
|
||||
Redirect::to("/admin/sessions?success=All active sessions revoked").into_response()
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,639 @@
|
||||
use super::*;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct UserUrlsQuery {
|
||||
pub tag: Option<String>,
|
||||
pub error: Option<String>,
|
||||
pub page: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct CreateUserUrlForm {
|
||||
pub destination: String,
|
||||
#[serde(default)]
|
||||
pub code: String,
|
||||
#[serde(default)]
|
||||
pub custom_slug: String,
|
||||
#[serde(default)]
|
||||
pub title: String,
|
||||
#[serde(default)]
|
||||
pub description: String,
|
||||
#[serde(default)]
|
||||
pub tags: String,
|
||||
pub csrf_token: String,
|
||||
#[serde(default)]
|
||||
pub expires_at: String,
|
||||
#[serde(default)]
|
||||
pub password: String,
|
||||
#[serde(default)]
|
||||
pub max_access_count: String,
|
||||
#[serde(default)]
|
||||
pub utm_source: String,
|
||||
#[serde(default)]
|
||||
pub utm_medium: String,
|
||||
#[serde(default)]
|
||||
pub utm_campaign: String,
|
||||
}
|
||||
|
||||
pub async fn user_urls_get(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
Query(query): Query<UserUrlsQuery>,
|
||||
) -> Response {
|
||||
let (user, session_id) = match require_user_auth(&state, &jar).await {
|
||||
Ok(u) => u,
|
||||
Err(redir) => return redir.into_response(),
|
||||
};
|
||||
|
||||
let user_dbs = match state.get_user_dbs(user.id) {
|
||||
Ok(dbs) => dbs,
|
||||
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
|
||||
};
|
||||
|
||||
let (urls, total_pages, page, visible_pages) = {
|
||||
let conn = user_dbs.content.lock().unwrap();
|
||||
let total_records = if let Some(tag_str) = query.tag.as_deref() {
|
||||
get_url_count_by_tag(&conn, tag_str).unwrap_or(0)
|
||||
} else {
|
||||
get_url_counts(&conn).map(|(t, _, _)| t).unwrap_or(0)
|
||||
};
|
||||
let calculated_total_pages = (total_records as usize).div_ceil(PAGE_SIZE);
|
||||
let total_pages = std::cmp::max(1, calculated_total_pages);
|
||||
let requested_page = query.page.unwrap_or(1);
|
||||
let current_page = if requested_page == 0 {
|
||||
1
|
||||
} else {
|
||||
requested_page
|
||||
}
|
||||
.clamp(1, total_pages);
|
||||
let offset = (current_page - 1) * PAGE_SIZE;
|
||||
|
||||
let urls = list_urls(&conn, PAGE_SIZE as i64, offset as i64, query.tag.as_deref())
|
||||
.unwrap_or_default();
|
||||
|
||||
let start_page = current_page.saturating_sub(3).max(1);
|
||||
let end_page = std::cmp::min(total_pages, current_page + 3);
|
||||
let visible_pages: Vec<usize> = (start_page..=end_page).collect();
|
||||
|
||||
(urls, total_pages, current_page, visible_pages)
|
||||
};
|
||||
|
||||
let csrf_token = generate_csrf_token(&session_id);
|
||||
|
||||
let proto = if state.config.cookie_secure {
|
||||
"https"
|
||||
} else {
|
||||
"http"
|
||||
};
|
||||
let base_url = state
|
||||
.config
|
||||
.base_url
|
||||
.clone()
|
||||
.unwrap_or_else(|| format!("{}://localhost:{}", proto, state.config.port));
|
||||
|
||||
let template = crate::templates::UserUrlsTemplate {
|
||||
admin_username: user.username.clone(),
|
||||
username: user.username,
|
||||
urls,
|
||||
csrf_token,
|
||||
error: query.error,
|
||||
tag_filter: query.tag,
|
||||
base_url,
|
||||
current_page: page,
|
||||
total_pages,
|
||||
visible_pages,
|
||||
};
|
||||
|
||||
template.into_response()
|
||||
}
|
||||
|
||||
pub async fn user_urls_create(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
headers: HeaderMap,
|
||||
connect_info: Option<ConnectInfo<SocketAddr>>,
|
||||
Form(form): Form<CreateUserUrlForm>,
|
||||
) -> Response {
|
||||
let (user, session_id) = match require_user_auth(&state, &jar).await {
|
||||
Ok(u) => u,
|
||||
Err(redir) => return redir.into_response(),
|
||||
};
|
||||
|
||||
if !verify_csrf(&session_id, &form.csrf_token) {
|
||||
return Redirect::to("/user/urls?error=Invalid CSRF token").into_response();
|
||||
}
|
||||
|
||||
let user_dbs = match state.get_user_dbs(user.id) {
|
||||
Ok(dbs) => dbs,
|
||||
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
|
||||
};
|
||||
|
||||
let ip = get_client_ip(&headers, connect_info);
|
||||
|
||||
let mut code = form.custom_slug.trim().to_lowercase();
|
||||
if code.is_empty() {
|
||||
code = form.code.trim().to_lowercase();
|
||||
if code.is_empty() {
|
||||
code = generate_token(3);
|
||||
} else if code.len() != 6 || !code.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
return Redirect::to("/user/urls?error=Custom code must be exactly 6 hex characters")
|
||||
.into_response();
|
||||
}
|
||||
} else if !crate::utils::validation::validate_custom_slug(&code) {
|
||||
return Redirect::to(
|
||||
"/user/urls?error=Custom slug must start with ! followed by 1-24 a-z, 0-9, -, _",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
{
|
||||
let users_conn = state.users_db.lock().unwrap();
|
||||
if !crate::db::users::check_quota_limit(&users_conn, user.id, "urls").unwrap_or(false) {
|
||||
return Redirect::to("/user/urls?error=Quota limit exceeded").into_response();
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
if !crate::db::users::is_slug_available(&system_conn, &code).unwrap_or(false) {
|
||||
return Redirect::to("/user/urls?error=Short code/slug already exists").into_response();
|
||||
}
|
||||
if let Err(e) = crate::db::users::register_global_slug(
|
||||
&system_conn,
|
||||
&code,
|
||||
user.id,
|
||||
"url",
|
||||
"",
|
||||
"reserving",
|
||||
) {
|
||||
return Redirect::to(&format!("/user/urls?error=Failed to reserve slug: {}", e))
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
|
||||
let dest = match crate::services::urls::prepare_destination(
|
||||
&form.destination,
|
||||
crate::services::urls::UtmParams {
|
||||
source: Some(&form.utm_source),
|
||||
medium: Some(&form.utm_medium),
|
||||
campaign: Some(&form.utm_campaign),
|
||||
},
|
||||
) {
|
||||
Ok(d) => d,
|
||||
Err(msg) => {
|
||||
return Redirect::to(&format!("/user/urls?error={}", msg)).into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let expires_at_opt = crate::services::urls::parse_expires_at_input(&form.expires_at);
|
||||
|
||||
let password_hash_opt = if form.password.trim().is_empty() {
|
||||
None
|
||||
} else {
|
||||
match hash_password(&form.password) {
|
||||
Ok(h) => Some(h),
|
||||
Err(_) => return Redirect::to("/user/urls?error=Hashing error").into_response(),
|
||||
}
|
||||
};
|
||||
|
||||
let max_access_count_opt = if form.max_access_count.trim().is_empty() {
|
||||
None
|
||||
} else {
|
||||
match form.max_access_count.trim().parse::<i64>() {
|
||||
Ok(c) => Some(c),
|
||||
Err(_) => {
|
||||
return Redirect::to("/user/urls?error=Invalid max access count").into_response()
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let tags_list: Vec<String> = form
|
||||
.tags
|
||||
.split(',')
|
||||
.map(|t| t.trim().to_string())
|
||||
.filter(|t| !t.is_empty())
|
||||
.collect();
|
||||
|
||||
let title_opt = if form.title.trim().is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(form.title.trim())
|
||||
};
|
||||
let desc_opt = if form.description.trim().is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(form.description.trim())
|
||||
};
|
||||
|
||||
let res = {
|
||||
let conn = user_dbs.content.lock().unwrap();
|
||||
crate::db::content::create_url_extended(
|
||||
&conn,
|
||||
&code,
|
||||
&dest,
|
||||
title_opt,
|
||||
desc_opt,
|
||||
&tags_list,
|
||||
expires_at_opt.as_deref(),
|
||||
password_hash_opt.as_deref(),
|
||||
max_access_count_opt,
|
||||
)
|
||||
};
|
||||
|
||||
match res {
|
||||
Ok(url) => {
|
||||
{
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let _ = system_conn.execute(
|
||||
"UPDATE global_slugs SET target_id = ?1, status = 'active', updated_at = ?2 WHERE slug = ?3;",
|
||||
rusqlite::params![url.id, chrono::Utc::now().to_rfc3339(), code],
|
||||
);
|
||||
}
|
||||
{
|
||||
let users_conn = state.users_db.lock().unwrap();
|
||||
let _ = crate::db::users::increment_quota_counter(&users_conn, user.id, "urls");
|
||||
}
|
||||
let _ = write_audit_log(
|
||||
&state.admin_db.lock().unwrap(),
|
||||
&state,
|
||||
&user.username,
|
||||
"USER_URL_CREATION",
|
||||
Some("url"),
|
||||
Some(&url.id),
|
||||
Some(&ip),
|
||||
headers.get("user-agent").and_then(|h| h.to_str().ok()),
|
||||
);
|
||||
Redirect::to("/user/urls").into_response()
|
||||
}
|
||||
Err(e) => {
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let _ = crate::db::users::release_global_slug(&system_conn, &code, user.id);
|
||||
Redirect::to(&format!("/user/urls?error=Database error: {}", e)).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn user_urls_delete(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
headers: HeaderMap,
|
||||
connect_info: Option<ConnectInfo<SocketAddr>>,
|
||||
Path(id): Path<String>,
|
||||
Form(form): Form<HashMap<String, String>>,
|
||||
) -> Response {
|
||||
let (user, session_id) = match require_user_auth(&state, &jar).await {
|
||||
Ok(u) => u,
|
||||
Err(redir) => return redir.into_response(),
|
||||
};
|
||||
|
||||
let user_dbs = match state.get_user_dbs(user.id) {
|
||||
Ok(dbs) => dbs,
|
||||
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
|
||||
};
|
||||
|
||||
let csrf_token = form.get("csrf_token").cloned().unwrap_or_default();
|
||||
if !verify_csrf(&session_id, &csrf_token) {
|
||||
return Redirect::to("/user/urls?error=Invalid CSRF token").into_response();
|
||||
}
|
||||
|
||||
let conn = user_dbs.content.lock().unwrap();
|
||||
match get_url_by_id(&conn, &id) {
|
||||
Ok(Some(url)) => {
|
||||
let _ = crate::db::users::decrement_quota_counter(
|
||||
&state.users_db.lock().unwrap(),
|
||||
user.id,
|
||||
"urls",
|
||||
);
|
||||
match delete_url(&conn, &id) {
|
||||
Ok(_) => {
|
||||
let _ = crate::db::users::release_global_slug(
|
||||
&state.system_db.lock().unwrap(),
|
||||
&url.code,
|
||||
user.id,
|
||||
);
|
||||
let ip = get_client_ip(&headers, connect_info);
|
||||
let _ = write_audit_log(
|
||||
&state.admin_db.lock().unwrap(),
|
||||
&state,
|
||||
&user.username,
|
||||
"USER_URL_DELETION",
|
||||
Some("url"),
|
||||
Some(&id),
|
||||
Some(&ip),
|
||||
headers.get("user-agent").and_then(|h| h.to_str().ok()),
|
||||
);
|
||||
Redirect::to("/user/urls").into_response()
|
||||
}
|
||||
Err(e) => Redirect::to(&format!("/user/urls?error=Failed to delete link: {}", e))
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
_ => Redirect::to("/user/urls?error=Link not found").into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
// GET /admin/urls
|
||||
#[derive(Deserialize)]
|
||||
pub struct UrlsQuery {
|
||||
pub tag: Option<String>,
|
||||
pub error: Option<String>,
|
||||
pub page: Option<usize>,
|
||||
}
|
||||
|
||||
pub async fn urls_get(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
Query(query): Query<UrlsQuery>,
|
||||
) -> Response {
|
||||
let (user, session_id) = match require_auth(&state, &jar).await {
|
||||
Ok(u) => u,
|
||||
Err(redir) => return redir.into_response(),
|
||||
};
|
||||
|
||||
let (urls, total_pages, page, visible_pages) = {
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
let total_records = if let Some(tag_str) = query.tag.as_deref() {
|
||||
get_url_count_by_tag(&conn, tag_str).unwrap_or(0)
|
||||
} else {
|
||||
get_url_counts(&conn).map(|(t, _, _)| t).unwrap_or(0)
|
||||
};
|
||||
let calculated_total_pages = (total_records as usize).div_ceil(PAGE_SIZE);
|
||||
let total_pages = std::cmp::max(1, calculated_total_pages);
|
||||
let requested_page = query.page.unwrap_or(1);
|
||||
let current_page = if requested_page == 0 {
|
||||
1
|
||||
} else {
|
||||
requested_page
|
||||
}
|
||||
.clamp(1, total_pages);
|
||||
let offset = (current_page - 1) * PAGE_SIZE;
|
||||
|
||||
let urls = list_urls(&conn, PAGE_SIZE as i64, offset as i64, query.tag.as_deref())
|
||||
.unwrap_or_default();
|
||||
|
||||
let start_page = current_page.saturating_sub(3).max(1);
|
||||
let end_page = std::cmp::min(total_pages, current_page + 3);
|
||||
let visible_pages: Vec<usize> = (start_page..=end_page).collect();
|
||||
|
||||
(urls, total_pages, current_page, visible_pages)
|
||||
};
|
||||
|
||||
let csrf_token = generate_csrf_token(&session_id);
|
||||
|
||||
let proto = if state.config.cookie_secure {
|
||||
"https"
|
||||
} else {
|
||||
"http"
|
||||
};
|
||||
let base_url = state
|
||||
.config
|
||||
.base_url
|
||||
.clone()
|
||||
.unwrap_or_else(|| format!("{}://localhost:{}", proto, state.config.port));
|
||||
|
||||
let template = crate::templates::UrlsTemplate {
|
||||
admin_username: user.username,
|
||||
urls,
|
||||
csrf_token,
|
||||
error: query.error,
|
||||
tag_filter: query.tag,
|
||||
base_url,
|
||||
current_page: page,
|
||||
total_pages,
|
||||
visible_pages,
|
||||
};
|
||||
|
||||
template.into_response()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct CreateUrlForm {
|
||||
pub destination: String,
|
||||
pub code: String,
|
||||
pub custom_slug: String,
|
||||
pub title: String,
|
||||
pub description: String,
|
||||
pub tags: String,
|
||||
pub csrf_token: String,
|
||||
pub expires_at: String,
|
||||
pub password: String,
|
||||
pub max_access_count: String,
|
||||
pub utm_source: String,
|
||||
pub utm_medium: String,
|
||||
pub utm_campaign: String,
|
||||
}
|
||||
|
||||
// POST /admin/urls/create
|
||||
pub async fn urls_create(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
headers: HeaderMap,
|
||||
connect_info: Option<ConnectInfo<SocketAddr>>,
|
||||
Form(form): Form<CreateUrlForm>,
|
||||
) -> Response {
|
||||
let (user, session_id) = match require_auth(&state, &jar).await {
|
||||
Ok(u) => u,
|
||||
Err(redir) => return redir.into_response(),
|
||||
};
|
||||
|
||||
if !verify_csrf(&session_id, &form.csrf_token) {
|
||||
return Redirect::to("/admin/urls?error=Invalid CSRF token").into_response();
|
||||
}
|
||||
|
||||
let ip = get_client_ip(&headers, connect_info);
|
||||
let admin_user_id = user.id.parse::<i64>().unwrap_or(1);
|
||||
|
||||
// Custom Slug takes priority if provided
|
||||
let mut code = form.custom_slug.trim().to_lowercase();
|
||||
if code.is_empty() {
|
||||
code = form.code.trim().to_lowercase();
|
||||
if code.is_empty() {
|
||||
code = generate_token(3);
|
||||
} else {
|
||||
if code.len() != 6 || !code.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
return Redirect::to(
|
||||
"/admin/urls?error=Custom code must be exactly 6 hex characters",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if !crate::utils::validation::validate_custom_slug(&code) {
|
||||
return Redirect::to("/admin/urls?error=Custom slug must start with ! followed by 1-24 characters of a-z, 0-9, -, _")
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
|
||||
let dest = match crate::services::urls::prepare_destination(
|
||||
&form.destination,
|
||||
crate::services::urls::UtmParams {
|
||||
source: Some(&form.utm_source),
|
||||
medium: Some(&form.utm_medium),
|
||||
campaign: Some(&form.utm_campaign),
|
||||
},
|
||||
) {
|
||||
Ok(d) => d,
|
||||
Err(msg) => {
|
||||
return Redirect::to(&format!("/admin/urls?error={}", msg)).into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let expires_at_opt = crate::services::urls::parse_expires_at_input(&form.expires_at);
|
||||
|
||||
let password_hash_opt = if form.password.trim().is_empty() {
|
||||
None
|
||||
} else {
|
||||
match hash_password(&form.password) {
|
||||
Ok(h) => Some(h),
|
||||
Err(_) => return Redirect::to("/admin/urls?error=Hashing error").into_response(),
|
||||
}
|
||||
};
|
||||
|
||||
let max_access_count_opt = if form.max_access_count.trim().is_empty() {
|
||||
None
|
||||
} else {
|
||||
match form.max_access_count.trim().parse::<i64>() {
|
||||
Ok(c) => Some(c),
|
||||
Err(_) => {
|
||||
return Redirect::to("/admin/urls?error=Invalid max access count").into_response()
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let tags_list: Vec<String> = form
|
||||
.tags
|
||||
.split(',')
|
||||
.map(|t| t.trim().to_string())
|
||||
.filter(|t| !t.is_empty())
|
||||
.collect();
|
||||
|
||||
let title_opt = if form.title.trim().is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(form.title.trim())
|
||||
};
|
||||
let desc_opt = if form.description.trim().is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(form.description.trim())
|
||||
};
|
||||
|
||||
{
|
||||
let users_conn = state.users_db.lock().unwrap();
|
||||
if !crate::db::users::check_quota_limit(&users_conn, admin_user_id, "urls").unwrap_or(false)
|
||||
{
|
||||
return Redirect::to("/admin/urls?error=Quota limit exceeded").into_response();
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
if !crate::db::users::is_slug_available(&system_conn, &code).unwrap_or(false) {
|
||||
return Redirect::to("/admin/urls?error=Short code/slug already exists")
|
||||
.into_response();
|
||||
}
|
||||
// Always use owner_user_id = 1 for admin content so it resolves via state.content_db
|
||||
if let Err(e) =
|
||||
crate::db::users::register_global_slug(&system_conn, &code, 1, "url", "", "reserving")
|
||||
{
|
||||
return Redirect::to(&format!("/admin/urls?error=Failed to reserve slug: {}", e))
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
|
||||
let res = {
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
crate::db::content::create_url_extended(
|
||||
&conn,
|
||||
&code,
|
||||
&dest,
|
||||
title_opt,
|
||||
desc_opt,
|
||||
&tags_list,
|
||||
expires_at_opt.as_deref(),
|
||||
password_hash_opt.as_deref(),
|
||||
max_access_count_opt,
|
||||
)
|
||||
};
|
||||
|
||||
match res {
|
||||
Ok(url) => {
|
||||
{
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let _ = system_conn.execute(
|
||||
"UPDATE global_slugs SET target_id = ?1, status = 'active', updated_at = ?2 WHERE slug = ?3;",
|
||||
rusqlite::params![url.id, chrono::Utc::now().to_rfc3339(), code],
|
||||
);
|
||||
}
|
||||
{
|
||||
let users_conn = state.users_db.lock().unwrap();
|
||||
let _ =
|
||||
crate::db::users::increment_quota_counter(&users_conn, admin_user_id, "urls");
|
||||
}
|
||||
{
|
||||
let conn = state.admin_db.lock().unwrap();
|
||||
let _ = write_audit_log(
|
||||
&conn,
|
||||
&state,
|
||||
&user.username,
|
||||
"URL_CREATION",
|
||||
Some("url"),
|
||||
Some(&url.id),
|
||||
Some(&ip),
|
||||
headers.get("user-agent").and_then(|h| h.to_str().ok()),
|
||||
);
|
||||
}
|
||||
Redirect::to("/admin/urls").into_response()
|
||||
}
|
||||
Err(e) => {
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let _ = crate::db::users::release_global_slug(&system_conn, &code, 1);
|
||||
Redirect::to(&format!("/admin/urls?error=Database error: {}", e)).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// POST /admin/urls/delete/:id
|
||||
pub async fn urls_delete(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
headers: HeaderMap,
|
||||
connect_info: Option<ConnectInfo<SocketAddr>>,
|
||||
Path(id): Path<String>,
|
||||
Form(form): Form<std::collections::HashMap<String, String>>,
|
||||
) -> Response {
|
||||
let (user, session_id) = match require_auth(&state, &jar).await {
|
||||
Ok(u) => u,
|
||||
Err(redir) => return redir.into_response(),
|
||||
};
|
||||
|
||||
let csrf_token = form.get("csrf_token").cloned().unwrap_or_default();
|
||||
if !verify_csrf(&session_id, &csrf_token) {
|
||||
return Redirect::to("/admin/urls?error=Invalid CSRF token").into_response();
|
||||
}
|
||||
|
||||
let ip = get_client_ip(&headers, connect_info);
|
||||
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
match delete_url(&conn, &id) {
|
||||
Ok(_) => {
|
||||
{
|
||||
let conn_admin = state.admin_db.lock().unwrap();
|
||||
let _ = write_audit_log(
|
||||
&conn_admin,
|
||||
&state,
|
||||
&user.username,
|
||||
"URL_DELETION",
|
||||
Some("url"),
|
||||
Some(&id),
|
||||
Some(&ip),
|
||||
headers.get("user-agent").and_then(|h| h.to_str().ok()),
|
||||
);
|
||||
}
|
||||
Redirect::to("/admin/urls").into_response()
|
||||
}
|
||||
Err(e) => {
|
||||
Redirect::to(&format!("/admin/urls?error=Failed to delete link: {}", e)).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,704 @@
|
||||
use super::*;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct UsersQuery {
|
||||
pub success: Option<String>,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct CreateUserForm {
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
pub account_type: String,
|
||||
pub metadata: String,
|
||||
pub csrf_token: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct UpdateUserStatusForm {
|
||||
pub status: String,
|
||||
pub csrf_token: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct UpdateUserTypeForm {
|
||||
pub account_type: String,
|
||||
pub csrf_token: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ResetPasswordForm {
|
||||
pub new_password: String,
|
||||
pub csrf_token: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct DeleteUserForm {
|
||||
pub csrf_token: String,
|
||||
}
|
||||
|
||||
pub async fn users_get(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
Query(query): Query<UsersQuery>,
|
||||
) -> Response {
|
||||
let (user, session_id) = match require_auth(&state, &jar).await {
|
||||
Ok(u) => u,
|
||||
Err(redir) => return redir.into_response(),
|
||||
};
|
||||
|
||||
let users = {
|
||||
let conn = state.users_db.lock().unwrap();
|
||||
crate::db::users::list_users(&conn).unwrap_or_default()
|
||||
};
|
||||
|
||||
let csrf_token = generate_csrf_token(&session_id);
|
||||
|
||||
let template = crate::templates::UsersTemplate {
|
||||
admin_username: user.username,
|
||||
users,
|
||||
csrf_token,
|
||||
success: query.success,
|
||||
error: query.error,
|
||||
};
|
||||
|
||||
template.into_response()
|
||||
}
|
||||
|
||||
pub async fn users_create_post(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
Form(form): Form<CreateUserForm>,
|
||||
) -> Response {
|
||||
let (user, session_id) = match require_auth(&state, &jar).await {
|
||||
Ok(u) => u,
|
||||
Err(redir) => return redir.into_response(),
|
||||
};
|
||||
|
||||
if !verify_csrf(&session_id, &form.csrf_token) {
|
||||
return Redirect::to("/admin/users?error=Invalid CSRF token").into_response();
|
||||
}
|
||||
|
||||
let username = form.username.trim().to_lowercase();
|
||||
if username.len() < 3 {
|
||||
return Redirect::to("/admin/users?error=Username must be at least 3 characters")
|
||||
.into_response();
|
||||
}
|
||||
if !username
|
||||
.chars()
|
||||
.all(|c| c.is_alphanumeric() || c == '-' || c == '_')
|
||||
{
|
||||
return Redirect::to(
|
||||
"/admin/users?error=Username must contain only alphanumeric characters, hyphens, or underscores",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
if form.password.trim().len() < 8 {
|
||||
return Redirect::to("/admin/users?error=Password must be at least 8 characters")
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let account_type = if form.account_type.trim().is_empty() {
|
||||
"standard"
|
||||
} else {
|
||||
form.account_type.trim()
|
||||
};
|
||||
|
||||
let metadata = if form.metadata.trim().is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(form.metadata.trim())
|
||||
};
|
||||
|
||||
let hash = match hash_password(&form.password) {
|
||||
Ok(h) => h,
|
||||
Err(_) => return Redirect::to("/admin/users?error=Internal hashing error").into_response(),
|
||||
};
|
||||
|
||||
let new_user = {
|
||||
let conn = state.users_db.lock().unwrap();
|
||||
match crate::db::users::create_user(&conn, &username, &hash, account_type, metadata) {
|
||||
Ok(u) => u,
|
||||
Err(rusqlite::Error::SqliteFailure(err, _))
|
||||
if err.code == rusqlite::ErrorCode::ConstraintViolation =>
|
||||
{
|
||||
return Redirect::to("/admin/users?error=Username already exists").into_response();
|
||||
}
|
||||
Err(e) => {
|
||||
return Redirect::to(&format!("/admin/users?error=Database error: {}", e))
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(e) = state.db.init_user_databases(new_user.id) {
|
||||
return Redirect::to(&format!(
|
||||
"/admin/users?error=Failed to initialize user databases: {}",
|
||||
e
|
||||
))
|
||||
.into_response();
|
||||
}
|
||||
|
||||
{
|
||||
let conn = state.admin_db.lock().unwrap();
|
||||
let _ = write_audit_log(
|
||||
&conn,
|
||||
&state,
|
||||
&user.username,
|
||||
"USER_CREATION",
|
||||
Some("user"),
|
||||
Some(&new_user.id.to_string()),
|
||||
None,
|
||||
None,
|
||||
);
|
||||
}
|
||||
|
||||
Redirect::to("/admin/users?success=User created successfully").into_response()
|
||||
}
|
||||
|
||||
pub async fn users_update_status_post(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
Path(id): Path<i64>,
|
||||
Form(form): Form<UpdateUserStatusForm>,
|
||||
) -> Response {
|
||||
let (user, session_id) = match require_auth(&state, &jar).await {
|
||||
Ok(u) => u,
|
||||
Err(redir) => return redir.into_response(),
|
||||
};
|
||||
|
||||
if !verify_csrf(&session_id, &form.csrf_token) {
|
||||
return Redirect::to("/admin/users?error=Invalid CSRF token").into_response();
|
||||
}
|
||||
|
||||
let status = form.status.trim().to_lowercase();
|
||||
if !["active", "disabled", "suspended", "pending", "deleted"].contains(&status.as_str()) {
|
||||
return Redirect::to("/admin/users?error=Invalid user status").into_response();
|
||||
}
|
||||
|
||||
let conn = state.users_db.lock().unwrap();
|
||||
match crate::db::users::update_user_status(&conn, id, &status) {
|
||||
Ok(_) => {
|
||||
let conn_admin = state.admin_db.lock().unwrap();
|
||||
let _ = write_audit_log(
|
||||
&conn_admin,
|
||||
&state,
|
||||
&user.username,
|
||||
"USER_STATUS_UPDATE",
|
||||
Some("user"),
|
||||
Some(&id.to_string()),
|
||||
None,
|
||||
None,
|
||||
);
|
||||
Redirect::to("/admin/users?success=User status updated").into_response()
|
||||
}
|
||||
Err(e) => Redirect::to(&format!(
|
||||
"/admin/users?error=Failed to update status: {}",
|
||||
e
|
||||
))
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn users_update_type_post(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
Path(id): Path<i64>,
|
||||
Form(form): Form<UpdateUserTypeForm>,
|
||||
) -> Response {
|
||||
let (user, session_id) = match require_auth(&state, &jar).await {
|
||||
Ok(u) => u,
|
||||
Err(redir) => return redir.into_response(),
|
||||
};
|
||||
|
||||
if !verify_csrf(&session_id, &form.csrf_token) {
|
||||
return Redirect::to("/admin/users?error=Invalid CSRF token").into_response();
|
||||
}
|
||||
|
||||
let account_type = form.account_type.trim().to_lowercase();
|
||||
if !["admin", "standard", "organization", "service", "system"].contains(&account_type.as_str())
|
||||
{
|
||||
return Redirect::to("/admin/users?error=Invalid account type").into_response();
|
||||
}
|
||||
|
||||
let conn = state.users_db.lock().unwrap();
|
||||
match crate::db::users::update_user_account_type(&conn, id, &account_type) {
|
||||
Ok(_) => {
|
||||
let conn_admin = state.admin_db.lock().unwrap();
|
||||
let _ = write_audit_log(
|
||||
&conn_admin,
|
||||
&state,
|
||||
&user.username,
|
||||
"USER_ACCOUNT_TYPE_UPDATE",
|
||||
Some("user"),
|
||||
Some(&id.to_string()),
|
||||
None,
|
||||
None,
|
||||
);
|
||||
Redirect::to("/admin/users?success=User account type updated").into_response()
|
||||
}
|
||||
Err(e) => Redirect::to(&format!(
|
||||
"/admin/users?error=Failed to update account type: {}",
|
||||
e
|
||||
))
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn users_reset_password_post(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
headers: HeaderMap,
|
||||
connect_info: Option<ConnectInfo<SocketAddr>>,
|
||||
Path(id): Path<i64>,
|
||||
Form(form): Form<ResetPasswordForm>,
|
||||
) -> Response {
|
||||
let (user, session_id) = match require_auth(&state, &jar).await {
|
||||
Ok(u) => u,
|
||||
Err(redir) => return redir.into_response(),
|
||||
};
|
||||
|
||||
if !verify_csrf(&session_id, &form.csrf_token) {
|
||||
return Redirect::to("/admin/users?error=Invalid CSRF token").into_response();
|
||||
}
|
||||
|
||||
if form.new_password.trim().len() < 8 {
|
||||
return Redirect::to("/admin/users?error=Password must be at least 8 characters")
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let hash = match hash_password(&form.new_password) {
|
||||
Ok(h) => h,
|
||||
Err(_) => return Redirect::to("/admin/users?error=Internal hashing error").into_response(),
|
||||
};
|
||||
|
||||
let conn = state.users_db.lock().unwrap();
|
||||
match crate::db::users::reset_user_password(&conn, id, &hash) {
|
||||
Ok(_) => {
|
||||
let ip = get_client_ip(&headers, connect_info);
|
||||
let conn_admin = state.admin_db.lock().unwrap();
|
||||
let _ = write_audit_log(
|
||||
&conn_admin,
|
||||
&state,
|
||||
&user.username,
|
||||
"USER_PASSWORD_RESET",
|
||||
Some("user"),
|
||||
Some(&id.to_string()),
|
||||
Some(&ip),
|
||||
headers.get("user-agent").and_then(|h| h.to_str().ok()),
|
||||
);
|
||||
Redirect::to("/admin/users?success=Password reset successfully").into_response()
|
||||
}
|
||||
Err(e) => Redirect::to(&format!(
|
||||
"/admin/users?error=Failed to reset password: {}",
|
||||
e
|
||||
))
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn users_delete_post(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
headers: HeaderMap,
|
||||
connect_info: Option<ConnectInfo<SocketAddr>>,
|
||||
Path(id): Path<i64>,
|
||||
Form(form): Form<DeleteUserForm>,
|
||||
) -> Response {
|
||||
let (user, session_id) = match require_auth(&state, &jar).await {
|
||||
Ok(u) => u,
|
||||
Err(redir) => return redir.into_response(),
|
||||
};
|
||||
|
||||
if !verify_csrf(&session_id, &form.csrf_token) {
|
||||
return Redirect::to("/admin/users?error=Invalid CSRF token").into_response();
|
||||
}
|
||||
|
||||
match crate::web::multi_user::delete_user_resources(&state, id, &user.username, false) {
|
||||
Ok(_) => {
|
||||
let ip = get_client_ip(&headers, connect_info);
|
||||
let conn_admin = state.admin_db.lock().unwrap();
|
||||
let _ = write_audit_log(
|
||||
&conn_admin,
|
||||
&state,
|
||||
&user.username,
|
||||
"USER_DELETION",
|
||||
Some("user"),
|
||||
Some(&id.to_string()),
|
||||
Some(&ip),
|
||||
headers.get("user-agent").and_then(|h| h.to_str().ok()),
|
||||
);
|
||||
Redirect::to("/admin/users?success=User deleted successfully").into_response()
|
||||
}
|
||||
Err(err) => Redirect::to(&format!("/admin/users?error={}", err)).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GA Hardening UI Handlers and Structs
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
|
||||
pub struct UserDetailStats {
|
||||
pub max_urls: i64,
|
||||
pub max_landings: i64,
|
||||
pub max_api_tokens: i64,
|
||||
pub max_storage_mb: i64,
|
||||
pub current_urls: i64,
|
||||
pub current_landings: i64,
|
||||
pub current_api_tokens: i64,
|
||||
pub current_storage_mb: i64,
|
||||
pub url_pct: i64,
|
||||
pub landing_pct: i64,
|
||||
pub token_pct: i64,
|
||||
pub storage_pct: i64,
|
||||
pub total_visits: i64,
|
||||
}
|
||||
|
||||
pub fn get_user_detail_stats(
|
||||
state: &AppState,
|
||||
user_id: i64,
|
||||
) -> Result<UserDetailStats, Box<dyn std::error::Error>> {
|
||||
use rusqlite::OptionalExtension;
|
||||
let quotas = {
|
||||
let conn = state.users_db.lock().unwrap();
|
||||
conn.query_row(
|
||||
"SELECT max_urls, max_landings, max_api_tokens, max_storage_mb, current_urls, current_landings, current_api_tokens, current_storage_mb \
|
||||
FROM quotas WHERE user_id = ?1;",
|
||||
[user_id],
|
||||
|row| Ok(crate::models::UserQuotas {
|
||||
user_id,
|
||||
max_urls: row.get(0)?,
|
||||
max_landings: row.get(1)?,
|
||||
max_api_tokens: row.get(2)?,
|
||||
max_storage_mb: row.get(3)?,
|
||||
current_urls: row.get(4)?,
|
||||
current_landings: row.get(5)?,
|
||||
current_api_tokens: row.get(6)?,
|
||||
current_storage_mb: row.get(7)?,
|
||||
})
|
||||
).optional()?
|
||||
};
|
||||
|
||||
let quotas = quotas.unwrap_or(crate::models::UserQuotas {
|
||||
user_id,
|
||||
max_urls: 100,
|
||||
max_landings: 10,
|
||||
max_api_tokens: 5,
|
||||
max_storage_mb: 100,
|
||||
current_urls: 0,
|
||||
current_landings: 0,
|
||||
current_api_tokens: 0,
|
||||
current_storage_mb: 0,
|
||||
});
|
||||
|
||||
let total_visits = {
|
||||
if let Ok(dbs) = state.get_user_dbs(user_id) {
|
||||
let conn = dbs.analytics.lock().unwrap();
|
||||
conn.query_row("SELECT COUNT(*) FROM visits;", [], |row| {
|
||||
row.get::<_, i64>(0)
|
||||
})
|
||||
.unwrap_or(0)
|
||||
} else {
|
||||
0
|
||||
}
|
||||
};
|
||||
|
||||
let url_pct = if quotas.max_urls > 0 {
|
||||
(quotas.current_urls * 100) / quotas.max_urls
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let landing_pct = if quotas.max_landings > 0 {
|
||||
(quotas.current_landings * 100) / quotas.max_landings
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let token_pct = if quotas.max_api_tokens > 0 {
|
||||
(quotas.current_api_tokens * 100) / quotas.max_api_tokens
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let storage_pct = if quotas.max_storage_mb > 0 {
|
||||
(quotas.current_storage_mb * 100) / quotas.max_storage_mb
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
Ok(UserDetailStats {
|
||||
max_urls: quotas.max_urls,
|
||||
max_landings: quotas.max_landings,
|
||||
max_api_tokens: quotas.max_api_tokens,
|
||||
max_storage_mb: quotas.max_storage_mb,
|
||||
current_urls: quotas.current_urls,
|
||||
current_landings: quotas.current_landings,
|
||||
current_api_tokens: quotas.current_api_tokens,
|
||||
current_storage_mb: quotas.current_storage_mb,
|
||||
url_pct,
|
||||
landing_pct,
|
||||
token_pct,
|
||||
storage_pct,
|
||||
total_visits,
|
||||
})
|
||||
}
|
||||
|
||||
// GET /admin/users/new
|
||||
pub async fn users_new_get(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
Query(params): Query<HashMap<String, String>>,
|
||||
) -> Response {
|
||||
let (user, session_id) = match require_auth(&state, &jar).await {
|
||||
Ok(u) => u,
|
||||
Err(redir) => return redir.into_response(),
|
||||
};
|
||||
|
||||
let csrf_token = generate_csrf_token(&session_id);
|
||||
|
||||
let template = crate::templates::UsersNewTemplate {
|
||||
admin_username: user.username,
|
||||
csrf_token,
|
||||
success: params.get("success").cloned(),
|
||||
error: params.get("error").cloned(),
|
||||
};
|
||||
|
||||
template.into_response()
|
||||
}
|
||||
|
||||
// GET /admin/users/:id
|
||||
pub async fn user_detail_get(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
Path(id): Path<i64>,
|
||||
Query(params): Query<HashMap<String, String>>,
|
||||
) -> Response {
|
||||
let (user, session_id) = match require_auth(&state, &jar).await {
|
||||
Ok(u) => u,
|
||||
Err(redir) => return redir.into_response(),
|
||||
};
|
||||
|
||||
let target_user = {
|
||||
let conn = state.users_db.lock().unwrap();
|
||||
let u_res = conn.query_row(
|
||||
"SELECT id, username, password_hash, status, created_at, last_login, account_type, organization_id, metadata \
|
||||
FROM users WHERE id = ?1;",
|
||||
[id],
|
||||
|row| Ok(crate::models::TenantUser {
|
||||
id: row.get(0)?,
|
||||
username: row.get(1)?,
|
||||
password_hash: row.get(2)?,
|
||||
status: row.get(3)?,
|
||||
created_at: row.get(4)?,
|
||||
last_login: row.get(5)?,
|
||||
account_type: row.get(6)?,
|
||||
organization_id: row.get(7)?,
|
||||
metadata: row.get(8)?,
|
||||
})
|
||||
);
|
||||
match u_res {
|
||||
Ok(u) => u,
|
||||
Err(_) => return Redirect::to("/admin/users?error=User not found").into_response(),
|
||||
}
|
||||
};
|
||||
|
||||
let stats = match get_user_detail_stats(&state, id) {
|
||||
Ok(s) => s,
|
||||
Err(_) => return Redirect::to("/admin/users?error=Database error").into_response(),
|
||||
};
|
||||
|
||||
let sessions = {
|
||||
let conn = state.users_db.lock().unwrap();
|
||||
let mut stmt = conn
|
||||
.prepare("SELECT id, user_id, expires_at, created_at FROM sessions WHERE user_id = ?1;")
|
||||
.unwrap();
|
||||
let rows = stmt
|
||||
.query_map([id], |row| {
|
||||
Ok(crate::models::UserSession {
|
||||
id: row.get(0)?,
|
||||
user_id: row.get(1)?,
|
||||
expires_at: row.get(2)?,
|
||||
created_at: row.get(3)?,
|
||||
})
|
||||
})
|
||||
.unwrap();
|
||||
rows.filter_map(|r| r.ok()).collect()
|
||||
};
|
||||
|
||||
let tokens = {
|
||||
let conn = state.users_db.lock().unwrap();
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT id, user_id, token_hash, created_at FROM api_tokens WHERE user_id = ?1;",
|
||||
)
|
||||
.unwrap();
|
||||
let rows = stmt
|
||||
.query_map([id], |row| {
|
||||
Ok(crate::models::UserApiToken {
|
||||
id: row.get(0)?,
|
||||
user_id: row.get(1)?,
|
||||
token_hash: row.get(2)?,
|
||||
created_at: row.get(3)?,
|
||||
})
|
||||
})
|
||||
.unwrap();
|
||||
rows.filter_map(|r| r.ok()).collect()
|
||||
};
|
||||
|
||||
let csrf_token = generate_csrf_token(&session_id);
|
||||
|
||||
let template = crate::templates::UserDetailTemplate {
|
||||
admin_username: user.username,
|
||||
target_user,
|
||||
stats,
|
||||
sessions,
|
||||
tokens,
|
||||
csrf_token,
|
||||
success: params.get("success").cloned(),
|
||||
error: params.get("error").cloned(),
|
||||
};
|
||||
|
||||
template.into_response()
|
||||
}
|
||||
|
||||
// GET /admin/users/:id/edit
|
||||
pub async fn user_edit_get(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
Path(id): Path<i64>,
|
||||
Query(params): Query<HashMap<String, String>>,
|
||||
) -> Response {
|
||||
let (user, session_id) = match require_auth(&state, &jar).await {
|
||||
Ok(u) => u,
|
||||
Err(redir) => return redir.into_response(),
|
||||
};
|
||||
|
||||
let target_user = {
|
||||
let conn = state.users_db.lock().unwrap();
|
||||
let u_res = conn.query_row(
|
||||
"SELECT id, username, password_hash, status, created_at, last_login, account_type, organization_id, metadata \
|
||||
FROM users WHERE id = ?1;",
|
||||
[id],
|
||||
|row| Ok(crate::models::TenantUser {
|
||||
id: row.get(0)?,
|
||||
username: row.get(1)?,
|
||||
password_hash: row.get(2)?,
|
||||
status: row.get(3)?,
|
||||
created_at: row.get(4)?,
|
||||
last_login: row.get(5)?,
|
||||
account_type: row.get(6)?,
|
||||
organization_id: row.get(7)?,
|
||||
metadata: row.get(8)?,
|
||||
})
|
||||
);
|
||||
match u_res {
|
||||
Ok(u) => u,
|
||||
Err(_) => return Redirect::to("/admin/users?error=User not found").into_response(),
|
||||
}
|
||||
};
|
||||
|
||||
let quotas = {
|
||||
let conn = state.users_db.lock().unwrap();
|
||||
conn.query_row(
|
||||
"SELECT max_urls, max_landings, max_api_tokens, max_storage_mb, current_urls, current_landings, current_api_tokens, current_storage_mb \
|
||||
FROM quotas WHERE user_id = ?1;",
|
||||
[id],
|
||||
|row| Ok(crate::models::UserQuotas {
|
||||
user_id: id,
|
||||
max_urls: row.get(0)?,
|
||||
max_landings: row.get(1)?,
|
||||
max_api_tokens: row.get(2)?,
|
||||
max_storage_mb: row.get(3)?,
|
||||
current_urls: row.get(4)?,
|
||||
current_landings: row.get(5)?,
|
||||
current_api_tokens: row.get(6)?,
|
||||
current_storage_mb: row.get(7)?,
|
||||
})
|
||||
).unwrap_or(crate::models::UserQuotas {
|
||||
user_id: id,
|
||||
max_urls: 100,
|
||||
max_landings: 10,
|
||||
max_api_tokens: 5,
|
||||
max_storage_mb: 100,
|
||||
current_urls: 0,
|
||||
current_landings: 0,
|
||||
current_api_tokens: 0,
|
||||
current_storage_mb: 0,
|
||||
})
|
||||
};
|
||||
|
||||
let csrf_token = generate_csrf_token(&session_id);
|
||||
|
||||
let template = crate::templates::UserEditTemplate {
|
||||
admin_username: user.username,
|
||||
target_user,
|
||||
quotas,
|
||||
csrf_token,
|
||||
success: params.get("success").cloned(),
|
||||
error: params.get("error").cloned(),
|
||||
};
|
||||
|
||||
template.into_response()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct UserEditForm {
|
||||
pub account_type: String,
|
||||
pub metadata: String,
|
||||
pub max_urls: i64,
|
||||
pub max_landings: i64,
|
||||
pub max_api_tokens: i64,
|
||||
pub max_storage_mb: i64,
|
||||
pub csrf_token: String,
|
||||
}
|
||||
|
||||
// POST /admin/users/:id/edit
|
||||
pub async fn user_edit_post(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
Path(id): Path<i64>,
|
||||
Form(form): Form<UserEditForm>,
|
||||
) -> Response {
|
||||
let (_user, session_id) = match require_auth(&state, &jar).await {
|
||||
Ok(u) => u,
|
||||
Err(redir) => return redir.into_response(),
|
||||
};
|
||||
|
||||
if !verify_csrf(&session_id, &form.csrf_token) {
|
||||
return Redirect::to(&format!(
|
||||
"/admin/users/{}/edit?error=Invalid CSRF token",
|
||||
id
|
||||
))
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let conn = state.users_db.lock().unwrap();
|
||||
let _ = conn.execute(
|
||||
"UPDATE users SET account_type = ?1, metadata = ?2 WHERE id = ?3;",
|
||||
rusqlite::params![form.account_type, form.metadata, id],
|
||||
);
|
||||
|
||||
let _ = conn.execute(
|
||||
"INSERT INTO quotas (user_id, max_urls, max_landings, max_api_tokens, max_storage_mb) \
|
||||
VALUES (?1, ?2, ?3, ?4, ?5) \
|
||||
ON CONFLICT(user_id) DO UPDATE SET \
|
||||
max_urls = excluded.max_urls, \
|
||||
max_landings = excluded.max_landings, \
|
||||
max_api_tokens = excluded.max_api_tokens, \
|
||||
max_storage_mb = excluded.max_storage_mb;",
|
||||
rusqlite::params![
|
||||
id,
|
||||
form.max_urls,
|
||||
form.max_landings,
|
||||
form.max_api_tokens,
|
||||
form.max_storage_mb
|
||||
],
|
||||
);
|
||||
|
||||
Redirect::to(&format!(
|
||||
"/admin/users/{}?success=User updated successfully",
|
||||
id
|
||||
))
|
||||
.into_response()
|
||||
}
|
||||
+244
-74
@@ -99,34 +99,23 @@ pub async fn api_create_url(
|
||||
}
|
||||
}
|
||||
|
||||
let mut dest = payload.destination.trim().to_string();
|
||||
if let Ok(mut parsed) = reqwest::Url::parse(&dest) {
|
||||
let mut has_utm = false;
|
||||
{
|
||||
let mut query = parsed.query_pairs_mut();
|
||||
if let Some(ref src) = payload.utm_source {
|
||||
if !src.trim().is_empty() {
|
||||
query.append_pair("utm_source", src.trim());
|
||||
has_utm = true;
|
||||
}
|
||||
}
|
||||
if let Some(ref med) = payload.utm_medium {
|
||||
if !med.trim().is_empty() {
|
||||
query.append_pair("utm_medium", med.trim());
|
||||
has_utm = true;
|
||||
}
|
||||
}
|
||||
if let Some(ref camp) = payload.utm_campaign {
|
||||
if !camp.trim().is_empty() {
|
||||
query.append_pair("utm_campaign", camp.trim());
|
||||
has_utm = true;
|
||||
}
|
||||
}
|
||||
let dest = match crate::services::urls::prepare_destination(
|
||||
&payload.destination,
|
||||
crate::services::urls::UtmParams {
|
||||
source: payload.utm_source.as_deref(),
|
||||
medium: payload.utm_medium.as_deref(),
|
||||
campaign: payload.utm_campaign.as_deref(),
|
||||
},
|
||||
) {
|
||||
Ok(d) => d,
|
||||
Err(msg) => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({ "error": msg })),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
if has_utm {
|
||||
dest = parsed.to_string();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let password_hash = if let Some(ref pwd) = payload.password {
|
||||
if pwd.is_empty() {
|
||||
@@ -149,20 +138,105 @@ pub async fn api_create_url(
|
||||
None
|
||||
};
|
||||
|
||||
// Dynamically resolve target user ID and content DB
|
||||
let (target_user_id, content_db) = match user.0 {
|
||||
crate::models::ApiActor::Admin(_) => (1, state.content_db.clone()),
|
||||
crate::models::ApiActor::User(ref u) => {
|
||||
let user_dbs = match state.get_user_dbs(u.id) {
|
||||
Ok(dbs) => dbs,
|
||||
Err(_) => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ApiError {
|
||||
error: "Database error".to_string(),
|
||||
}),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
};
|
||||
(u.id, user_dbs.content.clone())
|
||||
}
|
||||
};
|
||||
|
||||
// Check quota
|
||||
{
|
||||
let users_conn = state.users_db.lock().unwrap();
|
||||
if !crate::db::users::check_quota_limit(&users_conn, target_user_id, "urls")
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return (
|
||||
StatusCode::FORBIDDEN,
|
||||
Json(ApiError {
|
||||
error: "Quota limit exceeded".to_string(),
|
||||
}),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
|
||||
// Check availability
|
||||
{
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
if !crate::db::users::is_slug_available(&system_conn, &code).unwrap_or(false) {
|
||||
return (
|
||||
StatusCode::CONFLICT,
|
||||
Json(ApiError {
|
||||
error: "Short code already exists".to_string(),
|
||||
}),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
if let Err(e) = crate::db::users::register_global_slug(
|
||||
&system_conn,
|
||||
&code,
|
||||
target_user_id,
|
||||
"url",
|
||||
"",
|
||||
"reserving",
|
||||
) {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ApiError {
|
||||
error: format!("Failed to reserve slug: {}", e),
|
||||
}),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
|
||||
let tags = payload.tags.unwrap_or_default();
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
match crate::db::content::create_url_extended(
|
||||
&conn,
|
||||
&code,
|
||||
&dest,
|
||||
payload.title.as_deref(),
|
||||
payload.description.as_deref(),
|
||||
&tags,
|
||||
payload.expires_at.as_deref(),
|
||||
password_hash.as_deref(),
|
||||
payload.max_access_count,
|
||||
) {
|
||||
let res = {
|
||||
let conn = content_db.lock().unwrap();
|
||||
crate::db::content::create_url_extended(
|
||||
&conn,
|
||||
&code,
|
||||
&dest,
|
||||
payload.title.as_deref(),
|
||||
payload.description.as_deref(),
|
||||
&tags,
|
||||
payload.expires_at.as_deref(),
|
||||
password_hash.as_deref(),
|
||||
payload.max_access_count,
|
||||
)
|
||||
};
|
||||
|
||||
match res {
|
||||
Ok(url) => {
|
||||
// Activate slug
|
||||
{
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let _ = system_conn.execute(
|
||||
"UPDATE global_slugs SET target_id = ?1, status = 'active', updated_at = ?2 WHERE slug = ?3;",
|
||||
rusqlite::params![url.id, chrono::Utc::now().to_rfc3339(), code],
|
||||
);
|
||||
}
|
||||
// Increment quota
|
||||
{
|
||||
let users_conn = state.users_db.lock().unwrap();
|
||||
let _ =
|
||||
crate::db::users::increment_quota_counter(&users_conn, target_user_id, "urls");
|
||||
}
|
||||
|
||||
let ip = get_client_ip(&headers, connect_info);
|
||||
let user_agent = headers.get("user-agent").and_then(|h| h.to_str().ok());
|
||||
let _ = write_audit_log(
|
||||
@@ -189,24 +263,17 @@ pub async fn api_create_url(
|
||||
}
|
||||
(StatusCode::CREATED, Json(url)).into_response()
|
||||
}
|
||||
Err(rusqlite::Error::SqliteFailure(err, _))
|
||||
if err.code == rusqlite::ErrorCode::ConstraintViolation =>
|
||||
{
|
||||
Err(e) => {
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let _ = crate::db::users::release_global_slug(&system_conn, &code, target_user_id);
|
||||
(
|
||||
StatusCode::CONFLICT,
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ApiError {
|
||||
error: "Short code already exists".to_string(),
|
||||
error: e.to_string(),
|
||||
}),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
Err(e) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ApiError {
|
||||
error: e.to_string(),
|
||||
}),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -275,6 +342,15 @@ pub async fn api_update_url(
|
||||
Json(payload): Json<UpdateUrlRequest>,
|
||||
) -> Response {
|
||||
let tags = payload.tags.unwrap_or_default();
|
||||
if !crate::utils::validation::validate_redirect_destination(&payload.destination) {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({
|
||||
"error": "Destination must be a valid http(s) URL without control characters"
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
match update_url(
|
||||
&conn,
|
||||
@@ -434,16 +510,109 @@ pub async fn api_create_page(
|
||||
}
|
||||
}
|
||||
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
match create_landing_page(
|
||||
&conn,
|
||||
&code,
|
||||
&payload.slug,
|
||||
&payload.title,
|
||||
&payload.html_content,
|
||||
&payload.state,
|
||||
) {
|
||||
// Dynamically resolve target user ID and content DB
|
||||
let (target_user_id, content_db) = match user.0 {
|
||||
crate::models::ApiActor::Admin(_) => (1, state.content_db.clone()),
|
||||
crate::models::ApiActor::User(ref u) => {
|
||||
let user_dbs = match state.get_user_dbs(u.id) {
|
||||
Ok(dbs) => dbs,
|
||||
Err(_) => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ApiError {
|
||||
error: "Database error".to_string(),
|
||||
}),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
};
|
||||
(u.id, user_dbs.content.clone())
|
||||
}
|
||||
};
|
||||
|
||||
// Check quota
|
||||
{
|
||||
let users_conn = state.users_db.lock().unwrap();
|
||||
if !crate::db::users::check_quota_limit(&users_conn, target_user_id, "landings")
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return (
|
||||
StatusCode::FORBIDDEN,
|
||||
Json(ApiError {
|
||||
error: "Quota limit exceeded".to_string(),
|
||||
}),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
|
||||
// Check availability
|
||||
{
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
if !crate::db::users::is_slug_available(&system_conn, &code).unwrap_or(false) {
|
||||
return (
|
||||
StatusCode::CONFLICT,
|
||||
Json(ApiError {
|
||||
error: "Short code already exists".to_string(),
|
||||
}),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
if let Err(e) = crate::db::users::register_global_slug(
|
||||
&system_conn,
|
||||
&code,
|
||||
target_user_id,
|
||||
"page",
|
||||
"",
|
||||
"reserving",
|
||||
) {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ApiError {
|
||||
error: format!("Failed to reserve slug: {}", e),
|
||||
}),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
|
||||
let res = {
|
||||
let conn = content_db.lock().unwrap();
|
||||
create_landing_page(
|
||||
&conn,
|
||||
&code,
|
||||
&payload.slug,
|
||||
&payload.title,
|
||||
&payload.html_content,
|
||||
&payload.state,
|
||||
)
|
||||
};
|
||||
|
||||
match res {
|
||||
Ok(page) => {
|
||||
// Activate slug
|
||||
{
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let global_status = if payload.state == "published" {
|
||||
"active"
|
||||
} else {
|
||||
"disabled"
|
||||
};
|
||||
let _ = system_conn.execute(
|
||||
"UPDATE global_slugs SET target_id = ?1, status = ?2, updated_at = ?3 WHERE slug = ?4;",
|
||||
rusqlite::params![page.id, global_status, chrono::Utc::now().to_rfc3339(), code],
|
||||
);
|
||||
}
|
||||
// Increment quota
|
||||
{
|
||||
let users_conn = state.users_db.lock().unwrap();
|
||||
let _ = crate::db::users::increment_quota_counter(
|
||||
&users_conn,
|
||||
target_user_id,
|
||||
"landings",
|
||||
);
|
||||
}
|
||||
|
||||
let ip = get_client_ip(&headers, connect_info);
|
||||
let user_agent = headers.get("user-agent").and_then(|h| h.to_str().ok());
|
||||
let _ = write_audit_log(
|
||||
@@ -457,24 +626,17 @@ pub async fn api_create_page(
|
||||
);
|
||||
(StatusCode::CREATED, Json(page)).into_response()
|
||||
}
|
||||
Err(rusqlite::Error::SqliteFailure(err, _))
|
||||
if err.code == rusqlite::ErrorCode::ConstraintViolation =>
|
||||
{
|
||||
Err(e) => {
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let _ = crate::db::users::release_global_slug(&system_conn, &code, target_user_id);
|
||||
(
|
||||
StatusCode::CONFLICT,
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ApiError {
|
||||
error: "Short code already exists".to_string(),
|
||||
error: e.to_string(),
|
||||
}),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
Err(e) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ApiError {
|
||||
error: e.to_string(),
|
||||
}),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -629,7 +791,11 @@ pub struct OverallStatsResponse {
|
||||
}
|
||||
|
||||
// GET /api/v1/stats
|
||||
pub async fn api_overall_stats(State(state): State<AppState>, _user: ApiUser) -> Response {
|
||||
pub async fn api_overall_stats(State(state): State<AppState>, user: ApiUser) -> Response {
|
||||
if let Err(err) = user.require_admin() {
|
||||
return err.into_response();
|
||||
}
|
||||
|
||||
let (total_urls, active_links, dead_links) = {
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
get_url_counts(&conn).unwrap_or((0, 0, 0))
|
||||
@@ -818,9 +984,13 @@ pub struct AuditQuery {
|
||||
// GET /api/v1/audit
|
||||
pub async fn api_list_audit(
|
||||
State(state): State<AppState>,
|
||||
_user: ApiUser,
|
||||
user: ApiUser,
|
||||
Query(query): Query<AuditQuery>,
|
||||
) -> Response {
|
||||
if let Err(err) = user.require_admin() {
|
||||
return err.into_response();
|
||||
}
|
||||
|
||||
let limit = query.limit.unwrap_or(50);
|
||||
let offset = query.offset.unwrap_or(0);
|
||||
|
||||
|
||||
+68
-98
@@ -1,8 +1,9 @@
|
||||
use crate::auth::generate_token;
|
||||
use crate::auth::password::hash_password;
|
||||
use crate::auth::ApiUser;
|
||||
use crate::services::bulk_urls::{
|
||||
create_urls_bulk, ensure_url_quota, BulkUrlCreateItem, BulkUrlError,
|
||||
};
|
||||
use crate::state::AppState;
|
||||
use crate::utils::get_client_ip;
|
||||
use crate::utils::{get_client_ip, lock_db};
|
||||
use axum::{
|
||||
extract::{ConnectInfo, State},
|
||||
http::{HeaderMap, StatusCode},
|
||||
@@ -68,7 +69,18 @@ pub async fn api_bulk_qr(
|
||||
// Retrieve URLs from database
|
||||
let mut urls = Vec::new();
|
||||
{
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
let conn = match lock_db(&state.content_db, "content_db") {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(BulkErrorResponse {
|
||||
error: e.to_string(),
|
||||
}),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
for id in &payload.ids {
|
||||
match crate::db::content::get_url_by_id(&conn, id) {
|
||||
Ok(Some(url)) => urls.push(url),
|
||||
@@ -115,9 +127,8 @@ pub async fn api_bulk_qr(
|
||||
// Generate ZIP
|
||||
match crate::services::bulk::export_qr_zip(&urls, &format, &base_url) {
|
||||
Ok(zip_data) => {
|
||||
// Write Audit Log
|
||||
{
|
||||
let system_conn = state.db.system.lock().unwrap();
|
||||
// Write Audit Log (best-effort; do not fail the download on audit lock poison)
|
||||
if let Ok(system_conn) = lock_db(&state.db.system, "system_db") {
|
||||
let _ = crate::db::audit_events::write_audit_event(
|
||||
&system_conn,
|
||||
user.0.username(),
|
||||
@@ -147,6 +158,16 @@ pub async fn api_bulk_qr(
|
||||
}
|
||||
}
|
||||
|
||||
fn bulk_url_error_response(err: BulkUrlError) -> Response {
|
||||
let (status, msg) = match &err {
|
||||
BulkUrlError::BadRequest(m) => (StatusCode::BAD_REQUEST, m.clone()),
|
||||
BulkUrlError::Conflict(m) => (StatusCode::CONFLICT, m.clone()),
|
||||
BulkUrlError::Forbidden(m) => (StatusCode::FORBIDDEN, m.clone()),
|
||||
BulkUrlError::Internal(m) => (StatusCode::INTERNAL_SERVER_ERROR, m.clone()),
|
||||
};
|
||||
(status, Json(BulkErrorResponse { error: msg })).into_response()
|
||||
}
|
||||
|
||||
// POST /api/v1/bulk/url
|
||||
pub async fn api_bulk_url(
|
||||
State(state): State<AppState>,
|
||||
@@ -165,110 +186,59 @@ pub async fn api_bulk_url(
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let mut conn = state.content_db.lock().unwrap();
|
||||
let tx = match conn.transaction() {
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(BulkErrorResponse {
|
||||
error: format!("Failed to start database transaction: {}", e),
|
||||
}),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
};
|
||||
|
||||
let mut created_urls = Vec::new();
|
||||
|
||||
for item in payload {
|
||||
let mut code = item.code.unwrap_or_default().trim().to_lowercase();
|
||||
if code.is_empty() {
|
||||
code = generate_token(3); // 6 hex
|
||||
} else {
|
||||
if code.len() != 6 || !code.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
let _ = tx.rollback();
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(BulkErrorResponse {
|
||||
error: format!("Short code '{}' must be 6 hex characters", code),
|
||||
}),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
|
||||
let password_hash = if let Some(ref pwd) = item.password {
|
||||
match hash_password(pwd) {
|
||||
Ok(h) => Some(h),
|
||||
Err(e) => {
|
||||
let _ = tx.rollback();
|
||||
// Dynamically resolve target user ID and content DB
|
||||
let (target_user_id, content_db) = match user.0 {
|
||||
crate::models::ApiActor::Admin(_) => (1, state.content_db.clone()),
|
||||
crate::models::ApiActor::User(ref u) => {
|
||||
let user_dbs = match state.get_user_dbs(u.id) {
|
||||
Ok(dbs) => dbs,
|
||||
Err(_) => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(BulkErrorResponse {
|
||||
error: format!("Password hashing error: {}", e),
|
||||
error: "Database error".to_string(),
|
||||
}),
|
||||
)
|
||||
.into_response();
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let tags = item.tags.unwrap_or_default();
|
||||
match crate::db::content::create_url_extended(
|
||||
&tx,
|
||||
&code,
|
||||
&item.destination,
|
||||
item.title.as_deref(),
|
||||
item.description.as_deref(),
|
||||
&tags,
|
||||
item.expires_at.as_deref(),
|
||||
password_hash.as_deref(),
|
||||
item.max_access_count,
|
||||
) {
|
||||
Ok(url) => created_urls.push(url),
|
||||
Err(rusqlite::Error::SqliteFailure(err, _))
|
||||
if err.code == rusqlite::ErrorCode::ConstraintViolation =>
|
||||
{
|
||||
let _ = tx.rollback();
|
||||
return (
|
||||
StatusCode::CONFLICT,
|
||||
Json(BulkErrorResponse {
|
||||
error: format!("Short code '{}' already exists", code),
|
||||
}),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = tx.rollback();
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(BulkErrorResponse {
|
||||
error: format!("Database insert error: {}", e),
|
||||
}),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
(u.id, user_dbs.content.clone())
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(e) = ensure_url_quota(&state.users_db, target_user_id, payload.len() as i64) {
|
||||
return bulk_url_error_response(e);
|
||||
}
|
||||
|
||||
if let Err(e) = tx.commit() {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(BulkErrorResponse {
|
||||
error: format!("Failed to commit transaction: {}", e),
|
||||
}),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
let items: Vec<BulkUrlCreateItem> = payload
|
||||
.into_iter()
|
||||
.map(|item| BulkUrlCreateItem {
|
||||
destination: item.destination,
|
||||
code: item.code,
|
||||
title: item.title,
|
||||
description: item.description,
|
||||
tags: item.tags,
|
||||
expires_at: item.expires_at,
|
||||
password: item.password,
|
||||
max_access_count: item.max_access_count,
|
||||
})
|
||||
.collect();
|
||||
|
||||
let created_urls = match create_urls_bulk(
|
||||
&content_db,
|
||||
&state.system_db,
|
||||
&state.users_db,
|
||||
target_user_id,
|
||||
items,
|
||||
) {
|
||||
Ok(urls) => urls,
|
||||
Err(e) => return bulk_url_error_response(e),
|
||||
};
|
||||
|
||||
// Write Audit Log for the entire batch
|
||||
let ip = get_client_ip(&headers, connect_info);
|
||||
let user_agent = headers.get("user-agent").and_then(|h| h.to_str().ok());
|
||||
{
|
||||
let system_conn = state.db.system.lock().unwrap();
|
||||
if let Ok(system_conn) = lock_db(&state.db.system, "system_db") {
|
||||
let _ = crate::db::audit_events::write_audit_event(
|
||||
&system_conn,
|
||||
user.0.username(),
|
||||
|
||||
+52
-4
@@ -63,14 +63,14 @@ pub async fn resolve_page(
|
||||
.into_response();
|
||||
}
|
||||
|
||||
// 2. Get user specific database connections
|
||||
let user_dbs = match state.get_user_dbs(owner_user_id) {
|
||||
Ok(dbs) => dbs,
|
||||
// 2. Get content database connection via tenant DB resolution
|
||||
let content_conn = match state.get_user_dbs(owner_user_id) {
|
||||
Ok(dbs) => dbs.content,
|
||||
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
|
||||
};
|
||||
|
||||
let page_opt = {
|
||||
let conn = user_dbs.content.lock().unwrap();
|
||||
let conn = content_conn.lock().unwrap();
|
||||
match crate::db::content::get_landing_page_by_code(&conn, &code) {
|
||||
Ok(page) => page,
|
||||
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
|
||||
@@ -181,3 +181,51 @@ pub async fn deploy_script() -> Response {
|
||||
Err(_) => (StatusCode::NOT_FOUND, "deploy.sh not found").into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn social_preview() -> Response {
|
||||
let mut target_path = std::path::PathBuf::from("www/images/preview.png");
|
||||
|
||||
if !target_path.exists() {
|
||||
if let Ok(exe_path) = std::env::current_exe() {
|
||||
if let Some(exe_dir) = exe_path.parent() {
|
||||
let path1 = exe_dir.join("www/images/preview.png");
|
||||
if path1.exists() {
|
||||
target_path = path1;
|
||||
} else if let Some(parent1) = exe_dir.parent() {
|
||||
let path2 = parent1.join("www/images/preview.png");
|
||||
if path2.exists() {
|
||||
target_path = path2;
|
||||
} else if let Some(parent2) = parent1.parent() {
|
||||
let path3 = parent2.join("www/images/preview.png");
|
||||
if path3.exists() {
|
||||
target_path = path3;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !target_path.exists() {
|
||||
if let Ok(manifest_dir) = std::env::var("CARGO_MANIFEST_DIR") {
|
||||
let path = std::path::PathBuf::from(manifest_dir).join("www/images/preview.png");
|
||||
if path.exists() {
|
||||
target_path = path;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match tokio::fs::read(target_path).await {
|
||||
Ok(content) => (
|
||||
StatusCode::OK,
|
||||
[
|
||||
("content-type", "image/png"),
|
||||
("cache-control", "public, max-age=86400"),
|
||||
],
|
||||
content,
|
||||
)
|
||||
.into_response(),
|
||||
|
||||
Err(_) => (StatusCode::NOT_FOUND, "preview.png not found").into_response(),
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@ pub async fn gate_post(
|
||||
State(state): State<AppState>,
|
||||
Path(code): Path<String>,
|
||||
jar: CookieJar,
|
||||
headers: axum::http::HeaderMap,
|
||||
Form(form): Form<PasswordGateForm>,
|
||||
) -> Response {
|
||||
let url_opt = match get_url_by_code(&state.db, &code) {
|
||||
@@ -55,8 +56,9 @@ pub async fn gate_post(
|
||||
if verify_password(&form.password, password_hash) {
|
||||
// Correct password - set 15 min temporary cookie
|
||||
let cookie_name = format!("bzod_gate_{}", code);
|
||||
let secure_flag = crate::utils::resolve_cookie_secure(state.config.cookie_secure, &headers);
|
||||
let cookie = Cookie::build((cookie_name, "authorized"))
|
||||
.secure(state.config.cookie_secure)
|
||||
.secure(secure_flag)
|
||||
.same_site(axum_extra::extract::cookie::SameSite::Strict)
|
||||
.http_only(true)
|
||||
.path("/")
|
||||
|
||||
+59
-71
@@ -10,7 +10,6 @@ use std::net::SocketAddr;
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
// GET /api/qr/:file (e.g. /api/qr/abcdef.png or /api/qr/abcdef.svg or JSON stats /api/qr/abcdef)
|
||||
// GET /api/qr/:file (e.g. /api/qr/abcdef.png or /api/qr/abcdef.svg or JSON stats /api/qr/abcdef)
|
||||
pub async fn qr_handler(
|
||||
State(state): State<AppState>,
|
||||
@@ -38,12 +37,12 @@ pub async fn qr_handler(
|
||||
return (StatusCode::UNAUTHORIZED, "Unauthorized").into_response();
|
||||
}
|
||||
|
||||
// We need to look up owner_user_id and status from global_slugs
|
||||
let (owner_user_id, slug_status) = {
|
||||
// We need to look up owner_user_id, target_id, and status from global_slugs
|
||||
let (owner_user_id, target_id, slug_status) = {
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let mut stmt = match system_conn
|
||||
.prepare("SELECT owner_user_id, status FROM global_slugs WHERE slug = ?1;")
|
||||
{
|
||||
let mut stmt = match system_conn.prepare(
|
||||
"SELECT owner_user_id, target_id, status FROM global_slugs WHERE slug = ?1;",
|
||||
) {
|
||||
Ok(s) => s,
|
||||
Err(_) => {
|
||||
return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response()
|
||||
@@ -52,19 +51,25 @@ pub async fn qr_handler(
|
||||
use rusqlite::OptionalExtension;
|
||||
match stmt
|
||||
.query_row(rusqlite::params![&file], |row| {
|
||||
Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?))
|
||||
Ok((
|
||||
row.get::<_, i64>(0)?,
|
||||
row.get::<_, String>(1)?,
|
||||
row.get::<_, String>(2)?,
|
||||
))
|
||||
})
|
||||
.optional()
|
||||
{
|
||||
Ok(Some((uid, status))) => (uid, status),
|
||||
Ok(None) => (1, "active".to_string()), // fallback to admin
|
||||
Ok(Some((uid, tid, status))) => (uid, tid, status),
|
||||
Ok(None) => return (StatusCode::NOT_FOUND, "URL not found").into_response(),
|
||||
Err(_) => {
|
||||
return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response()
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if slug_status != "active" {
|
||||
if slug_status == "disabled" {
|
||||
return (StatusCode::GONE, "This content has been disabled").into_response();
|
||||
} else if slug_status != "active" {
|
||||
return (StatusCode::NOT_FOUND, "URL not found").into_response();
|
||||
}
|
||||
|
||||
@@ -73,31 +78,16 @@ pub async fn qr_handler(
|
||||
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
|
||||
};
|
||||
|
||||
let url_opt = {
|
||||
let conn = user_dbs.content.lock().unwrap();
|
||||
match crate::db::content::get_url_by_code(&conn, &file) {
|
||||
Ok(u) => u,
|
||||
Err(_) => {
|
||||
return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response()
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let url = match url_opt {
|
||||
Some(u) => u,
|
||||
None => return (StatusCode::NOT_FOUND, "URL not found").into_response(),
|
||||
};
|
||||
|
||||
let qr_scans = {
|
||||
let conn = user_dbs.analytics.lock().unwrap();
|
||||
crate::db::qr::get_qr_scan_count(&conn, &url.id).unwrap_or(0)
|
||||
crate::db::qr::get_qr_scan_count(&conn, &target_id).unwrap_or(0)
|
||||
};
|
||||
|
||||
let direct_clicks = {
|
||||
let conn = user_dbs.analytics.lock().unwrap();
|
||||
conn.query_row(
|
||||
"SELECT COUNT(*) FROM visits WHERE target_type = 'url' AND target_id = ?1;",
|
||||
rusqlite::params![url.id],
|
||||
"SELECT COUNT(*) FROM visits WHERE target_id = ?1;",
|
||||
rusqlite::params![target_id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap_or(0)
|
||||
@@ -113,15 +103,17 @@ pub async fn qr_handler(
|
||||
let code = parts[0];
|
||||
let ext = parts[1].to_lowercase();
|
||||
|
||||
if !crate::utils::validation::validate_redirect_code(code) {
|
||||
if !crate::utils::validation::validate_redirect_code(code)
|
||||
&& !crate::utils::validation::validate_page_code(code)
|
||||
{
|
||||
return (StatusCode::NOT_FOUND, "Not Found").into_response();
|
||||
}
|
||||
|
||||
// We need to look up owner_user_id and status from global_slugs
|
||||
let (owner_user_id, slug_status) = {
|
||||
// We need to look up owner_user_id, target_type, target_id, and status from global_slugs
|
||||
let (owner_user_id, target_type, target_id, slug_status) = {
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let mut stmt = match system_conn
|
||||
.prepare("SELECT owner_user_id, status FROM global_slugs WHERE slug = ?1;")
|
||||
.prepare("SELECT owner_user_id, target_type, target_id, status FROM global_slugs WHERE slug = ?1;")
|
||||
{
|
||||
Ok(s) => s,
|
||||
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
|
||||
@@ -129,38 +121,27 @@ pub async fn qr_handler(
|
||||
use rusqlite::OptionalExtension;
|
||||
match stmt
|
||||
.query_row(rusqlite::params![code], |row| {
|
||||
Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?))
|
||||
Ok((
|
||||
row.get::<_, i64>(0)?,
|
||||
row.get::<_, String>(1)?,
|
||||
row.get::<_, String>(2)?,
|
||||
row.get::<_, String>(3)?,
|
||||
))
|
||||
})
|
||||
.optional()
|
||||
{
|
||||
Ok(Some((uid, status))) => (uid, status),
|
||||
Ok(None) => (1, "active".to_string()), // fallback to admin
|
||||
Ok(Some(info)) => info,
|
||||
Ok(None) => return (StatusCode::NOT_FOUND, "Not Found").into_response(),
|
||||
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
|
||||
}
|
||||
};
|
||||
|
||||
if slug_status != "active" {
|
||||
return (StatusCode::NOT_FOUND, "Url not found").into_response();
|
||||
if slug_status == "disabled" {
|
||||
return (StatusCode::GONE, "This content has been disabled").into_response();
|
||||
} else if slug_status != "active" {
|
||||
return (StatusCode::NOT_FOUND, "Not Found").into_response();
|
||||
}
|
||||
|
||||
let user_dbs = match state.get_user_dbs(owner_user_id) {
|
||||
Ok(dbs) => dbs,
|
||||
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
|
||||
};
|
||||
|
||||
let url_opt = {
|
||||
let conn = user_dbs.content.lock().unwrap();
|
||||
match crate::db::content::get_url_by_code(&conn, code) {
|
||||
Ok(u) => u,
|
||||
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
|
||||
}
|
||||
};
|
||||
|
||||
let url = match url_opt {
|
||||
Some(u) => u,
|
||||
None => return (StatusCode::NOT_FOUND, "Url not found").into_response(),
|
||||
};
|
||||
|
||||
// Construct public base URL
|
||||
let proto = if state.config.cookie_secure {
|
||||
"https"
|
||||
@@ -178,7 +159,11 @@ pub async fn qr_handler(
|
||||
.clone()
|
||||
.unwrap_or_else(|| format!("{}://{}", proto, host_header));
|
||||
|
||||
let full_url = format!("{}/{}", base_url.trim_end_matches('/'), code);
|
||||
let full_url = if target_type == "page" {
|
||||
format!("{}/p/{}", base_url.trim_end_matches('/'), code)
|
||||
} else {
|
||||
format!("{}/{}", base_url.trim_end_matches('/'), code)
|
||||
};
|
||||
|
||||
// Generate QR code based on format
|
||||
let (body, content_type) = if ext == "svg" {
|
||||
@@ -211,22 +196,25 @@ pub async fn qr_handler(
|
||||
.into_response();
|
||||
};
|
||||
|
||||
// Log the QR access event
|
||||
let ip = get_client_ip(&headers, connect_info);
|
||||
let user_agent = headers
|
||||
.get("user-agent")
|
||||
.and_then(|h| h.to_str().ok())
|
||||
.map(|s| s.to_string());
|
||||
// Log the QR access event in a try-catch style
|
||||
let _ = {
|
||||
let ip = get_client_ip(&headers, connect_info);
|
||||
let user_agent = headers
|
||||
.get("user-agent")
|
||||
.and_then(|h| h.to_str().ok())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
{
|
||||
let analytics_conn = user_dbs.analytics.lock().unwrap();
|
||||
let _ = crate::db::qr::log_qr_access(
|
||||
&analytics_conn,
|
||||
&url.id,
|
||||
Some(ip.as_str()),
|
||||
user_agent.as_deref(),
|
||||
);
|
||||
}
|
||||
if let Ok(user_dbs) = state.get_user_dbs(owner_user_id) {
|
||||
if let Ok(analytics_conn) = user_dbs.analytics.lock() {
|
||||
let _ = crate::db::qr::log_qr_access(
|
||||
&analytics_conn,
|
||||
&target_id,
|
||||
Some(ip.as_str()),
|
||||
user_agent.as_deref(),
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Response::builder()
|
||||
.header("content-type", content_type)
|
||||
|
||||
+384
-74
@@ -1,20 +1,298 @@
|
||||
//! Public short-code redirect hot path.
|
||||
//!
|
||||
//! Design notes:
|
||||
//! - Destination `Location` headers never panic on malformed values.
|
||||
//! - Content DB work uses a single mutex acquisition where safe.
|
||||
//! - Expiration is enforced on the read path; persistent `expired=1` is left to
|
||||
//! the background expiry job (`jobs::expiry`), not written here.
|
||||
//! - Blocking rusqlite work runs in `spawn_blocking` so Tokio workers are not starved.
|
||||
//! - Analytics enqueue remains non-blocking (`try_send` via the queue).
|
||||
|
||||
use axum::{
|
||||
extract::{ConnectInfo, Path, State},
|
||||
http::{HeaderMap, StatusCode},
|
||||
http::{header, HeaderMap, HeaderValue, StatusCode},
|
||||
response::{IntoResponse, Redirect, Response},
|
||||
};
|
||||
use axum_extra::extract::CookieJar;
|
||||
use chrono::Utc;
|
||||
use rusqlite::OptionalExtension;
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tracing::{error, warn};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::analytics::get_client_country;
|
||||
use crate::models::VisitRecord;
|
||||
use crate::models::{LinkPreview, Url, VisitRecord};
|
||||
use crate::state::AppState;
|
||||
use crate::templates::PreviewTemplate;
|
||||
use crate::utils::get_client_ip;
|
||||
|
||||
/// Compact outcome from blocking redirect DB work (avoids large enum / Result variants).
|
||||
enum ResolveOutcome {
|
||||
Ready(Box<ResolvedUrl>),
|
||||
/// Early HTTP response that does not need further processing.
|
||||
Early {
|
||||
status: StatusCode,
|
||||
body: &'static str,
|
||||
},
|
||||
/// Permanent redirect to a relative path (e.g. page target → `/p/{code}`).
|
||||
PermanentPath(String),
|
||||
DbError {
|
||||
operation: &'static str,
|
||||
message: String,
|
||||
owner_user_id: Option<i64>,
|
||||
resource_id: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
/// Safe client-facing DB error after structured server-side logging.
|
||||
fn db_error_response(
|
||||
operation: &str,
|
||||
code: &str,
|
||||
owner_user_id: Option<i64>,
|
||||
resource_id: Option<&str>,
|
||||
err: impl std::fmt::Display,
|
||||
) -> Response {
|
||||
error!(
|
||||
operation = operation,
|
||||
code = code,
|
||||
owner_user_id = owner_user_id,
|
||||
resource_id = resource_id.unwrap_or(""),
|
||||
error = %err,
|
||||
"redirect path database error"
|
||||
);
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response()
|
||||
}
|
||||
|
||||
/// Build a permanent redirect without panicking on invalid destinations.
|
||||
///
|
||||
/// Defense in depth:
|
||||
/// 1. Canonical destination rules (http/https, no control chars) — same as writes.
|
||||
/// 2. `HeaderValue` construction — rejects remaining illegal header bytes.
|
||||
///
|
||||
/// Neither step may panic. Full destination values are not logged.
|
||||
fn permanent_redirect_to(destination: &str, code: &str) -> Response {
|
||||
if !crate::utils::validation::validate_redirect_destination(destination) {
|
||||
warn!(
|
||||
operation = "validate_redirect_destination",
|
||||
code = code,
|
||||
destination_len = destination.len(),
|
||||
"invalid stored redirect destination rejected"
|
||||
);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Invalid redirect destination",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
match HeaderValue::from_str(destination) {
|
||||
Ok(loc) => {
|
||||
let mut resp = (StatusCode::MOVED_PERMANENTLY, "").into_response();
|
||||
resp.headers_mut().insert(header::LOCATION, loc);
|
||||
resp
|
||||
}
|
||||
Err(err) => {
|
||||
warn!(
|
||||
operation = "build_location_header",
|
||||
code = code,
|
||||
error = %err,
|
||||
// Do not log the full destination if it may contain control chars;
|
||||
// log length only for forensics.
|
||||
destination_len = destination.len(),
|
||||
"invalid redirect destination rejected (possible response-splitting attempt)"
|
||||
);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Invalid redirect destination",
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of the initial global-slug + URL resolution phase.
|
||||
struct ResolvedUrl {
|
||||
owner_user_id: i64,
|
||||
url: Url,
|
||||
content: Arc<Mutex<rusqlite::Connection>>,
|
||||
}
|
||||
|
||||
/// Lookup global slug namespace then load the URL from the tenant content DB.
|
||||
/// Runs entirely on a blocking thread.
|
||||
fn resolve_url_blocking(
|
||||
system_db: Arc<Mutex<rusqlite::Connection>>,
|
||||
state: AppState,
|
||||
code: &str,
|
||||
) -> ResolveOutcome {
|
||||
// 1. Query global slug namespace in system.db
|
||||
let slug_info = {
|
||||
let system_conn = match system_db.lock() {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
return ResolveOutcome::DbError {
|
||||
operation: "lock_system_db",
|
||||
message: e.to_string(),
|
||||
owner_user_id: None,
|
||||
resource_id: None,
|
||||
};
|
||||
}
|
||||
};
|
||||
let mut stmt = match system_conn.prepare(
|
||||
"SELECT owner_user_id, target_type, target_id, status FROM global_slugs WHERE slug = ?1;",
|
||||
) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
return ResolveOutcome::DbError {
|
||||
operation: "prepare_global_slugs",
|
||||
message: e.to_string(),
|
||||
owner_user_id: None,
|
||||
resource_id: None,
|
||||
};
|
||||
}
|
||||
};
|
||||
match stmt
|
||||
.query_row(rusqlite::params![code], |row| {
|
||||
Ok((
|
||||
row.get::<_, i64>(0)?,
|
||||
row.get::<_, String>(1)?,
|
||||
row.get::<_, String>(2)?,
|
||||
row.get::<_, String>(3)?,
|
||||
))
|
||||
})
|
||||
.optional()
|
||||
{
|
||||
Ok(info) => info,
|
||||
Err(e) => {
|
||||
return ResolveOutcome::DbError {
|
||||
operation: "query_global_slugs",
|
||||
message: e.to_string(),
|
||||
owner_user_id: None,
|
||||
resource_id: None,
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let (owner_user_id, target_type, _target_id, slug_status) = match slug_info {
|
||||
Some(info) => info,
|
||||
None => {
|
||||
// Fallback to legacy_admin's DB (user_id = 1) if not found in global_slugs
|
||||
(1, "url".to_string(), "".to_string(), "active".to_string())
|
||||
}
|
||||
};
|
||||
|
||||
// If slug status is disabled, flagged, or soft_deleted, we return 410 Gone
|
||||
if slug_status != "active" {
|
||||
return ResolveOutcome::Early {
|
||||
status: StatusCode::GONE,
|
||||
body: "This content has been disabled or moderated",
|
||||
};
|
||||
}
|
||||
|
||||
// If target type is page, redirect permanently to /p/slug
|
||||
if target_type == "page" {
|
||||
return ResolveOutcome::PermanentPath(format!("/p/{}", code));
|
||||
}
|
||||
|
||||
// 2. Get content database connection via tenant DB resolution
|
||||
let content_conn = match state.get_user_dbs(owner_user_id) {
|
||||
Ok(dbs) => dbs,
|
||||
Err(e) => {
|
||||
return ResolveOutcome::DbError {
|
||||
operation: "get_user_dbs",
|
||||
message: e.to_string(),
|
||||
owner_user_id: Some(owner_user_id),
|
||||
resource_id: None,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
let url = {
|
||||
let conn = match content_conn.content.lock() {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
return ResolveOutcome::DbError {
|
||||
operation: "lock_content_db",
|
||||
message: e.to_string(),
|
||||
owner_user_id: Some(owner_user_id),
|
||||
resource_id: None,
|
||||
};
|
||||
}
|
||||
};
|
||||
match crate::db::content::get_url_by_code(&conn, code) {
|
||||
Ok(Some(url)) => url,
|
||||
Ok(None) => {
|
||||
return ResolveOutcome::Early {
|
||||
status: StatusCode::NOT_FOUND,
|
||||
body: "Short code not found",
|
||||
};
|
||||
}
|
||||
Err(e) => {
|
||||
return ResolveOutcome::DbError {
|
||||
operation: "get_url_by_code",
|
||||
message: e.to_string(),
|
||||
owner_user_id: Some(owner_user_id),
|
||||
resource_id: None,
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
ResolveOutcome::Ready(Box::new(ResolvedUrl {
|
||||
owner_user_id,
|
||||
url,
|
||||
content: content_conn.content,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Increment access count and load preview under a single content-DB lock.
|
||||
fn increment_and_preview_blocking(
|
||||
content: Arc<Mutex<rusqlite::Connection>>,
|
||||
url_id: &str,
|
||||
code: &str,
|
||||
owner_user_id: i64,
|
||||
fallback_access_count: i64,
|
||||
) -> Result<(i64, Option<LinkPreview>), String> {
|
||||
let conn = content
|
||||
.lock()
|
||||
.map_err(|e| format!("lock_content_db_hot: {}", e))?;
|
||||
|
||||
let new_access_count = match crate::db::content::increment_access_count(&conn, url_id) {
|
||||
Ok(n) => n,
|
||||
Err(e) => {
|
||||
// Preserve prior soft-failure semantics for the counter value used only
|
||||
// internally; never silence the underlying error.
|
||||
error!(
|
||||
operation = "increment_access_count",
|
||||
code = code,
|
||||
owner_user_id = owner_user_id,
|
||||
resource_id = url_id,
|
||||
error = %e,
|
||||
"failed to increment access count; continuing with estimated value"
|
||||
);
|
||||
fallback_access_count + 1
|
||||
}
|
||||
};
|
||||
|
||||
let preview = match crate::db::preview::get_preview(&conn, url_id) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
error!(
|
||||
operation = "get_preview",
|
||||
code = code,
|
||||
owner_user_id = owner_user_id,
|
||||
resource_id = url_id,
|
||||
error = %e,
|
||||
"failed to load link preview; continuing without preview"
|
||||
);
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
Ok((new_access_count, preview))
|
||||
}
|
||||
|
||||
// GET /:code
|
||||
// Resolve and redirect
|
||||
pub async fn resolve_redirect(
|
||||
@@ -24,69 +302,58 @@ pub async fn resolve_redirect(
|
||||
headers: HeaderMap,
|
||||
connect_info: Option<ConnectInfo<SocketAddr>>,
|
||||
) -> Response {
|
||||
// Basic validation of code (must be 6 hex characters or a valid custom slug)
|
||||
if !crate::utils::validation::validate_redirect_code(&code) {
|
||||
// Basic validation of code (must be 6 hex characters, 4 hex characters, or a valid custom slug)
|
||||
if !crate::utils::validation::validate_redirect_code(&code)
|
||||
&& !crate::utils::validation::validate_page_code(&code)
|
||||
{
|
||||
return (StatusCode::NOT_FOUND, "Not Found").into_response();
|
||||
}
|
||||
|
||||
// 1. Query global slug namespace in system.db
|
||||
let slug_info = {
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let mut stmt = match system_conn.prepare(
|
||||
"SELECT owner_user_id, target_type, target_id, status FROM global_slugs WHERE slug = ?1;"
|
||||
) {
|
||||
Ok(s) => s,
|
||||
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
|
||||
};
|
||||
stmt.query_row(rusqlite::params![code], |row| {
|
||||
Ok((
|
||||
row.get::<_, i64>(0)?,
|
||||
row.get::<_, String>(1)?,
|
||||
row.get::<_, String>(2)?,
|
||||
row.get::<_, String>(3)?,
|
||||
))
|
||||
})
|
||||
.optional()
|
||||
};
|
||||
let system_db = state.system_db.clone();
|
||||
let state_for_lookup = state.clone();
|
||||
let code_for_lookup = code.clone();
|
||||
|
||||
let (owner_user_id, _target_type, _target_id, slug_status) = match slug_info {
|
||||
Ok(Some(info)) => info,
|
||||
Ok(None) => {
|
||||
// Fallback to legacy_admin's DB (user_id = 1) if not found in global_slugs
|
||||
(1, "url".to_string(), "".to_string(), "active".to_string())
|
||||
}
|
||||
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
|
||||
};
|
||||
|
||||
// If slug status is disabled, flagged, or soft_deleted, we return 410 Gone
|
||||
if slug_status != "active" {
|
||||
return (
|
||||
StatusCode::GONE,
|
||||
"This content has been disabled or moderated",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
// 2. Get user specific database connections
|
||||
let user_dbs = match state.get_user_dbs(owner_user_id) {
|
||||
Ok(dbs) => dbs,
|
||||
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
|
||||
};
|
||||
|
||||
let url_opt = {
|
||||
let conn = user_dbs.content.lock().unwrap();
|
||||
match crate::db::content::get_url_by_code(&conn, &code) {
|
||||
Ok(url) => url,
|
||||
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
|
||||
let outcome = match tokio::task::spawn_blocking(move || {
|
||||
resolve_url_blocking(system_db, state_for_lookup, &code_for_lookup)
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(outcome) => outcome,
|
||||
Err(e) => {
|
||||
return db_error_response("spawn_blocking_resolve", &code, None, None, e.to_string());
|
||||
}
|
||||
};
|
||||
|
||||
let url = match url_opt {
|
||||
Some(u) => u,
|
||||
None => return (StatusCode::NOT_FOUND, "Short code not found").into_response(),
|
||||
let resolved = match outcome {
|
||||
ResolveOutcome::Ready(r) => r,
|
||||
ResolveOutcome::Early { status, body } => return (status, body).into_response(),
|
||||
ResolveOutcome::PermanentPath(path) => {
|
||||
return Redirect::permanent(&path).into_response();
|
||||
}
|
||||
ResolveOutcome::DbError {
|
||||
operation,
|
||||
message,
|
||||
owner_user_id,
|
||||
resource_id,
|
||||
} => {
|
||||
return db_error_response(
|
||||
operation,
|
||||
&code,
|
||||
owner_user_id,
|
||||
resource_id.as_deref(),
|
||||
message,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// 3. Expiration check
|
||||
let ResolvedUrl {
|
||||
owner_user_id,
|
||||
url,
|
||||
content,
|
||||
} = *resolved;
|
||||
|
||||
// 3. Expiration check (read-only on the hot path).
|
||||
// Background job `jobs::expiry::run_expiry_checker` persists expired=1.
|
||||
if url.expired {
|
||||
return (StatusCode::GONE, "This link has expired").into_response();
|
||||
}
|
||||
@@ -94,14 +361,6 @@ pub async fn resolve_redirect(
|
||||
if let Some(ref expires_at_str) = url.expires_at {
|
||||
if let Ok(expires_at) = chrono::DateTime::parse_from_rfc3339(expires_at_str) {
|
||||
if expires_at.with_timezone(&Utc) < Utc::now() {
|
||||
// Mark as expired in DB asynchronously/immediately
|
||||
{
|
||||
let conn = user_dbs.content.lock().unwrap();
|
||||
let _ = conn.execute(
|
||||
"UPDATE urls SET expired = 1 WHERE id = ?1;",
|
||||
[url.id.clone()],
|
||||
);
|
||||
}
|
||||
return (StatusCode::GONE, "This link has expired").into_response();
|
||||
}
|
||||
}
|
||||
@@ -129,15 +388,40 @@ pub async fn resolve_redirect(
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Increment access count & retrieve preview config
|
||||
let _new_access_count = {
|
||||
let conn = user_dbs.content.lock().unwrap();
|
||||
crate::db::content::increment_access_count(&conn, &url.id).unwrap_or(url.access_count + 1)
|
||||
};
|
||||
|
||||
let preview_opt = {
|
||||
let conn = user_dbs.content.lock().unwrap();
|
||||
crate::db::preview::get_preview(&conn, &url.id).unwrap_or(None)
|
||||
// 6. Increment access count & retrieve preview config (single content lock, off executor)
|
||||
let url_id = url.id.clone();
|
||||
let code_for_hot = code.clone();
|
||||
let fallback_access_count = url.access_count;
|
||||
let preview_opt = match tokio::task::spawn_blocking(move || {
|
||||
increment_and_preview_blocking(
|
||||
content,
|
||||
&url_id,
|
||||
&code_for_hot,
|
||||
owner_user_id,
|
||||
fallback_access_count,
|
||||
)
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(Ok((_new_access_count, preview))) => preview,
|
||||
Ok(Err(msg)) => {
|
||||
return db_error_response(
|
||||
"increment_and_preview",
|
||||
&code,
|
||||
Some(owner_user_id),
|
||||
Some(&url.id),
|
||||
msg,
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
return db_error_response(
|
||||
"spawn_blocking_hot",
|
||||
&code,
|
||||
Some(owner_user_id),
|
||||
Some(&url.id),
|
||||
e.to_string(),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Asynchronously record analytics
|
||||
@@ -188,6 +472,32 @@ pub async fn resolve_redirect(
|
||||
}
|
||||
.into_response()
|
||||
} else {
|
||||
Redirect::temporary(&url.destination).into_response()
|
||||
permanent_redirect_to(&url.destination, &code)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn permanent_redirect_rejects_crlf() {
|
||||
let resp = permanent_redirect_to("https://evil.example/\r\nX-Injected: yes", "abc123");
|
||||
assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
|
||||
assert!(resp.headers().get(header::LOCATION).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn permanent_redirect_rejects_control_chars() {
|
||||
let resp = permanent_redirect_to("https://evil.example/\x00payload", "abc123");
|
||||
assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn permanent_redirect_accepts_valid_url() {
|
||||
let resp = permanent_redirect_to("https://example.com/path?q=1", "abc123");
|
||||
assert_eq!(resp.status(), StatusCode::MOVED_PERMANENTLY);
|
||||
let loc = resp.headers().get(header::LOCATION).unwrap();
|
||||
assert_eq!(loc, "https://example.com/path?q=1");
|
||||
}
|
||||
}
|
||||
@@ -52,10 +52,26 @@ pub fn create_router(state: AppState) -> Router {
|
||||
"/user/analytics/url/:id",
|
||||
get(admin::user_url_analytics_get),
|
||||
)
|
||||
.route(
|
||||
"/user/analytics/url/:id/export/csv",
|
||||
get(admin::user_url_analytics_csv_export),
|
||||
)
|
||||
.route(
|
||||
"/user/analytics/url/:id/export/json",
|
||||
get(admin::user_url_analytics_json_export),
|
||||
)
|
||||
.route(
|
||||
"/user/analytics/page/:id",
|
||||
get(admin::user_page_analytics_get),
|
||||
)
|
||||
.route(
|
||||
"/user/analytics/page/:id/export/csv",
|
||||
get(admin::user_page_analytics_csv_export),
|
||||
)
|
||||
.route(
|
||||
"/user/analytics/page/:id/export/json",
|
||||
get(admin::user_page_analytics_json_export),
|
||||
)
|
||||
.route("/api-tokens", get(admin::api_tokens_get))
|
||||
.route("/api-tokens/create", post(admin::api_tokens_create_post))
|
||||
.route(
|
||||
@@ -79,6 +95,7 @@ pub fn create_router(state: AppState) -> Router {
|
||||
.route("/admin/pages/delete/:id", post(admin::pages_delete))
|
||||
.route("/admin/analytics/url/:id", get(admin::url_analytics_get))
|
||||
.route("/deploy.sh", get(pages::deploy_script))
|
||||
.route("/images/preview.png", get(pages::social_preview))
|
||||
.route(
|
||||
"/admin/analytics/url/:id/export/csv",
|
||||
get(admin::url_analytics_csv_export),
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
<td style="text-align: center; vertical-align: middle;">
|
||||
<a href="/api/qr/{{ code }}.png" target="_blank" title="View QR Code">
|
||||
<img src="/api/qr/{{ code }}.svg" alt="QR" style="width: 32px; height: 32px; border-radius: 4px; border: 1px solid var(--border-color); background: white; padding: 1px;">
|
||||
</a>
|
||||
<div style="margin-top: 0.25rem; display: flex; gap: 0.25rem; justify-content: center;">
|
||||
<a href="/api/qr/{{ code }}.png" download class="badge" style="font-size: 0.65rem; background-color: rgba(99, 102, 241, 0.1); color: #818cf8; text-decoration: none; padding: 0.1rem 0.25rem;">PNG</a>
|
||||
<a href="/api/qr/{{ code }}.svg" download class="badge" style="font-size: 0.65rem; background-color: rgba(99, 102, 241, 0.1); color: #818cf8; text-decoration: none; padding: 0.1rem 0.25rem;">SVG</a>
|
||||
</div>
|
||||
</td>
|
||||
@@ -7,6 +7,38 @@
|
||||
{% block header_title %}System Health Dashboard{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% if !registry_errors.is_empty() || !registry_warnings.is_empty() %}
|
||||
<div style="display: grid; grid-template-columns: 1fr; gap: 1rem; margin-bottom: 1.5rem;">
|
||||
{% if !registry_errors.is_empty() %}
|
||||
<div class="card" style="border: 1px solid var(--dead-color); background-color: rgba(220, 53, 69, 0.1); padding: 1.5rem;">
|
||||
<h3 style="font-size: 1.15rem; color: var(--dead-color); display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.5rem; margin-top: 0;">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
|
||||
Global Registry Errors (Action Required)
|
||||
</h3>
|
||||
<ul style="margin: 0; padding-left: 1.5rem; color: var(--text-primary); display: flex; flex-direction: column; gap: 0.25rem;">
|
||||
{% for err in registry_errors %}
|
||||
<li>{{ err }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if !registry_warnings.is_empty() %}
|
||||
<div class="card" style="border: 1px solid #ffc107; background-color: rgba(255, 193, 7, 0.1); padding: 1.5rem;">
|
||||
<h3 style="font-size: 1.15rem; color: #ffc107; display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.5rem; margin-top: 0;">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
|
||||
Global Registry Warnings (Attention Needed)
|
||||
</h3>
|
||||
<ul style="margin: 0; padding-left: 1.5rem; color: var(--text-primary); display: flex; flex-direction: column; gap: 0.25rem;">
|
||||
{% for warn in registry_warnings %}
|
||||
<li>{{ warn }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; align-items: start; margin-bottom: 1.5rem;">
|
||||
<!-- DB Health Report -->
|
||||
<div class="card" style="padding: 0; overflow: hidden;">
|
||||
|
||||
+104
-10
@@ -4,17 +4,111 @@
|
||||
|
||||
{% block active_pages %}active{% endblock %}
|
||||
|
||||
{% block sidebar_links %}
|
||||
{% if is_admin %}
|
||||
<li class="{% block active_dashboard %}{% endblock %}">
|
||||
<a href="/admin/dashboard">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="9"/><rect x="14" y="3" width="7" height="5"/><rect x="14" y="12" width="7" height="9"/><rect x="3" y="16" width="7" height="5"/></svg>
|
||||
Dashboard
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/admin/urls">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>
|
||||
Short URLs
|
||||
</a>
|
||||
</li>
|
||||
<li class="active">
|
||||
<a href="/admin/pages">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
|
||||
Landing Pages
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/admin/users">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
|
||||
Users Management
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/admin/settings">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1-1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
|
||||
Settings
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/admin/audit">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>
|
||||
Audit Log
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/admin/status">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="22" y1="12" x2="18" y2="12"/><line x1="6" y1="12" x2="2" y2="12"/><polyline points="10 6 14 12 10 18"/><line x1="18" y1="12" x2="14" y2="12"/><line x1="6" y1="12" x2="10" y2="12"/></svg>
|
||||
Status
|
||||
</a>
|
||||
</li>
|
||||
{% else %}
|
||||
<li>
|
||||
<a href="/user/dashboard">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="9"/><rect x="14" y="3" width="7" height="5"/><rect x="14" y="12" width="7" height="9"/><rect x="3" y="16" width="7" height="5"/></svg>
|
||||
Dashboard
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/user/urls">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>
|
||||
Short URLs
|
||||
</a>
|
||||
</li>
|
||||
<li class="active">
|
||||
<a href="/user/pages">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
|
||||
Landing Pages
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/user/settings">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
|
||||
Settings
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/user/audit">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>
|
||||
Audit Log
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/user/status">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="22" y1="12" x2="18" y2="12"/><line x1="6" y1="12" x2="2" y2="12"/><polyline points="10 6 14 12 10 18"/><line x1="18" y1="12" x2="14" y2="12"/><line x1="6" y1="12" x2="10" y2="12"/></svg>
|
||||
Status
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block sidebar_footer %}
|
||||
<div class="sidebar-footer">
|
||||
<div class="admin-user-info">
|
||||
<div class="avatar">{{ admin_username[0..1].to_uppercase() }}</div>
|
||||
<span>{{ admin_username }}</span>
|
||||
</div>
|
||||
<a href="{% if is_admin %}/admin/logout{% else %}/logout{% endif %}" class="logout-btn">Log Out</a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block header_title %}Page Analytics: /p/{{ page.code }}{% endblock %}
|
||||
|
||||
{% block header_actions %}
|
||||
<div style="display: flex; gap: 0.5rem;">
|
||||
<a href="/admin/analytics/page/{{ page.id }}/export/csv?date_from={{ date_from.as_deref().unwrap_or("") }}&date_to={{ date_to.as_deref().unwrap_or("") }}" class="btn btn-secondary" style="padding: 0.5rem 1rem; font-size: 0.9rem; display: inline-flex; align-items: center; gap: 0.25rem;">
|
||||
<a href="{% if is_admin %}/admin/analytics/page/{{ page.id }}/export/csv{% else %}/user/analytics/page/{{ page.id }}/export/csv{% endif %}?date_from={{ date_from.as_deref().unwrap_or("") }}&date_to={{ date_to.as_deref().unwrap_or("") }}" class="btn btn-secondary" style="padding: 0.5rem 1rem; font-size: 0.9rem; display: inline-flex; align-items: center; gap: 0.25rem;">
|
||||
📥 Export CSV
|
||||
</a>
|
||||
<a href="/admin/analytics/page/{{ page.id }}/export/json?date_from={{ date_from.as_deref().unwrap_or("") }}&date_to={{ date_to.as_deref().unwrap_or("") }}" class="btn btn-secondary" style="padding: 0.5rem 1rem; font-size: 0.9rem; display: inline-flex; align-items: center; gap: 0.25rem;">
|
||||
<a href="{% if is_admin %}/admin/analytics/page/{{ page.id }}/export/json{% else %}/user/analytics/page/{{ page.id }}/export/json{% endif %}?date_from={{ date_from.as_deref().unwrap_or("") }}&date_to={{ date_to.as_deref().unwrap_or("") }}" class="btn btn-secondary" style="padding: 0.5rem 1rem; font-size: 0.9rem; display: inline-flex; align-items: center; gap: 0.25rem;">
|
||||
📥 Export JSON
|
||||
</a>
|
||||
<a href="/admin/pages" class="btn btn-secondary" style="padding: 0.5rem 1rem; font-size: 0.9rem; display: inline-flex; align-items: center; gap: 0.25rem;">
|
||||
<a href="{% if is_admin %}/admin/pages{% else %}/user/pages{% endif %}" class="btn btn-secondary" style="padding: 0.5rem 1rem; font-size: 0.9rem; display: inline-flex; align-items: center; gap: 0.25rem;">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg>
|
||||
Back to Landing Pages
|
||||
</a>
|
||||
@@ -24,7 +118,7 @@
|
||||
{% block content %}
|
||||
<!-- Date Filter Form -->
|
||||
<div class="card" style="margin-bottom: 2rem;">
|
||||
<form method="GET" action="/admin/analytics/page/{{ page.id }}" style="display: flex; flex-wrap: wrap; gap: 1rem; align-items: flex-end;">
|
||||
<form method="GET" action="{% if is_admin %}/admin/analytics/page/{{ page.id }}{% else %}/user/analytics/page/{{ page.id }}{% endif %}" style="display: flex; flex-wrap: wrap; gap: 1rem; align-items: flex-end;">
|
||||
<div class="form-group" style="margin: 0; flex: 1; min-width: 150px;">
|
||||
<label for="date_from" style="margin-bottom: 0.25rem; font-size: 0.85rem;">Date From</label>
|
||||
<input type="date" id="date_from" name="date_from" class="form-input" value="{{ date_from.as_deref().unwrap_or("") }}" style="padding: 0.4rem 0.6rem;">
|
||||
@@ -34,7 +128,7 @@
|
||||
<input type="date" id="date_to" name="date_to" class="form-input" value="{{ date_to.as_deref().unwrap_or("") }}" style="padding: 0.4rem 0.6rem;">
|
||||
</div>
|
||||
<button type="submit" class="btn" style="padding: 0.45rem 1.25rem; font-size: 0.9rem;">Apply Filters</button>
|
||||
<a href="/admin/analytics/page/{{ page.id }}" class="btn btn-secondary" style="padding: 0.45rem 1.25rem; font-size: 0.9rem; text-decoration: none; display: inline-flex; align-items: center; justify-content: center;">Clear</a>
|
||||
<a href="{% if is_admin %}/admin/analytics/page/{{ page.id }}{% else %}/user/analytics/page/{{ page.id }}{% endif %}" class="btn btn-secondary" style="padding: 0.45rem 1.25rem; font-size: 0.9rem; text-decoration: none; display: inline-flex; align-items: center; justify-content: center;">Clear</a>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -195,8 +289,8 @@
|
||||
|
||||
<div class="pagination" style="display: flex; justify-content: center; align-items: center; gap: 0.5rem;">
|
||||
{% if current_page > 1 %}
|
||||
<a href="/admin/analytics/page/{{ page.id }}?analytics_page=1&date_from={{ date_from.as_deref().unwrap_or("") }}&date_to={{ date_to.as_deref().unwrap_or("") }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem;"><< First</a>
|
||||
<a href="/admin/analytics/page/{{ page.id }}?analytics_page={{ current_page - 1 }}&date_from={{ date_from.as_deref().unwrap_or("") }}&date_to={{ date_to.as_deref().unwrap_or("") }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem;">< Prev</a>
|
||||
<a href="{% if is_admin %}/admin/analytics/page/{{ page.id }}{% else %}/user/analytics/page/{{ page.id }}{% endif %}?analytics_page=1&date_from={{ date_from.as_deref().unwrap_or("") }}&date_to={{ date_to.as_deref().unwrap_or("") }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem;"><< First</a>
|
||||
<a href="{% if is_admin %}/admin/analytics/page/{{ page.id }}{% else %}/user/analytics/page/{{ page.id }}{% endif %}?analytics_page={{ current_page - 1 }}&date_from={{ date_from.as_deref().unwrap_or("") }}&date_to={{ date_to.as_deref().unwrap_or("") }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem;">< Prev</a>
|
||||
{% else %}
|
||||
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem; font-size: 0.85rem;"><< First</span>
|
||||
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem; font-size: 0.85rem;">< Prev</span>
|
||||
@@ -206,13 +300,13 @@
|
||||
{% if self.is_current(p) %}
|
||||
<span class="btn btn-primary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem; font-weight: bold;">[{{ p }}]</span>
|
||||
{% else %}
|
||||
<a href="/admin/analytics/page/{{ page.id }}?analytics_page={{ p }}&date_from={{ date_from.as_deref().unwrap_or("") }}&date_to={{ date_to.as_deref().unwrap_or("") }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem;">{{ p }}</a>
|
||||
<a href="{% if is_admin %}/admin/analytics/page/{{ page.id }}{% else %}/user/analytics/page/{{ page.id }}{% endif %}?analytics_page={{ p }}&date_from={{ date_from.as_deref().unwrap_or("") }}&date_to={{ date_to.as_deref().unwrap_or("") }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem;">{{ p }}</a>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% if current_page < total_pages %}
|
||||
<a href="/admin/analytics/page/{{ page.id }}?analytics_page={{ current_page + 1 }}&date_from={{ date_from.as_deref().unwrap_or("") }}&date_to={{ date_to.as_deref().unwrap_or("") }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem;">Next ></a>
|
||||
<a href="/admin/analytics/page/{{ page.id }}?analytics_page={{ total_pages }}&date_from={{ date_from.as_deref().unwrap_or("") }}&date_to={{ date_to.as_deref().unwrap_or("") }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem;">Last >></a>
|
||||
<a href="{% if is_admin %}/admin/analytics/page/{{ page.id }}{% else %}/user/analytics/page/{{ page.id }}{% endif %}?analytics_page={{ current_page + 1 }}&date_from={{ date_from.as_deref().unwrap_or("") }}&date_to={{ date_to.as_deref().unwrap_or("") }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem;">Next ></a>
|
||||
<a href="{% if is_admin %}/admin/analytics/page/{{ page.id }}{% else %}/user/analytics/page/{{ page.id }}{% endif %}?analytics_page={{ total_pages }}&date_from={{ date_from.as_deref().unwrap_or("") }}&date_to={{ date_to.as_deref().unwrap_or("") }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem;">Last >></a>
|
||||
{% else %}
|
||||
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem; font-size: 0.85rem;">Next ></span>
|
||||
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem; font-size: 0.85rem;">Last >></span>
|
||||
|
||||
@@ -84,6 +84,7 @@
|
||||
<th>SEO Preview Path</th>
|
||||
<th>Status</th>
|
||||
<th>Analytics</th>
|
||||
<th>QR Code</th>
|
||||
<th>Created</th>
|
||||
<th>Action</th>
|
||||
</tr>
|
||||
@@ -91,7 +92,7 @@
|
||||
<tbody>
|
||||
{% if pages.is_empty() %}
|
||||
<tr>
|
||||
<td colspan="7" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
|
||||
<td colspan="8" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
|
||||
No landing pages registered. Create one to get started!
|
||||
</td>
|
||||
</tr>
|
||||
@@ -126,6 +127,8 @@
|
||||
📊 Analytics
|
||||
</a>
|
||||
</td>
|
||||
{% let code = page.code.as_str() %}
|
||||
{% include "components/qr_preview.html" %}
|
||||
<td style="font-size: 0.8rem; color: var(--text-secondary);">
|
||||
{{ page.created_at[0..10] }}
|
||||
</td>
|
||||
|
||||
+104
-10
@@ -4,17 +4,111 @@
|
||||
|
||||
{% block active_urls %}active{% endblock %}
|
||||
|
||||
{% block sidebar_links %}
|
||||
{% if is_admin %}
|
||||
<li class="{% block active_dashboard %}{% endblock %}">
|
||||
<a href="/admin/dashboard">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="9"/><rect x="14" y="3" width="7" height="5"/><rect x="14" y="12" width="7" height="9"/><rect x="3" y="16" width="7" height="5"/></svg>
|
||||
Dashboard
|
||||
</a>
|
||||
</li>
|
||||
<li class="active">
|
||||
<a href="/admin/urls">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>
|
||||
Short URLs
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/admin/pages">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
|
||||
Landing Pages
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/admin/users">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
|
||||
Users Management
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/admin/settings">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
|
||||
Settings
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/admin/audit">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>
|
||||
Audit Log
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/admin/status">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="22" y1="12" x2="18" y2="12"/><line x1="6" y1="12" x2="2" y2="12"/><polyline points="10 6 14 12 10 18"/><line x1="18" y1="12" x2="14" y2="12"/><line x1="6" y1="12" x2="10" y2="12"/></svg>
|
||||
Status
|
||||
</a>
|
||||
</li>
|
||||
{% else %}
|
||||
<li>
|
||||
<a href="/user/dashboard">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="9"/><rect x="14" y="3" width="7" height="5"/><rect x="14" y="12" width="7" height="9"/><rect x="3" y="16" width="7" height="5"/></svg>
|
||||
Dashboard
|
||||
</a>
|
||||
</li>
|
||||
<li class="active">
|
||||
<a href="/user/urls">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>
|
||||
Short URLs
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/user/pages">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
|
||||
Landing Pages
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/user/settings">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
|
||||
Settings
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/user/audit">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>
|
||||
Audit Log
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/user/status">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="22" y1="12" x2="18" y2="12"/><line x1="6" y1="12" x2="2" y2="12"/><polyline points="10 6 14 12 10 18"/><line x1="18" y1="12" x2="14" y2="12"/><line x1="6" y1="12" x2="10" y2="12"/></svg>
|
||||
Status
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block sidebar_footer %}
|
||||
<div class="sidebar-footer">
|
||||
<div class="admin-user-info">
|
||||
<div class="avatar">{{ admin_username[0..1].to_uppercase() }}</div>
|
||||
<span>{{ admin_username }}</span>
|
||||
</div>
|
||||
<a href="{% if is_admin %}/admin/logout{% else %}/logout{% endif %}" class="logout-btn">Log Out</a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block header_title %}URL Analytics: /{{ url.code }}{% endblock %}
|
||||
|
||||
{% block header_actions %}
|
||||
<div style="display: flex; gap: 0.5rem;">
|
||||
<a href="/admin/analytics/url/{{ url.id }}/export/csv?date_from={{ date_from.as_deref().unwrap_or("") }}&date_to={{ date_to.as_deref().unwrap_or("") }}" class="btn btn-secondary" style="padding: 0.5rem 1rem; font-size: 0.9rem; display: inline-flex; align-items: center; gap: 0.25rem;">
|
||||
<a href="{% if is_admin %}/admin/analytics/url/{{ url.id }}/export/csv{% else %}/user/analytics/url/{{ url.id }}/export/csv{% endif %}?date_from={{ date_from.as_deref().unwrap_or("") }}&date_to={{ date_to.as_deref().unwrap_or("") }}" class="btn btn-secondary" style="padding: 0.5rem 1rem; font-size: 0.9rem; display: inline-flex; align-items: center; gap: 0.25rem;">
|
||||
📥 Export CSV
|
||||
</a>
|
||||
<a href="/admin/analytics/url/{{ url.id }}/export/json?date_from={{ date_from.as_deref().unwrap_or("") }}&date_to={{ date_to.as_deref().unwrap_or("") }}" class="btn btn-secondary" style="padding: 0.5rem 1rem; font-size: 0.9rem; display: inline-flex; align-items: center; gap: 0.25rem;">
|
||||
<a href="{% if is_admin %}/admin/analytics/url/{{ url.id }}/export/json{% else %}/user/analytics/url/{{ url.id }}/export/json{% endif %}?date_from={{ date_from.as_deref().unwrap_or("") }}&date_to={{ date_to.as_deref().unwrap_or("") }}" class="btn btn-secondary" style="padding: 0.5rem 1rem; font-size: 0.9rem; display: inline-flex; align-items: center; gap: 0.25rem;">
|
||||
📥 Export JSON
|
||||
</a>
|
||||
<a href="/admin/urls" class="btn btn-secondary" style="padding: 0.5rem 1rem; font-size: 0.9rem; display: inline-flex; align-items: center; gap: 0.25rem;">
|
||||
<a href="{% if is_admin %}/admin/urls{% else %}/user/urls{% endif %}" class="btn btn-secondary" style="padding: 0.5rem 1rem; font-size: 0.9rem; display: inline-flex; align-items: center; gap: 0.25rem;">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg>
|
||||
Back to URLs
|
||||
</a>
|
||||
@@ -24,7 +118,7 @@
|
||||
{% block content %}
|
||||
<!-- Date Filter Form -->
|
||||
<div class="card" style="margin-bottom: 2rem;">
|
||||
<form method="GET" action="/admin/analytics/url/{{ url.id }}" style="display: flex; flex-wrap: wrap; gap: 1rem; align-items: flex-end;">
|
||||
<form method="GET" action="{% if is_admin %}/admin/analytics/url/{{ url.id }}{% else %}/user/analytics/url/{{ url.id }}{% endif %}" style="display: flex; flex-wrap: wrap; gap: 1rem; align-items: flex-end;">
|
||||
<div class="form-group" style="margin: 0; flex: 1; min-width: 150px;">
|
||||
<label for="date_from" style="margin-bottom: 0.25rem; font-size: 0.85rem;">Date From</label>
|
||||
<input type="date" id="date_from" name="date_from" class="form-input" value="{{ date_from.as_deref().unwrap_or("") }}" style="padding: 0.4rem 0.6rem;">
|
||||
@@ -34,7 +128,7 @@
|
||||
<input type="date" id="date_to" name="date_to" class="form-input" value="{{ date_to.as_deref().unwrap_or("") }}" style="padding: 0.4rem 0.6rem;">
|
||||
</div>
|
||||
<button type="submit" class="btn" style="padding: 0.45rem 1.25rem; font-size: 0.9rem;">Apply Filters</button>
|
||||
<a href="/admin/analytics/url/{{ url.id }}" class="btn btn-secondary" style="padding: 0.45rem 1.25rem; font-size: 0.9rem; text-decoration: none; display: inline-flex; align-items: center; justify-content: center;">Clear</a>
|
||||
<a href="{% if is_admin %}/admin/analytics/url/{{ url.id }}{% else %}/user/analytics/url/{{ url.id }}{% endif %}" class="btn btn-secondary" style="padding: 0.45rem 1.25rem; font-size: 0.9rem; text-decoration: none; display: inline-flex; align-items: center; justify-content: center;">Clear</a>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -224,8 +318,8 @@
|
||||
|
||||
<div class="pagination" style="display: flex; justify-content: center; align-items: center; gap: 0.5rem;">
|
||||
{% if current_page > 1 %}
|
||||
<a href="/admin/analytics/url/{{ url.id }}?analytics_page=1&date_from={{ date_from.as_deref().unwrap_or("") }}&date_to={{ date_to.as_deref().unwrap_or("") }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem;"><< First</a>
|
||||
<a href="/admin/analytics/url/{{ url.id }}?analytics_page={{ current_page - 1 }}&date_from={{ date_from.as_deref().unwrap_or("") }}&date_to={{ date_to.as_deref().unwrap_or("") }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem;">< Prev</a>
|
||||
<a href="{% if is_admin %}/admin/analytics/url/{{ url.id }}{% else %}/user/analytics/url/{{ url.id }}{% endif %}?analytics_page=1&date_from={{ date_from.as_deref().unwrap_or("") }}&date_to={{ date_to.as_deref().unwrap_or("") }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem;"><< First</a>
|
||||
<a href="{% if is_admin %}/admin/analytics/url/{{ url.id }}{% else %}/user/analytics/url/{{ url.id }}{% endif %}?analytics_page={{ current_page - 1 }}&date_from={{ date_from.as_deref().unwrap_or("") }}&date_to={{ date_to.as_deref().unwrap_or("") }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem;">< Prev</a>
|
||||
{% else %}
|
||||
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem; font-size: 0.85rem;"><< First</span>
|
||||
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem; font-size: 0.85rem;">< Prev</span>
|
||||
@@ -235,13 +329,13 @@
|
||||
{% if self.is_current(p) %}
|
||||
<span class="btn btn-primary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem; font-weight: bold;">[{{ p }}]</span>
|
||||
{% else %}
|
||||
<a href="/admin/analytics/url/{{ url.id }}?analytics_page={{ p }}&date_from={{ date_from.as_deref().unwrap_or("") }}&date_to={{ date_to.as_deref().unwrap_or("") }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem;">{{ p }}</a>
|
||||
<a href="{% if is_admin %}/admin/analytics/url/{{ url.id }}{% else %}/user/analytics/url/{{ url.id }}{% endif %}?analytics_page={{ p }}&date_from={{ date_from.as_deref().unwrap_or("") }}&date_to={{ date_to.as_deref().unwrap_or("") }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem;">{{ p }}</a>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% if current_page < total_pages %}
|
||||
<a href="/admin/analytics/url/{{ url.id }}?analytics_page={{ current_page + 1 }}&date_from={{ date_from.as_deref().unwrap_or("") }}&date_to={{ date_to.as_deref().unwrap_or("") }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem;">Next ></a>
|
||||
<a href="/admin/analytics/url/{{ url.id }}?analytics_page={{ total_pages }}&date_from={{ date_from.as_deref().unwrap_or("") }}&date_to={{ date_to.as_deref().unwrap_or("") }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem;">Last >></a>
|
||||
<a href="{% if is_admin %}/admin/analytics/url/{{ url.id }}{% else %}/user/analytics/url/{{ url.id }}{% endif %}?analytics_page={{ current_page + 1 }}&date_from={{ date_from.as_deref().unwrap_or("") }}&date_to={{ date_to.as_deref().unwrap_or("") }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem;">Next ></a>
|
||||
<a href="{% if is_admin %}/admin/analytics/url/{{ url.id }}{% else %}/user/analytics/url/{{ url.id }}{% endif %}?analytics_page={{ total_pages }}&date_from={{ date_from.as_deref().unwrap_or("") }}&date_to={{ date_to.as_deref().unwrap_or("") }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem;">Last >></a>
|
||||
{% else %}
|
||||
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem; font-size: 0.85rem;">Next ></span>
|
||||
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem; font-size: 0.85rem;">Last >></span>
|
||||
|
||||
+2
-9
@@ -191,15 +191,8 @@
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td style="text-align: center; vertical-align: middle;">
|
||||
<a href="/api/qr/{{ url.code }}.png" target="_blank" title="View QR Code">
|
||||
<img src="/api/qr/{{ url.code }}.svg" alt="QR" style="width: 32px; height: 32px; border-radius: 4px; border: 1px solid var(--border-color); background: white; padding: 1px;">
|
||||
</a>
|
||||
<div style="margin-top: 0.25rem; display: flex; gap: 0.25rem; justify-content: center;">
|
||||
<a href="/api/qr/{{ url.code }}.png" download class="badge" style="font-size: 0.65rem; background-color: rgba(99, 102, 241, 0.1); color: #818cf8; text-decoration: none; padding: 0.1rem 0.25rem;">PNG</a>
|
||||
<a href="/api/qr/{{ url.code }}.svg" download class="badge" style="font-size: 0.65rem; background-color: rgba(99, 102, 241, 0.1); color: #818cf8; text-decoration: none; padding: 0.1rem 0.25rem;">SVG</a>
|
||||
</div>
|
||||
</td>
|
||||
{% let code = url.code.as_str() %}
|
||||
{% include "components/qr_preview.html" %}
|
||||
<td>
|
||||
<span class="badge badge-{{ url.status }}">
|
||||
{{ url.status }}
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
{% extends "user_layout.html" %}
|
||||
|
||||
{% block title %}Landing Page Analytics - /p/{{ page_code }} - BZOD{% endblock %}
|
||||
|
||||
{% block active_pages %}active{% endblock %}
|
||||
|
||||
{% block header_title %}Landing Page Analytics: /p/{{ page_code }}{% endblock %}
|
||||
|
||||
{% block header_actions %}
|
||||
<a href="/user/pages" class="btn btn-secondary" style="padding: 0.5rem 1rem; font-size: 0.9rem; display: inline-flex; align-items: center; gap: 0.25rem;">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg>
|
||||
Back to Landing Pages
|
||||
</a>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<!-- Page Details Card -->
|
||||
<div class="card" style="margin-bottom: 2rem;">
|
||||
<h3 style="font-size: 1.1rem; margin-bottom: 1rem; color: var(--text-primary); display: flex; align-items: center; gap: 0.5rem;">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
|
||||
Landing Page Details
|
||||
</h3>
|
||||
<div>
|
||||
<span style="font-size: 0.8rem; color: var(--text-secondary); text-transform: uppercase; letter-spacing: 0.5px; display: block; margin-bottom: 0.25rem;">Page Title</span>
|
||||
<span style="font-weight: 600; font-size: 1.1rem; color: var(--text-primary);">{{ title }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Visitor Activity Log -->
|
||||
<div class="card" style="padding: 0; overflow: hidden;">
|
||||
<div style="padding: 1.25rem 1.5rem; border-bottom: 1px solid var(--border-color);">
|
||||
<h3 style="font-size: 1.1rem; display: flex; align-items: center; gap: 0.5rem; margin: 0;">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
|
||||
Visitor Activity Log
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Sr</th>
|
||||
<th>Timestamp</th>
|
||||
<th>IP Address</th>
|
||||
<th>Country</th>
|
||||
<th>Referrer</th>
|
||||
<th>Browser</th>
|
||||
<th>User-Agent</th>
|
||||
<th>UTM Source</th>
|
||||
<th>UTM Campaign</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% if visits.is_empty() %}
|
||||
<tr>
|
||||
<td colspan="9" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
|
||||
No visitor activity available for this landing page.
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
{% for entry in visits %}
|
||||
<tr>
|
||||
<td>{{ entry.sr }}</td>
|
||||
<td style="font-family: monospace; font-size: 0.85rem; white-space: nowrap;">{{ entry.timestamp }}</td>
|
||||
<td style="font-family: monospace; font-size: 0.85rem;">{{ entry.ip_address }}</td>
|
||||
<td>
|
||||
{% if entry.country == "Unknown" %}
|
||||
<span style="color: var(--text-muted);">Unknown</span>
|
||||
{% else %}
|
||||
{{ entry.country }}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ entry.referrer }}</td>
|
||||
<td>{{ entry.browser }}</td>
|
||||
<td style="max-width: 250px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 0.8rem; color: var(--text-secondary);" title="{{ entry.user_agent }}">
|
||||
{{ entry.user_agent }}
|
||||
</td>
|
||||
<td>{{ entry.utm_source }}</td>
|
||||
<td>{{ entry.utm_campaign }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -72,6 +72,7 @@
|
||||
<th>SEO Preview Path</th>
|
||||
<th>Status</th>
|
||||
<th>Analytics</th>
|
||||
<th>QR Code</th>
|
||||
<th>Created</th>
|
||||
<th>Action</th>
|
||||
</tr>
|
||||
@@ -79,7 +80,7 @@
|
||||
<tbody>
|
||||
{% if pages.is_empty() %}
|
||||
<tr>
|
||||
<td colspan="7" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
|
||||
<td colspan="8" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
|
||||
No landing pages created yet.
|
||||
</td>
|
||||
</tr>
|
||||
@@ -114,6 +115,8 @@
|
||||
📊 Analytics
|
||||
</a>
|
||||
</td>
|
||||
{% let code = page.code.as_str() %}
|
||||
{% include "components/qr_preview.html" %}
|
||||
<td style="font-size: 0.8rem; color: var(--text-secondary);">
|
||||
{{ page.created_at[0..10] }}
|
||||
</td>
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
{% extends "user_layout.html" %}
|
||||
|
||||
{% block title %}Short URL Analytics - /{{ url_code }} - BZOD{% endblock %}
|
||||
|
||||
{% block active_urls %}active{% endblock %}
|
||||
|
||||
{% block header_title %}URL Analytics: /{{ url_code }}{% endblock %}
|
||||
|
||||
{% block header_actions %}
|
||||
<a href="/user/urls" class="btn btn-secondary" style="padding: 0.5rem 1rem; font-size: 0.9rem; display: inline-flex; align-items: center; gap: 0.25rem;">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg>
|
||||
Back to URLs
|
||||
</a>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<!-- Link Details Card -->
|
||||
<div class="card" style="margin-bottom: 2rem;">
|
||||
<h3 style="font-size: 1.1rem; margin-bottom: 1rem; color: var(--text-primary); display: flex; align-items: center; gap: 0.5rem;">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>
|
||||
Link Details
|
||||
</h3>
|
||||
<div>
|
||||
<span style="font-size: 0.8rem; color: var(--text-secondary); text-transform: uppercase; letter-spacing: 0.5px; display: block; margin-bottom: 0.25rem;">Destination URL</span>
|
||||
<a href="{{ destination }}" target="_blank" style="color: var(--accent-color); text-decoration: none; font-weight: 600; word-break: break-all;">
|
||||
{{ destination }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Visitor Activity Log -->
|
||||
<div class="card" style="padding: 0; overflow: hidden;">
|
||||
<div style="padding: 1.25rem 1.5rem; border-bottom: 1px solid var(--border-color);">
|
||||
<h3 style="font-size: 1.1rem; display: flex; align-items: center; gap: 0.5rem; margin: 0;">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
|
||||
Visitor Activity Log
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Sr</th>
|
||||
<th>Timestamp</th>
|
||||
<th>IP Address</th>
|
||||
<th>Country</th>
|
||||
<th>Referrer</th>
|
||||
<th>Browser</th>
|
||||
<th>User-Agent</th>
|
||||
<th>UTM Source</th>
|
||||
<th>UTM Campaign</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% if visits.is_empty() %}
|
||||
<tr>
|
||||
<td colspan="9" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
|
||||
No visitor activity available for this short link.
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
{% for entry in visits %}
|
||||
<tr>
|
||||
<td>{{ entry.sr }}</td>
|
||||
<td style="font-family: monospace; font-size: 0.85rem; white-space: nowrap;">{{ entry.timestamp }}</td>
|
||||
<td style="font-family: monospace; font-size: 0.85rem;">{{ entry.ip_address }}</td>
|
||||
<td>
|
||||
{% if entry.country == "Unknown" %}
|
||||
<span style="color: var(--text-muted);">Unknown</span>
|
||||
{% else %}
|
||||
{{ entry.country }}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ entry.referrer }}</td>
|
||||
<td>{{ entry.browser }}</td>
|
||||
<td style="max-width: 250px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 0.8rem; color: var(--text-secondary);" title="{{ entry.user_agent }}">
|
||||
{{ entry.user_agent }}
|
||||
</td>
|
||||
<td>{{ entry.utm_source }}</td>
|
||||
<td>{{ entry.utm_campaign }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -85,17 +85,8 @@
|
||||
<tr>
|
||||
<td><a href="/{{ url.code }}" target="_blank" style="color: var(--accent-color); text-decoration: none; font-family: monospace;">/{{ url.code }}</a></td>
|
||||
<td style="max-width: 260px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">{{ url.destination }}</td>
|
||||
<td>
|
||||
<div style="display: flex; flex-direction: column; align-items: center; gap: 0.25rem;">
|
||||
<a href="/api/qr/{{ url.code }}.png" target="_blank" title="View QR Code">
|
||||
<img src="/api/qr/{{ url.code }}.svg" alt="QR" style="width: 32px; height: 32px; border-radius: 4px; border: 1px solid var(--border-color); background: white; padding: 1px;">
|
||||
</a>
|
||||
<div style="display: flex; gap: 0.25rem;">
|
||||
<a href="/api/qr/{{ url.code }}.png" download class="badge" style="font-size: 0.65rem; background-color: rgba(99, 102, 241, 0.1); color: #818cf8; text-decoration: none; padding: 0.1rem 0.25rem;">PNG</a>
|
||||
<a href="/api/qr/{{ url.code }}.svg" download class="badge" style="font-size: 0.65rem; background-color: rgba(99, 102, 241, 0.1); color: #818cf8; text-decoration: none; padding: 0.1rem 0.25rem;">SVG</a>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
{% let code = url.code.as_str() %}
|
||||
{% include "components/qr_preview.html" %}
|
||||
<td>{{ url.status }}</td>
|
||||
<td>{% if url.tags.is_empty() %}-{% else %}{{ url.tags.join(", ") }}{% endif %}</td>
|
||||
<td>
|
||||
|
||||
@@ -28,7 +28,9 @@ fn create_temp_config(temp_dir: PathBuf) -> Config {
|
||||
|
||||
fn build_state(config: Config) -> (Db, AppState) {
|
||||
let db = Db::init(&config).expect("Failed to init Db");
|
||||
let queue = AnalyticsQueue::new(db.clone(), 1000);
|
||||
let (tx, rx) = tokio::sync::watch::channel(false);
|
||||
Box::leak(Box::new(tx));
|
||||
let (queue, _) = AnalyticsQueue::new(db.clone(), 1000, rx);
|
||||
let state = AppState {
|
||||
admin_db: db.admin.clone(),
|
||||
content_db: db.content.clone(),
|
||||
|
||||
@@ -0,0 +1,335 @@
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::time::Instant;
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
use bzod::analytics::AnalyticsQueue;
|
||||
use bzod::config::Config;
|
||||
use bzod::db::Db;
|
||||
use bzod::state::AppState;
|
||||
use bzod::web::create_router;
|
||||
|
||||
fn compute_sha256(value: &str) -> String {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(value.as_bytes());
|
||||
hex::encode(hasher.finalize())
|
||||
}
|
||||
|
||||
fn create_temp_config(temp_dir: PathBuf) -> Config {
|
||||
let mut config = Config::load();
|
||||
config.data_dir = temp_dir.clone();
|
||||
config.backup_dir = temp_dir.clone();
|
||||
config.admin_username = "admin".to_string();
|
||||
config.base_url = Some("http://localhost:8080".to_string());
|
||||
config.cookie_secure = false;
|
||||
config.bootstrap_password_sha256 = compute_sha256("bootstrap-secret");
|
||||
config
|
||||
}
|
||||
|
||||
fn extract_csrf_token(html: &str) -> Option<String> {
|
||||
let marker = "name=\"csrf_token\" value=\"";
|
||||
if let Some(pos) = html.find(marker) {
|
||||
let start = pos + marker.len();
|
||||
if let Some(end) = html[start..].find('"') {
|
||||
return Some(html[start..start + end].to_string());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
async fn start_test_server(
|
||||
temp_dir: PathBuf,
|
||||
) -> (reqwest::Client, String, tokio::task::JoinHandle<()>, Db) {
|
||||
let config = create_temp_config(temp_dir);
|
||||
let db = Db::init(&config).expect("Failed to init Db");
|
||||
let (tx, rx) = tokio::sync::watch::channel(false);
|
||||
Box::leak(Box::new(tx));
|
||||
let (queue, _) = AnalyticsQueue::new(db.clone(), 100, rx);
|
||||
|
||||
let state = AppState {
|
||||
admin_db: db.admin.clone(),
|
||||
content_db: db.content.clone(),
|
||||
analytics_db: db.analytics.clone(),
|
||||
system_db: db.system.clone(),
|
||||
users_db: db.users.clone(),
|
||||
user_dbs: std::sync::Arc::new(std::sync::Mutex::new(HashMap::new())),
|
||||
db: db.clone(),
|
||||
config,
|
||||
analytics_queue: queue,
|
||||
start_time: Instant::now(),
|
||||
};
|
||||
|
||||
let router = create_router(state);
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let url = format!("http://{}", addr);
|
||||
|
||||
let handle = tokio::spawn(async move {
|
||||
axum::serve(listener, router).await.unwrap();
|
||||
});
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.cookie_store(true)
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
(client, url, handle, db)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_analytics_and_table_parity() {
|
||||
let temp_dir = std::env::temp_dir().join(format!("bzod_parity_test_{}", uuid::Uuid::new_v4()));
|
||||
fs::create_dir_all(&temp_dir).unwrap();
|
||||
|
||||
let (client, base_url, _server_handle, db) = start_test_server(temp_dir.clone()).await;
|
||||
|
||||
// 1. Log in as admin FIRST (Bootstrap phase: zero users exist)
|
||||
let admin_client = reqwest::Client::builder()
|
||||
.cookie_store(true)
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
let admin_login_url = format!("{}/admin/login", base_url);
|
||||
let res = admin_client.get(&admin_login_url).send().await.unwrap();
|
||||
let html = res.text().await.unwrap();
|
||||
let csrf_token = extract_csrf_token(&html).unwrap();
|
||||
|
||||
let mut admin_login_params = HashMap::new();
|
||||
admin_login_params.insert("username", "admin");
|
||||
admin_login_params.insert("password", "bootstrap-secret");
|
||||
admin_login_params.insert("csrf_token", &csrf_token);
|
||||
|
||||
let res = admin_client
|
||||
.post(&admin_login_url)
|
||||
.form(&admin_login_params)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(res.status(), reqwest::StatusCode::SEE_OTHER);
|
||||
|
||||
// Admin adds a URL to the global content_db
|
||||
let _admin_url_id = {
|
||||
let conn_admin = db.content.lock().unwrap();
|
||||
let url = bzod::db::content::create_url_extended(
|
||||
&conn_admin,
|
||||
"!admin-slug",
|
||||
"https://admin.com",
|
||||
None,
|
||||
None,
|
||||
&vec![],
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let system_conn = db.system.lock().unwrap();
|
||||
bzod::db::users::register_global_slug(
|
||||
&system_conn,
|
||||
"!admin-slug",
|
||||
1,
|
||||
"url",
|
||||
&url.id,
|
||||
"active",
|
||||
)
|
||||
.unwrap();
|
||||
url.id
|
||||
};
|
||||
|
||||
// Admin adds a Landing Page to the global content_db
|
||||
let _admin_page_id = {
|
||||
let conn_admin = db.content.lock().unwrap();
|
||||
let page = bzod::db::content::create_landing_page(
|
||||
&conn_admin,
|
||||
"!admin-page",
|
||||
"admin-page",
|
||||
"Title Admin",
|
||||
"<html></html>",
|
||||
"published",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let system_conn = db.system.lock().unwrap();
|
||||
bzod::db::users::register_global_slug(
|
||||
&system_conn,
|
||||
"!admin-page",
|
||||
1,
|
||||
"page",
|
||||
&page.id,
|
||||
"active",
|
||||
)
|
||||
.unwrap();
|
||||
page.id
|
||||
};
|
||||
|
||||
// 2. Create User A
|
||||
let _ = bzod::cli::create_user::run(
|
||||
Some("usera".to_string()),
|
||||
Some("password123".to_string()),
|
||||
None,
|
||||
create_temp_config(temp_dir.clone()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let id_a = {
|
||||
let conn = db.users.lock().unwrap();
|
||||
bzod::db::users::get_user_by_username(&conn, "usera")
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.id
|
||||
};
|
||||
|
||||
// User A adds a URL
|
||||
let urla_id = {
|
||||
let conn_a = bzod::jobs::open_user_content_conn(&db, id_a).unwrap();
|
||||
let url = bzod::db::content::create_url_extended(
|
||||
&conn_a,
|
||||
"!usera-slug",
|
||||
"https://usera.com",
|
||||
None,
|
||||
None,
|
||||
&vec![],
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let system_conn = db.system.lock().unwrap();
|
||||
bzod::db::users::register_global_slug(
|
||||
&system_conn,
|
||||
"!usera-slug",
|
||||
id_a,
|
||||
"url",
|
||||
&url.id,
|
||||
"active",
|
||||
)
|
||||
.unwrap();
|
||||
url.id
|
||||
};
|
||||
|
||||
// User A adds a Landing Page
|
||||
let pagea_id = {
|
||||
let conn_a = bzod::jobs::open_user_content_conn(&db, id_a).unwrap();
|
||||
let page = bzod::db::content::create_landing_page(
|
||||
&conn_a,
|
||||
"!usera-page",
|
||||
"usera-page",
|
||||
"Title A",
|
||||
"<html></html>",
|
||||
"published",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let system_conn = db.system.lock().unwrap();
|
||||
bzod::db::users::register_global_slug(
|
||||
&system_conn,
|
||||
"!usera-page",
|
||||
id_a,
|
||||
"page",
|
||||
&page.id,
|
||||
"active",
|
||||
)
|
||||
.unwrap();
|
||||
page.id
|
||||
};
|
||||
|
||||
// --- Log in as User A ---
|
||||
let login_url = format!("{}/login", base_url);
|
||||
let res = client.get(&login_url).send().await.unwrap();
|
||||
let html = res.text().await.unwrap();
|
||||
let csrf_token = extract_csrf_token(&html).unwrap();
|
||||
|
||||
let mut login_params = HashMap::new();
|
||||
login_params.insert("username", "usera");
|
||||
login_params.insert("password", "password123");
|
||||
login_params.insert("csrf_token", &csrf_token);
|
||||
|
||||
let res = client
|
||||
.post(&login_url)
|
||||
.form(&login_params)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(res.status(), reqwest::StatusCode::SEE_OTHER);
|
||||
|
||||
// 1. Get user URLs dashboard and check for QR Code preview
|
||||
let res = client
|
||||
.get(format!("{}/user/urls", base_url))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(res.status(), reqwest::StatusCode::OK);
|
||||
let user_urls_html = res.text().await.unwrap();
|
||||
assert!(user_urls_html.contains("QR Code"));
|
||||
assert!(user_urls_html.contains("/api/qr/!usera-slug.svg"));
|
||||
|
||||
// 2. Get user Pages dashboard and check for QR Code preview
|
||||
let res = client
|
||||
.get(format!("{}/user/pages", base_url))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(res.status(), reqwest::StatusCode::OK);
|
||||
let user_pages_html = res.text().await.unwrap();
|
||||
assert!(user_pages_html.contains("QR Code"));
|
||||
assert!(user_pages_html.contains("/api/qr/!usera-page.svg"));
|
||||
|
||||
// 3. Get user URL analytics and verify dashboard features exist
|
||||
let res = client
|
||||
.get(format!("{}/user/analytics/url/{}", base_url, urla_id))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(res.status(), reqwest::StatusCode::OK);
|
||||
let user_url_analytics = res.text().await.unwrap();
|
||||
assert!(user_url_analytics.contains("Export CSV"));
|
||||
assert!(user_url_analytics.contains("Export JSON"));
|
||||
assert!(user_url_analytics.contains("date_from"));
|
||||
assert!(user_url_analytics.contains("Daily Click Traffic"));
|
||||
assert!(user_url_analytics.contains("Referrer Channels"));
|
||||
assert!(user_url_analytics.contains("Browser breakdown"));
|
||||
|
||||
// 4. Get user Page analytics and verify dashboard features exist
|
||||
let res = client
|
||||
.get(format!("{}/user/analytics/page/{}", base_url, pagea_id))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(res.status(), reqwest::StatusCode::OK);
|
||||
let user_page_analytics = res.text().await.unwrap();
|
||||
assert!(user_page_analytics.contains("Export CSV"));
|
||||
assert!(user_page_analytics.contains("Export JSON"));
|
||||
assert!(user_page_analytics.contains("date_from"));
|
||||
assert!(user_page_analytics.contains("Daily Page Views"));
|
||||
assert!(user_page_analytics.contains("Referrer Channels"));
|
||||
|
||||
// 5. Get admin URLs dashboard and check for QR Code preview
|
||||
let res = admin_client
|
||||
.get(format!("{}/admin/urls", base_url))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(res.status(), reqwest::StatusCode::OK);
|
||||
let admin_urls_html = res.text().await.unwrap();
|
||||
assert!(admin_urls_html.contains("QR Code"));
|
||||
assert!(admin_urls_html.contains("/api/qr/!admin-slug.svg"));
|
||||
|
||||
// 6. Get admin Pages dashboard and check for QR Code preview
|
||||
let res = admin_client
|
||||
.get(format!("{}/admin/pages", base_url))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(res.status(), reqwest::StatusCode::OK);
|
||||
let admin_pages_html = res.text().await.unwrap();
|
||||
assert!(admin_pages_html.contains("QR Code"));
|
||||
assert!(admin_pages_html.contains("/api/qr/!admin-page.svg"));
|
||||
|
||||
let _ = fs::remove_dir_all(&temp_dir);
|
||||
}
|
||||
@@ -37,7 +37,9 @@ fn compute_sha256(value: &str) -> String {
|
||||
|
||||
fn build_state(config: Config) -> (Db, bzod::state::AppState) {
|
||||
let db = Db::init(&config).expect("Failed to init Db");
|
||||
let queue = AnalyticsQueue::new(db.clone(), 1000);
|
||||
let (tx, rx) = tokio::sync::watch::channel(false);
|
||||
Box::leak(Box::new(tx));
|
||||
let (queue, _) = AnalyticsQueue::new(db.clone(), 1000, rx);
|
||||
let state = bzod::state::AppState {
|
||||
admin_db: db.admin.clone(),
|
||||
content_db: db.content.clone(),
|
||||
|
||||
@@ -135,8 +135,15 @@ async fn test_backup_restore_roundtrip() {
|
||||
|
||||
// Register global slug
|
||||
let system_conn = db.system.lock().unwrap();
|
||||
bzod::db::users::register_global_slug(&system_conn, "!rt-slug", user_id, "url", "rt-id")
|
||||
.unwrap();
|
||||
bzod::db::users::register_global_slug(
|
||||
&system_conn,
|
||||
"!rt-slug",
|
||||
user_id,
|
||||
"url",
|
||||
"rt-id",
|
||||
"active",
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// Backup user
|
||||
@@ -234,8 +241,15 @@ async fn test_restore_slug_collision_rejection() {
|
||||
.unwrap();
|
||||
|
||||
let system_conn = db.system.lock().unwrap();
|
||||
bzod::db::users::register_global_slug(&system_conn, "!collision-slug", id_a, "url", "a-id")
|
||||
.unwrap();
|
||||
bzod::db::users::register_global_slug(
|
||||
&system_conn,
|
||||
"!collision-slug",
|
||||
id_a,
|
||||
"url",
|
||||
"a-id",
|
||||
"active",
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// Backup User A
|
||||
@@ -289,18 +303,25 @@ async fn test_restore_slug_collision_rejection() {
|
||||
.unwrap();
|
||||
|
||||
let system_conn = db.system.lock().unwrap();
|
||||
bzod::db::users::register_global_slug(&system_conn, "!collision-slug", id_b, "url", "b-id")
|
||||
.unwrap();
|
||||
bzod::db::users::register_global_slug(
|
||||
&system_conn,
|
||||
"!collision-slug",
|
||||
id_b,
|
||||
"url",
|
||||
"b-id",
|
||||
"active",
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// Attempt to restore User A from backup
|
||||
bzod::cli::restore_user::run(
|
||||
// Attempt to restore User A from backup - must fail on collision
|
||||
let res = bzod::cli::restore_user::run(
|
||||
backup_file.to_string_lossy().to_string(),
|
||||
None,
|
||||
config.clone(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
.await;
|
||||
assert!(res.is_err());
|
||||
|
||||
// Verify that User B still owns the slug in global_slugs and User A's slug registration was skipped/rejected
|
||||
{
|
||||
|
||||
@@ -49,7 +49,9 @@ async fn start_test_server(
|
||||
) {
|
||||
let config = create_temp_config(temp_dir);
|
||||
let db = Db::init(&config).expect("Failed to init Db");
|
||||
let queue = AnalyticsQueue::new(db.clone(), 10);
|
||||
let (tx, rx) = tokio::sync::watch::channel(false);
|
||||
Box::leak(Box::new(tx));
|
||||
let (queue, _) = AnalyticsQueue::new(db.clone(), 10, rx);
|
||||
|
||||
let state = AppState {
|
||||
admin_db: db.admin.clone(),
|
||||
@@ -177,7 +179,7 @@ async fn test_scenario_a_user_create_login_shorten_visit_analytics() {
|
||||
let redir_url = format!("{}/!mygoogle", base_url);
|
||||
let res = client.get(&redir_url).send().await.unwrap();
|
||||
// It should redirect to google.com
|
||||
assert_eq!(res.status(), reqwest::StatusCode::TEMPORARY_REDIRECT);
|
||||
assert_eq!(res.status(), reqwest::StatusCode::MOVED_PERMANENTLY);
|
||||
assert_eq!(
|
||||
res.headers().get("location").unwrap().to_str().unwrap(),
|
||||
"https://google.com"
|
||||
@@ -573,7 +575,7 @@ async fn test_scenario_c_slug_transfer_workflow() {
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(res.status(), reqwest::StatusCode::TEMPORARY_REDIRECT);
|
||||
assert_eq!(res.status(), reqwest::StatusCode::MOVED_PERMANENTLY);
|
||||
assert_eq!(
|
||||
res.headers().get("location").unwrap().to_str().unwrap(),
|
||||
"https://yahoo.com"
|
||||
|
||||
@@ -30,7 +30,7 @@ async fn test_concurrent_slug_creation() {
|
||||
let task1 = tokio::spawn(async move {
|
||||
let conn = rusqlite::Connection::open(&path1).unwrap();
|
||||
b1.wait().await;
|
||||
bzod::db::users::register_global_slug(&conn, "!conc-slug", 10, "url", "url10")
|
||||
bzod::db::users::register_global_slug(&conn, "!conc-slug", 10, "url", "url10", "active")
|
||||
});
|
||||
|
||||
let b2 = barrier.clone();
|
||||
@@ -38,7 +38,7 @@ async fn test_concurrent_slug_creation() {
|
||||
let task2 = tokio::spawn(async move {
|
||||
let conn = rusqlite::Connection::open(&path2).unwrap();
|
||||
b2.wait().await;
|
||||
bzod::db::users::register_global_slug(&conn, "!conc-slug", 20, "url", "url20")
|
||||
bzod::db::users::register_global_slug(&conn, "!conc-slug", 20, "url", "url20", "active")
|
||||
});
|
||||
|
||||
let res1 = task1.await.unwrap();
|
||||
|
||||
@@ -23,7 +23,10 @@ fn create_temp_config(temp_dir: PathBuf) -> Config {
|
||||
config.backup_dir = temp_dir.clone();
|
||||
config.admin_username = "admin".to_string();
|
||||
config.base_url = Some("http://localhost:8080".to_string());
|
||||
config.cookie_secure = false; // Disable secure flag for testing over HTTP loopback
|
||||
// We leave config.cookie_secure as default (true).
|
||||
// The new resolve_cookie_secure logic will automatically drop Secure
|
||||
// for HTTP requests over the 127.0.0.1 loopback during this test,
|
||||
// proving the local development fix works end-to-end.
|
||||
config.bootstrap_password_sha256 = compute_sha256("bootstrap-secret");
|
||||
config
|
||||
}
|
||||
@@ -45,7 +48,9 @@ async fn start_test_server(
|
||||
) -> (reqwest::Client, String, tokio::task::JoinHandle<()>) {
|
||||
let config = create_temp_config(temp_dir);
|
||||
let db = Db::init(&config).expect("Failed to init Db");
|
||||
let queue = AnalyticsQueue::new(db.clone(), 100);
|
||||
let (tx, rx) = tokio::sync::watch::channel(false);
|
||||
Box::leak(Box::new(tx));
|
||||
let (queue, _) = AnalyticsQueue::new(db.clone(), 100, rx);
|
||||
|
||||
let state = AppState {
|
||||
admin_db: db.admin.clone(),
|
||||
|
||||
@@ -58,8 +58,15 @@ async fn test_global_slug_index_consistency() {
|
||||
// Register globally
|
||||
{
|
||||
let system_conn = db.system.lock().unwrap();
|
||||
bzod::db::users::register_global_slug(&system_conn, "!integ-slug", user_id, "url", &url_id)
|
||||
.unwrap();
|
||||
bzod::db::users::register_global_slug(
|
||||
&system_conn,
|
||||
"!integ-slug",
|
||||
user_id,
|
||||
"url",
|
||||
&url_id,
|
||||
"active",
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// Consistency Check:
|
||||
|
||||
@@ -0,0 +1,632 @@
|
||||
//! Tests for legacy_flat_backup restore compatibility and current backup roundtrip.
|
||||
//!
|
||||
//! These tests use a synthetic fixture that reproduces the exact structure of
|
||||
//! a real legacy_flat_backup archive:
|
||||
//! - admin.db with a users table (TEXT UUID PK, username, password_hash)
|
||||
//! - users.db that is completely empty (no tables, user_version=0)
|
||||
//! - system.db with global_slugs referencing multiple owner_user_ids
|
||||
//! - content.db with URLs and landing pages
|
||||
//! - analytics.db with visits
|
||||
//! - backup_manifest.json with type "legacy_flat_backup"
|
||||
//! - Orphaned slug entries (in global_slugs but not in content.db)
|
||||
//! - A missing tenant (owner_user_id=3 whose databases are not included)
|
||||
|
||||
use bzod::config::Config;
|
||||
use bzod::db::Db;
|
||||
use flate2::write::GzEncoder;
|
||||
use flate2::Compression;
|
||||
use rusqlite::Connection;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use tar::Builder;
|
||||
|
||||
fn create_temp_config(temp_dir: PathBuf) -> Config {
|
||||
let mut config = Config::load();
|
||||
config.data_dir = temp_dir.clone();
|
||||
config.backup_dir = temp_dir.clone();
|
||||
config.base_url = Some("http://bzo.in".to_string());
|
||||
config
|
||||
}
|
||||
|
||||
/// Build a synthetic legacy_flat_backup .tar.gz archive that reproduces the
|
||||
/// exact structure of the real production backup that fails with:
|
||||
/// "Failed to verify registry integrity in backup: no such table: users"
|
||||
///
|
||||
/// IMPORTANT: This uses purely synthetic data — no real credentials, URLs,
|
||||
/// audit logs, or analytics from the production backup are included.
|
||||
fn build_synthetic_legacy_fixture(output_path: &std::path::Path) {
|
||||
use chrono::Utc;
|
||||
let fixture_dir =
|
||||
std::env::temp_dir().join(format!("bzod_fixture_build_{}", uuid::Uuid::new_v4()));
|
||||
fs::create_dir_all(&fixture_dir).unwrap();
|
||||
|
||||
let now = Utc::now().to_rfc3339();
|
||||
|
||||
// --- admin.db: legacy admin schema with TEXT UUID primary key ---
|
||||
{
|
||||
let conn = Connection::open(fixture_dir.join("admin.db")).unwrap();
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE api_keys (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
key_hash TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
last_used_at TEXT
|
||||
);
|
||||
CREATE TABLE audit_logs (
|
||||
id TEXT PRIMARY KEY,
|
||||
timestamp TEXT NOT NULL,
|
||||
username TEXT NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
object_type TEXT,
|
||||
object_id TEXT,
|
||||
ip_address TEXT,
|
||||
user_agent TEXT
|
||||
);
|
||||
CREATE TABLE config (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Insert a synthetic admin with a known argon2id hash
|
||||
// (this is NOT a real password hash — it's a valid format placeholder)
|
||||
let admin_hash =
|
||||
"$argon2id$v=19$m=19456,t=2,p=1$dGVzdHNhbHQ$syntheticHashForTestingOnly00000000000000";
|
||||
conn.execute(
|
||||
"INSERT INTO users (id, username, password_hash, created_at) VALUES (?1, ?2, ?3, ?4);",
|
||||
rusqlite::params![
|
||||
"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
|
||||
"admin",
|
||||
admin_hash,
|
||||
&now
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Insert an audit log entry
|
||||
conn.execute(
|
||||
"INSERT INTO audit_logs (id, timestamp, username, action) VALUES (?1, ?2, ?3, ?4);",
|
||||
rusqlite::params!["audit-1", &now, "admin", "LOGIN"],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Set user_version to 1 (matching legacy migration state)
|
||||
conn.execute_batch("PRAGMA user_version = 1;").unwrap();
|
||||
}
|
||||
|
||||
// --- system.db: has global_slugs with multiple owners + orphaned entries ---
|
||||
{
|
||||
let mut conn = Connection::open(fixture_dir.join("system.db")).unwrap();
|
||||
// Run system migrations to get the full schema
|
||||
bzod::db::migrations::run_migrations(
|
||||
&mut conn,
|
||||
"system",
|
||||
bzod::db::migrations::SYSTEM_MIGRATIONS,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Insert global_slugs owned by user_id=1 (content exists)
|
||||
for (slug, target_type, target_id) in &[
|
||||
("abc123", "url", "url-id-1"),
|
||||
("def456", "url", "url-id-2"),
|
||||
("!custom-slug", "url", "url-id-3"),
|
||||
("!test-page", "page", "page-id-1"),
|
||||
("!meeting", "page", "page-id-2"),
|
||||
] {
|
||||
conn.execute(
|
||||
"INSERT INTO global_slugs (slug, owner_user_id, target_type, target_id, created_at, updated_at, status)
|
||||
VALUES (?1, 1, ?2, ?3, ?4, ?5, 'active');",
|
||||
rusqlite::params![slug, target_type, target_id, &now, &now],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// Insert global_slugs owned by user_id=3 (tenant NOT included in flat backup)
|
||||
for (slug, target_type, target_id) in &[
|
||||
("xyz789", "url", "user3-url-1"),
|
||||
("!user3-page", "page", "user3-page-1"),
|
||||
] {
|
||||
conn.execute(
|
||||
"INSERT INTO global_slugs (slug, owner_user_id, target_type, target_id, created_at, updated_at, status)
|
||||
VALUES (?1, 3, ?2, ?3, ?4, ?5, 'active');",
|
||||
rusqlite::params![slug, target_type, target_id, &now, &now],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// Insert an orphaned slug (user_id=1, content doesn't exist)
|
||||
conn.execute(
|
||||
"INSERT INTO global_slugs (slug, owner_user_id, target_type, target_id, created_at, updated_at, status)
|
||||
VALUES ('orphan-slug', 1, 'url', 'nonexistent-id', ?1, ?2, 'active');",
|
||||
rusqlite::params![&now, &now],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// --- users.db: completely empty (no schema, user_version=0) ---
|
||||
{
|
||||
let _conn = Connection::open(fixture_dir.join("users.db")).unwrap();
|
||||
// Intentionally empty — this is the root cause of the original bug
|
||||
}
|
||||
|
||||
// --- content.db: URLs and landing pages belonging to user_id=1 ---
|
||||
{
|
||||
let mut conn = Connection::open(fixture_dir.join("content.db")).unwrap();
|
||||
bzod::db::migrations::run_migrations(
|
||||
&mut conn,
|
||||
"content",
|
||||
bzod::db::migrations::CONTENT_MIGRATIONS,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Insert URLs
|
||||
for (id, code, dest) in &[
|
||||
("url-id-1", "abc123", "https://example.com/1"),
|
||||
("url-id-2", "def456", "https://example.com/2"),
|
||||
("url-id-3", "!custom-slug", "https://example.com/3"),
|
||||
] {
|
||||
conn.execute(
|
||||
"INSERT INTO urls (id, code, destination, status, created_at, updated_at)
|
||||
VALUES (?1, ?2, ?3, 'active', ?4, ?5);",
|
||||
rusqlite::params![id, code, dest, &now, &now],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// Insert landing pages
|
||||
for (id, code, title) in &[
|
||||
("page-id-1", "!test-page", "Test Page"),
|
||||
("page-id-2", "!meeting", "Meeting Notes"),
|
||||
] {
|
||||
conn.execute(
|
||||
"INSERT INTO landing_pages (id, code, slug, title, html_content, state, created_at, updated_at)
|
||||
VALUES (?1, ?2, ?2, ?3, '<h1>Test</h1>', 'published', ?4, ?5);",
|
||||
rusqlite::params![id, code, title, &now, &now],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
// --- analytics.db: visits ---
|
||||
{
|
||||
let mut conn = Connection::open(fixture_dir.join("analytics.db")).unwrap();
|
||||
bzod::db::migrations::run_migrations(
|
||||
&mut conn,
|
||||
"analytics",
|
||||
bzod::db::migrations::ANALYTICS_MIGRATIONS,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Insert some visits
|
||||
for i in 0..10 {
|
||||
conn.execute(
|
||||
"INSERT INTO visits (id, target_type, target_id, timestamp, owner_user_id, ip_address, user_agent, referer, accept_language, country, status_code)
|
||||
VALUES (?1, 'url', 'url-id-1', ?2, 1, ?3, ?4, ?5, ?6, ?7, ?8);",
|
||||
rusqlite::params![format!("visit-{}", i), &now, "127.0.0.1", "test-agent", "", "en-US", "US", 200],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
// --- backup_manifest.json ---
|
||||
let manifest = serde_json::json!({
|
||||
"created_at": &now,
|
||||
"type": "legacy_flat_backup",
|
||||
"files_included": ["admin.db", "system.db", "users.db", "content.db", "analytics.db"],
|
||||
"note": "Multi-tenant databases flattened for backward compatibility.",
|
||||
});
|
||||
fs::write(
|
||||
fixture_dir.join("backup_manifest.json"),
|
||||
manifest.to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// --- Package into .tar.gz ---
|
||||
let tar_file = fs::File::create(output_path).unwrap();
|
||||
let enc = GzEncoder::new(tar_file, Compression::default());
|
||||
let mut tar = Builder::new(enc);
|
||||
|
||||
for name in &[
|
||||
"admin.db",
|
||||
"system.db",
|
||||
"users.db",
|
||||
"content.db",
|
||||
"analytics.db",
|
||||
"backup_manifest.json",
|
||||
] {
|
||||
tar.append_path_with_name(fixture_dir.join(name), name)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
tar.into_inner().unwrap().finish().unwrap();
|
||||
let _ = fs::remove_dir_all(&fixture_dir);
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Test 1: Legacy flat backup restores without "no such table" error
|
||||
// ==========================================================================
|
||||
#[test]
|
||||
fn test_legacy_flat_backup_restore() {
|
||||
let temp_dir =
|
||||
std::env::temp_dir().join(format!("bzod_test_legacy_restore_{}", uuid::Uuid::new_v4()));
|
||||
fs::create_dir_all(&temp_dir).unwrap();
|
||||
|
||||
let fixture_path = temp_dir.join("legacy-backup.tar.gz");
|
||||
build_synthetic_legacy_fixture(&fixture_path);
|
||||
|
||||
let restore_dir = temp_dir.join("restored_data");
|
||||
fs::create_dir_all(&restore_dir).unwrap();
|
||||
|
||||
// This must succeed — previously it failed with "no such table: users"
|
||||
let result = bzod::cli::restore::perform_restore(&fixture_path, &restore_dir);
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"Legacy flat backup restore failed: {:?}",
|
||||
result.err()
|
||||
);
|
||||
|
||||
// Verify multi-tenant directory structure
|
||||
assert!(
|
||||
restore_dir.join("admin/admin.db").exists(),
|
||||
"admin/admin.db missing"
|
||||
);
|
||||
assert!(
|
||||
restore_dir.join("admin/system.db").exists(),
|
||||
"admin/system.db missing"
|
||||
);
|
||||
assert!(
|
||||
restore_dir.join("admin/users.db").exists(),
|
||||
"admin/users.db missing"
|
||||
);
|
||||
assert!(
|
||||
restore_dir.join("users/1/content.db").exists(),
|
||||
"users/1/content.db missing"
|
||||
);
|
||||
assert!(
|
||||
restore_dir.join("users/1/analytics.db").exists(),
|
||||
"users/1/analytics.db missing"
|
||||
);
|
||||
|
||||
let _ = fs::remove_dir_all(&temp_dir);
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Test 2: Admin credentials are preserved, not manufactured
|
||||
// ==========================================================================
|
||||
#[test]
|
||||
fn test_legacy_restore_preserves_admin_credentials() {
|
||||
let temp_dir =
|
||||
std::env::temp_dir().join(format!("bzod_test_legacy_creds_{}", uuid::Uuid::new_v4()));
|
||||
fs::create_dir_all(&temp_dir).unwrap();
|
||||
|
||||
let fixture_path = temp_dir.join("legacy-backup.tar.gz");
|
||||
build_synthetic_legacy_fixture(&fixture_path);
|
||||
|
||||
let restore_dir = temp_dir.join("restored_data");
|
||||
fs::create_dir_all(&restore_dir).unwrap();
|
||||
|
||||
bzod::cli::restore::perform_restore(&fixture_path, &restore_dir).unwrap();
|
||||
|
||||
let expected_hash =
|
||||
"$argon2id$v=19$m=19456,t=2,p=1$dGVzdHNhbHQ$syntheticHashForTestingOnly00000000000000";
|
||||
|
||||
// Verify original admin identity in admin.db is untouched
|
||||
{
|
||||
let conn = Connection::open(restore_dir.join("admin/admin.db")).unwrap();
|
||||
let (username, hash): (String, String) = conn
|
||||
.query_row(
|
||||
"SELECT username, password_hash FROM users WHERE id = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee';",
|
||||
[],
|
||||
|row| Ok((row.get(0)?, row.get(1)?)),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(username, "admin");
|
||||
assert_eq!(hash, expected_hash, "Admin password hash was modified!");
|
||||
}
|
||||
|
||||
// Verify users.db was bootstrapped with actual admin credentials
|
||||
{
|
||||
let conn = Connection::open(restore_dir.join("admin/users.db")).unwrap();
|
||||
|
||||
// legacy_admin system placeholder should exist with id=1
|
||||
let (la_username, la_hash, la_type): (String, String, String) = conn
|
||||
.query_row(
|
||||
"SELECT username, password_hash, account_type FROM users WHERE id = 1;",
|
||||
[],
|
||||
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(la_username, "legacy_admin");
|
||||
assert_eq!(
|
||||
la_hash, expected_hash,
|
||||
"legacy_admin hash should match original admin"
|
||||
);
|
||||
assert_eq!(la_type, "system");
|
||||
|
||||
// Actual admin account should exist with original credentials
|
||||
let (admin_hash, admin_type, admin_status): (String, String, String) = conn
|
||||
.query_row(
|
||||
"SELECT password_hash, account_type, status FROM users WHERE username = 'admin';",
|
||||
[],
|
||||
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
admin_hash, expected_hash,
|
||||
"Admin account hash should match original"
|
||||
);
|
||||
assert_eq!(admin_type, "admin");
|
||||
assert_eq!(admin_status, "active");
|
||||
}
|
||||
|
||||
let _ = fs::remove_dir_all(&temp_dir);
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Test 3: Functional data is preserved and accessible after restore + Db::init()
|
||||
// ==========================================================================
|
||||
#[tokio::test]
|
||||
async fn test_legacy_restore_functional_data() {
|
||||
let temp_dir = std::env::temp_dir().join(format!(
|
||||
"bzod_test_legacy_functional_{}",
|
||||
uuid::Uuid::new_v4()
|
||||
));
|
||||
fs::create_dir_all(&temp_dir).unwrap();
|
||||
|
||||
let fixture_path = temp_dir.join("legacy-backup.tar.gz");
|
||||
build_synthetic_legacy_fixture(&fixture_path);
|
||||
|
||||
let restore_dir = temp_dir.join("restored_data");
|
||||
fs::create_dir_all(&restore_dir).unwrap();
|
||||
|
||||
bzod::cli::restore::perform_restore(&fixture_path, &restore_dir).unwrap();
|
||||
|
||||
// Initialize Db against the restored data (simulates fresh v0.6.0 startup)
|
||||
let config = create_temp_config(restore_dir.clone());
|
||||
let db = Db::init(&config).expect("Db::init failed on restored legacy data");
|
||||
|
||||
// Verify URLs
|
||||
{
|
||||
let content_conn = bzod::jobs::open_user_content_conn(&db, 1).unwrap();
|
||||
let url_count: i64 = content_conn
|
||||
.query_row("SELECT COUNT(*) FROM urls;", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(url_count, 3, "Expected 3 URLs in restored content.db");
|
||||
|
||||
let url = bzod::db::content::get_url_by_code(&content_conn, "abc123")
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(url.destination, "https://example.com/1");
|
||||
}
|
||||
|
||||
// Verify landing pages
|
||||
{
|
||||
let content_conn = bzod::jobs::open_user_content_conn(&db, 1).unwrap();
|
||||
let page_count: i64 = content_conn
|
||||
.query_row("SELECT COUNT(*) FROM landing_pages;", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
page_count, 2,
|
||||
"Expected 2 landing pages in restored content.db"
|
||||
);
|
||||
}
|
||||
|
||||
// Verify analytics
|
||||
{
|
||||
let analytics_conn = bzod::jobs::open_user_analytics_conn(&db, 1).unwrap();
|
||||
let visit_count: i64 = analytics_conn
|
||||
.query_row("SELECT COUNT(*) FROM visits;", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
visit_count, 10,
|
||||
"Expected 10 visits in restored analytics.db"
|
||||
);
|
||||
}
|
||||
|
||||
// Verify global slug registry
|
||||
{
|
||||
let system_conn = db.system.lock().unwrap();
|
||||
let slug_count: i64 = system_conn
|
||||
.query_row("SELECT COUNT(*) FROM global_slugs;", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert!(
|
||||
slug_count >= 7,
|
||||
"Expected at least 7 global_slugs (5 user1 + 2 user3 + orphan)"
|
||||
);
|
||||
}
|
||||
|
||||
// Verify user 3 placeholder exists
|
||||
{
|
||||
let users_conn = db.users.lock().unwrap();
|
||||
let user3_exists: bool = users_conn
|
||||
.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM users WHERE id = 3);",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
user3_exists,
|
||||
"Placeholder for user_id=3 should exist in users.db"
|
||||
);
|
||||
|
||||
let (status, metadata): (String, Option<String>) = users_conn
|
||||
.query_row(
|
||||
"SELECT status, metadata FROM users WHERE id = 3;",
|
||||
[],
|
||||
|r| Ok((r.get(0)?, r.get(1)?)),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(status, "disabled", "User 3 placeholder should be disabled");
|
||||
assert!(
|
||||
metadata.as_deref().unwrap_or("").contains("Placeholder"),
|
||||
"User 3 metadata should document it as a placeholder"
|
||||
);
|
||||
}
|
||||
|
||||
// Verify admin identity in admin.db
|
||||
{
|
||||
let admin_conn = db.admin.lock().unwrap();
|
||||
let admin_exists: bool = admin_conn
|
||||
.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM users WHERE username = 'admin');",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
admin_exists,
|
||||
"Original admin identity must be preserved in admin.db"
|
||||
);
|
||||
}
|
||||
|
||||
let _ = fs::remove_dir_all(&temp_dir);
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Test 4: Current/native backup restore roundtrip
|
||||
// ==========================================================================
|
||||
#[tokio::test]
|
||||
async fn test_current_backup_restore_roundtrip() {
|
||||
let temp_dir =
|
||||
std::env::temp_dir().join(format!("bzod_test_current_rt_{}", uuid::Uuid::new_v4()));
|
||||
fs::create_dir_all(&temp_dir).unwrap();
|
||||
let config = create_temp_config(temp_dir.clone());
|
||||
|
||||
let db = Db::init(&config).expect("Failed to init Db");
|
||||
|
||||
// Create a user and add content
|
||||
let _ = bzod::cli::create_user::run(
|
||||
Some("testuser".to_string()),
|
||||
Some("password123".to_string()),
|
||||
None,
|
||||
config.clone(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let user_id = {
|
||||
let conn = db.users.lock().unwrap();
|
||||
bzod::db::users::get_user_by_username(&conn, "testuser")
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.id
|
||||
};
|
||||
|
||||
{
|
||||
let user_content_conn = bzod::jobs::open_user_content_conn(&db, user_id).unwrap();
|
||||
bzod::db::content::create_url_extended(
|
||||
&user_content_conn,
|
||||
"!current-test",
|
||||
"https://example.com/current",
|
||||
None,
|
||||
None,
|
||||
&vec![],
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let system_conn = db.system.lock().unwrap();
|
||||
bzod::db::users::register_global_slug(
|
||||
&system_conn,
|
||||
"!current-test",
|
||||
user_id,
|
||||
"url",
|
||||
"current-id",
|
||||
"active",
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// Create a native backup
|
||||
let backup_path = bzod::jobs::backup::perform_backup(&db, &config)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Restore to a fresh directory
|
||||
let restore_dir = temp_dir.join("restored_native");
|
||||
fs::create_dir_all(&restore_dir).unwrap();
|
||||
|
||||
bzod::cli::restore::perform_restore(std::path::Path::new(&backup_path), &restore_dir).unwrap();
|
||||
|
||||
// Verify restored data
|
||||
let restore_config = create_temp_config(restore_dir.clone());
|
||||
let restored_db = Db::init(&restore_config).expect("Db::init failed on restored native data");
|
||||
|
||||
{
|
||||
let conn = restored_db.users.lock().unwrap();
|
||||
let user = bzod::db::users::get_user_by_username(&conn, "testuser")
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(user.status, "active");
|
||||
}
|
||||
|
||||
{
|
||||
let content_conn = bzod::jobs::open_user_content_conn(&restored_db, user_id).unwrap();
|
||||
let url = bzod::db::content::get_url_by_code(&content_conn, "!current-test")
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(url.destination, "https://example.com/current");
|
||||
}
|
||||
|
||||
let _ = fs::remove_dir_all(&temp_dir);
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Test 5: Failed restore does not corrupt existing data
|
||||
// ==========================================================================
|
||||
#[tokio::test]
|
||||
async fn test_failed_restore_does_not_corrupt() {
|
||||
let temp_dir =
|
||||
std::env::temp_dir().join(format!("bzod_test_safe_restore_{}", uuid::Uuid::new_v4()));
|
||||
fs::create_dir_all(&temp_dir).unwrap();
|
||||
let config = create_temp_config(temp_dir.clone());
|
||||
|
||||
// Set up a working installation
|
||||
let _db = Db::init(&config).expect("Failed to init Db");
|
||||
|
||||
// Write a sentinel file to verify the data dir survives
|
||||
let sentinel = config.data_dir.join("admin").join("sentinel.txt");
|
||||
fs::write(&sentinel, "intact").unwrap();
|
||||
|
||||
// Create a corrupt "backup" file
|
||||
let corrupt_path = temp_dir.join("corrupt.tar.gz");
|
||||
fs::write(&corrupt_path, b"this is not a valid tar.gz file").unwrap();
|
||||
|
||||
// Attempt restore — must fail
|
||||
let result = bzod::cli::restore::perform_restore(&corrupt_path, &config.data_dir);
|
||||
assert!(result.is_err(), "Corrupt backup should fail to restore");
|
||||
|
||||
// Verify original data is intact
|
||||
assert!(
|
||||
sentinel.exists(),
|
||||
"Sentinel file must survive failed restore"
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read_to_string(&sentinel).unwrap(),
|
||||
"intact",
|
||||
"Sentinel file content must be unchanged"
|
||||
);
|
||||
|
||||
let _ = fs::remove_dir_all(&temp_dir);
|
||||
}
|
||||
@@ -25,7 +25,8 @@ async fn test_content_flagging_and_disabling() {
|
||||
let system_conn = db.system.lock().unwrap();
|
||||
|
||||
// Register slug
|
||||
bzod::db::users::register_global_slug(&system_conn, "!badslug", 10, "url", "url_abc").unwrap();
|
||||
bzod::db::users::register_global_slug(&system_conn, "!badslug", 10, "url", "url_abc", "active")
|
||||
.unwrap();
|
||||
|
||||
// Verify it is active initially
|
||||
let status: String = system_conn
|
||||
|
||||
Loaded 100 of 113 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user