Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2cd3c2d965 | ||
|
|
b66703082f | ||
|
|
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.7.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.7.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"
|
||||
@@ -0,0 +1,18 @@
|
||||
use std::process::Command;
|
||||
|
||||
fn main() {
|
||||
println!("cargo:rerun-if-changed=.git/HEAD");
|
||||
println!("cargo:rerun-if-changed=.git/refs");
|
||||
|
||||
if let Ok(output) = Command::new("git")
|
||||
.args(["rev-parse", "--short=12", "HEAD"])
|
||||
.output()
|
||||
{
|
||||
if output.status.success() {
|
||||
let commit = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
if !commit.is_empty() {
|
||||
println!("cargo:rustc-env=BZOD_GIT_COMMIT={commit}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,8 @@
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
BZOD_VERSION="0.7.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.7.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.7.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.7.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.7.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.
|
||||
+388
-4
@@ -1,9 +1,393 @@
|
||||
# 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.7.0 — Responsive UI, Theme Support & Build Metadata
|
||||
|
||||
## Added
|
||||
|
||||
### Responsive UI
|
||||
|
||||
* Responsive layouts for admin and user URL registry panels
|
||||
* Responsive layouts for admin and user landing page registry panels
|
||||
* Desktop, laptop, tablet, and mobile layout support
|
||||
* Table-to-card responsive behavior for registry panels
|
||||
* Resolved horizontal scrolling issues in registry panels
|
||||
|
||||
### Theme Support
|
||||
|
||||
* Dark/light theme toggle
|
||||
* Theme persistence across sessions
|
||||
* Responsive theme behavior across device sizes
|
||||
|
||||
### Build Metadata
|
||||
|
||||
* Introduced `build.rs` build script for compile-time metadata
|
||||
* Introduced `src/build_info.rs` module exposing `APP_VERSION` and `GIT_COMMIT`
|
||||
* Application version derived from `Cargo.toml` via `env!("CARGO_PKG_VERSION")`
|
||||
* Git commit hash (12-char short) embedded at build time via `BZOD_GIT_COMMIT`
|
||||
* Graceful fallback to `"unknown"` when Git metadata is unavailable
|
||||
|
||||
### Public Landing Page
|
||||
|
||||
* Root `/` serves `www/index.html` with runtime file and embedded fallback behavior
|
||||
* Public/runtime www assets supported by the deployment layout
|
||||
|
||||
## Changed
|
||||
|
||||
* Documentation updated to reflect v0.7.0 current state
|
||||
* Version metadata updated across Cargo.toml, deploy.sh, and docker-compose.yml
|
||||
|
||||
## Notes
|
||||
|
||||
* No API behavior changes
|
||||
* No database schema changes
|
||||
* No authentication or security behavior changes
|
||||
* Existing redirect, routing, and tenant isolation behavior preserved
|
||||
|
||||
---
|
||||
|
||||
# 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 +396,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.7.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.7.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.7.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.7.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.7.0-linux-amd64.tar.gz
|
||||
```
|
||||
|
||||
Extract:
|
||||
|
||||
```bash
|
||||
tar -xzf bzod-v0.7.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.7.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,465 @@
|
||||
# BZOD v0.7.0 — Responsive UI, Theme Support & Build Metadata
|
||||
|
||||
Release Date: 2026-08-11
|
||||
|
||||
## Highlights
|
||||
|
||||
- **Responsive UI**: Admin and user registry panels (URLs and landing pages) now adapt across desktop, laptop, tablet, and mobile viewports. Tables switch to card layouts on smaller screens, and horizontal scrolling issues in registry panels have been resolved.
|
||||
|
||||
- **Dark/Light Theme Support**: A dark/light theme toggle has been implemented with theme persistence across sessions and responsive behavior across device sizes.
|
||||
|
||||
- **Build Metadata**: The application now exposes its actual Cargo package version and, when available, the Git commit hash. This is powered by `build.rs` (compile-time Git commit extraction) and `src/build_info.rs` (exposing `APP_VERSION` and `GIT_COMMIT` constants). The version is derived from `Cargo.toml` via `env!("CARGO_PKG_VERSION")`, and the Git commit hash falls back gracefully to `"unknown"` when unavailable.
|
||||
|
||||
- **Public Landing Page Serving**: Root `/` now serves the public `www/index.html` with runtime file detection and embedded fallback behavior. Public/runtime www assets are supported by the deployment layout.
|
||||
|
||||
## User-Visible Changes
|
||||
|
||||
- Admin URL registry panel is responsive across all device sizes
|
||||
- Admin landing page registry panel is responsive across all device sizes
|
||||
- User URL registry panel is responsive across all device sizes
|
||||
- User landing page registry panel is responsive across all device sizes
|
||||
- Dark/light theme toggle available in the UI
|
||||
- Theme preference persists across sessions
|
||||
- Registry tables switch to card layouts on tablet and mobile viewports
|
||||
- Horizontal scrolling eliminated from registry panels
|
||||
|
||||
## Technical Changes
|
||||
|
||||
- Introduced `build.rs` build script for compile-time metadata extraction
|
||||
- Introduced `src/build_info.rs` module with `APP_VERSION` and `GIT_COMMIT` constants
|
||||
- Version and Git commit now available to system status and admin settings endpoints
|
||||
- Root `/` serves `www/index.html` with runtime/embedded fallback
|
||||
|
||||
## Breaking Changes
|
||||
|
||||
None.
|
||||
|
||||
## Upgrade Notes
|
||||
|
||||
- Direct upgrade from v0.6.0 with no migration required
|
||||
- No database schema changes
|
||||
- No API changes
|
||||
- No configuration changes
|
||||
- No breaking changes to existing functionality
|
||||
|
||||
---
|
||||
|
||||
# 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.7.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.7.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.7.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.7.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.7.0
|
||||
|
||||
This document describes the upgrade process for existing BZOD deployments upgrading to BZOD v0.7.0.
|
||||
|
||||
---
|
||||
|
||||
# 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,5 @@
|
||||
pub const APP_VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
pub const GIT_COMMIT: &str = match option_env!("BZOD_GIT_COMMIT") {
|
||||
Some(commit) => commit,
|
||||
None => "unknown",
|
||||
};
|
||||
@@ -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> = {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
pub mod analytics;
|
||||
pub mod auth;
|
||||
pub mod build_info;
|
||||
pub mod charts;
|
||||
pub mod cli;
|
||||
pub mod config;
|
||||
|
||||
+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),
|
||||
|
||||
+2
-2
@@ -79,8 +79,8 @@ pub async fn status_endpoint(
|
||||
queue_size: 0,
|
||||
memory_usage: get_memory_usage(),
|
||||
uptime_seconds: uptime,
|
||||
version: "0.1.0",
|
||||
git_commit: "unknown",
|
||||
version: crate::build_info::APP_VERSION,
|
||||
git_commit: crate::build_info::GIT_COMMIT,
|
||||
})
|
||||
.into_response()
|
||||
}
|
||||
|
||||
@@ -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;">
|
||||
|
||||
+1242
-72
File diff suppressed because it is too large.
Load diff
+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>
|
||||
|
||||
+56
-50
@@ -13,8 +13,7 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; align-items: start;">
|
||||
<!-- Create Landing Page Form -->
|
||||
<div class="urls-dashboard">
|
||||
<div class="card">
|
||||
<h3 style="font-size: 1.1rem; margin-bottom: 1.25rem; 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"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||
@@ -24,7 +23,7 @@
|
||||
<form action="/admin/pages/create" method="POST">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1rem;">
|
||||
<div class="page-form-grid-2">
|
||||
<div class="form-group">
|
||||
<label for="title">Page Title *</label>
|
||||
<input type="text" id="title" name="title" class="form-input" placeholder="e.g. Summer Campaign" required>
|
||||
@@ -36,7 +35,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 1rem;">
|
||||
<div class="page-form-grid-3">
|
||||
<div class="form-group">
|
||||
<label for="code">Short Code (4-Hex, optional)</label>
|
||||
<input type="text" id="code" name="code" class="form-input" placeholder="e.g. a1b2" pattern="[0-9a-fA-F]{4}" title="Must be exactly 4 hex characters">
|
||||
@@ -49,7 +48,7 @@
|
||||
|
||||
<div class="form-group">
|
||||
<label for="state">Publish State</label>
|
||||
<select id="state" name="state">
|
||||
<select id="state" name="state" class="form-input">
|
||||
<option value="draft">Draft</option>
|
||||
<option value="published" selected>Published</option>
|
||||
<option value="archived">Archived</option>
|
||||
@@ -59,16 +58,15 @@
|
||||
|
||||
<div class="form-group">
|
||||
<label for="html_content">Raw HTML Document *</label>
|
||||
<textarea id="html_content" name="html_content" class="form-input" style="font-family: monospace; font-size: 0.85rem; height: 300px; background-color: #05070f; border-color: rgba(255,255,255,0.05);" placeholder="<!DOCTYPE html> <html> <head> <title>Landing Page</title> </head> <body> <h1>Welcome!</h1> </body> </html>" required></textarea>
|
||||
<textarea id="html_content" name="html_content" class="form-input page-html-editor" placeholder="<!DOCTYPE html> <html> <head> <title>Landing Page</title> </head> <body> <h1>Welcome!</h1> </body> </html>" required></textarea>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn" style="width: 100%; margin-top: 0.5rem;">Save Page</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Landing Pages Table -->
|
||||
<div class="card" style="padding: 0; overflow: hidden;">
|
||||
<div style="padding: 1.25rem 1.5rem; border-bottom: 1px solid var(--border-color);">
|
||||
<div class="card registered-links-shell" style="padding: 0; overflow: hidden;">
|
||||
<div class="registered-links-header">
|
||||
<h3 style="font-size: 1.1rem; 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>
|
||||
Registered Pages
|
||||
@@ -76,66 +74,75 @@
|
||||
</div>
|
||||
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<table class="registered-pages-table">
|
||||
<colgroup>
|
||||
<col class="col-page-title">
|
||||
<col class="col-page-paths">
|
||||
<col class="col-page-status">
|
||||
<col class="col-page-qr">
|
||||
<col class="col-page-actions">
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Page Title</th>
|
||||
<th>Short Path</th>
|
||||
<th>SEO Preview Path</th>
|
||||
<th>Page</th>
|
||||
<th>Paths</th>
|
||||
<th>Status</th>
|
||||
<th>Analytics</th>
|
||||
<th>Created</th>
|
||||
<th>Action</th>
|
||||
<th>QR Code</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% if pages.is_empty() %}
|
||||
<tr>
|
||||
<td colspan="7" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
|
||||
<td colspan="5" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
|
||||
No landing pages registered. Create one to get started!
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
{% for page in pages %}
|
||||
<tr>
|
||||
<td>
|
||||
<div style="font-weight: 700; color: var(--text-primary);">
|
||||
{{ page.title }}
|
||||
</div>
|
||||
<div style="font-size: 0.75rem; color: var(--text-muted); font-family: monospace;">
|
||||
UUID: {{ page.id[0..8] }}...
|
||||
<td data-label="Page">
|
||||
<div class="page-title-block">
|
||||
<div class="page-title-heading">{{ page.title }}</div>
|
||||
<div class="page-title-meta">UUID: {{ page.id[0..8] }}...</div>
|
||||
<div class="page-title-meta">Created {{ page.created_at[0..10] }}</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<a href="/p/{{ page.code }}" target="_blank" style="color: var(--accent-color); font-weight: 700; text-decoration: none; font-family: monospace;">
|
||||
/p/{{ page.code }}
|
||||
</a>
|
||||
<td data-label="Paths">
|
||||
<div class="page-paths">
|
||||
<div class="page-path-group">
|
||||
<span class="page-path-caption">Short Path</span>
|
||||
<a href="/p/{{ page.code }}" target="_blank" class="page-path-link">
|
||||
/p/{{ page.code }}
|
||||
</a>
|
||||
</div>
|
||||
<div class="page-path-group">
|
||||
<span class="page-path-caption">SEO Preview Path</span>
|
||||
<a href="/p/{{ page.code }}/{{ page.slug }}" target="_blank" class="page-path-link secondary">
|
||||
/p/{{ page.code }}/{{ page.slug }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<a href="/p/{{ page.code }}/{{ page.slug }}" target="_blank" style="color: var(--text-secondary); text-decoration: none; font-size: 0.85rem; font-family: monospace;">
|
||||
/p/{{ page.code }}/{{ page.slug }}
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<td data-label="Status" class="status-cell">
|
||||
<span class="badge badge-{{ page.state }}">
|
||||
{{ page.state }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="/admin/analytics/page/{{ page.id }}" class="btn btn-secondary" style="padding: 0.4rem 0.6rem; font-size: 0.8rem; display: inline-flex; align-items: center; gap: 0.25rem;">
|
||||
📊 Analytics
|
||||
</a>
|
||||
</td>
|
||||
<td style="font-size: 0.8rem; color: var(--text-secondary);">
|
||||
{{ page.created_at[0..10] }}
|
||||
</td>
|
||||
<td>
|
||||
<form action="/admin/pages/delete/{{ page.id }}" method="POST" onsubmit="return confirm('Are you sure you want to delete this page?');">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button type="submit" class="btn btn-danger" style="padding: 0.4rem 0.6rem; font-size: 0.8rem;">
|
||||
Delete
|
||||
</button>
|
||||
</form>
|
||||
{% let code = page.code.as_str() %}
|
||||
{% include "components/qr_preview.html" %}
|
||||
<td data-label="Actions" class="actions-cell">
|
||||
<div class="action-stack">
|
||||
<a href="/admin/analytics/page/{{ page.id }}" class="btn btn-secondary" style="padding: 0.4rem 0.6rem; font-size: 0.8rem; display: inline-flex; align-items: center; gap: 0.25rem;">
|
||||
📊 Analytics
|
||||
</a>
|
||||
<form action="/admin/pages/delete/{{ page.id }}" method="POST" onsubmit="return confirm('Are you sure you want to delete this page?');">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button type="submit" class="btn btn-danger" style="padding: 0.4rem 0.6rem; font-size: 0.8rem;">
|
||||
Delete
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
@@ -144,8 +151,7 @@
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination Controls -->
|
||||
<div class="pagination" style="display: flex; justify-content: center; align-items: center; gap: 0.5rem; margin-top: 1rem; padding: 1rem; border-top: 1px solid var(--border-color);">
|
||||
<div class="pagination">
|
||||
{% if current_page > 1 %}
|
||||
<a href="/admin/pages?page=1" class="btn btn-secondary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem;"><< First</a>
|
||||
<a href="/admin/pages?page={{ current_page - 1 }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem;">< Prev</a>
|
||||
|
||||
+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>
|
||||
|
||||
+106
-96
@@ -4,6 +4,14 @@
|
||||
|
||||
{% block active_urls %}active{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
@media (max-width: 720px) {
|
||||
#utm_fields > div {
|
||||
grid-template-columns: 1fr !important;
|
||||
}
|
||||
}
|
||||
{% endblock %}
|
||||
|
||||
{% block header_title %}Short URL Registry{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
@@ -13,9 +21,8 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div style="display: grid; grid-template-columns: 1fr 2fr; gap: 1.5rem; align-items: start;">
|
||||
<!-- Create Link Form -->
|
||||
<div class="card">
|
||||
<div class="urls-dashboard">
|
||||
<div class="card url-form-card">
|
||||
<h3 style="font-size: 1.1rem; margin-bottom: 1.25rem; 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"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||
Shorten a New URL
|
||||
@@ -97,9 +104,8 @@
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Links Table -->
|
||||
<div class="card" style="padding: 0; overflow: hidden;">
|
||||
<div style="padding: 1.25rem 1.5rem; border-bottom: 1px solid var(--border-color); display: flex; justify-content: space-between; align-items: center;">
|
||||
<div class="card registered-links-shell" style="padding: 0; overflow: hidden;">
|
||||
<div class="registered-links-header">
|
||||
<h3 style="font-size: 1.1rem; 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="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/></svg>
|
||||
Registered Links
|
||||
@@ -113,7 +119,15 @@
|
||||
</div>
|
||||
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<table class="registered-links-table">
|
||||
<colgroup>
|
||||
<col class="col-short">
|
||||
<col class="col-destination">
|
||||
<col class="col-qr">
|
||||
<col class="col-health">
|
||||
<col class="col-tags">
|
||||
<col class="col-actions">
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Short Link</th>
|
||||
@@ -121,115 +135,112 @@
|
||||
<th>QR Code</th>
|
||||
<th>Health</th>
|
||||
<th>Tags</th>
|
||||
<th>Analytics</th>
|
||||
<th>Created</th>
|
||||
<th>Action</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% if urls.is_empty() %}
|
||||
<tr>
|
||||
<td colspan="8" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
|
||||
<td colspan="6" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
|
||||
No shortened URLs registered. Create one to get started!
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
{% for url in urls %}
|
||||
<tr>
|
||||
<td>
|
||||
<div style="display: flex; align-items: center; gap: 0.25rem;">
|
||||
<a href="/{{ url.code }}" target="_blank" style="color: var(--accent-color); font-weight: 700; text-decoration: none; font-family: monospace; font-size: 1rem;">
|
||||
{{ base_url.replace("https://", "").replace("http://", "") }}/{{ url.code }}
|
||||
</a>
|
||||
{% if url.is_password_protected() %}
|
||||
<span title="Password Protected" style="color: #fbbf24; display: inline-flex; align-items: center;">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect><path d="M7 11V7a5 5 0 0 1 10 0v4"></path></svg>
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div style="font-size: 0.8rem; color: var(--text-secondary); margin-top: 0.25rem; font-weight: 500;">
|
||||
{{ url.title.as_deref().unwrap_or("") }}
|
||||
</div>
|
||||
</td>
|
||||
<td style="max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">
|
||||
<a href="{{ url.destination }}" target="_blank" style="color: var(--text-secondary); text-decoration: none; font-size: 0.85rem;" title="{{ url.destination }}">
|
||||
{{ url.destination }}
|
||||
</a>
|
||||
{% if let Some(desc) = url.description.as_deref() %}
|
||||
{% if !desc.is_empty() %}
|
||||
<div style="font-size: 0.75rem; color: var(--text-muted); margin-top: 0.25rem;">
|
||||
{{ desc }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% if let Some(expires_at) = url.expires_at.as_deref() %}
|
||||
{% if !expires_at.is_empty() %}
|
||||
<div style="font-size: 0.75rem; color: #fca5a5; margin-top: 0.25rem; display: flex; align-items: center; gap: 0.25rem;">
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect><line x1="16" y1="2" x2="16" y2="6"></line><line x1="8" y1="2" x2="8" y2="6"></line><line x1="3" y1="10" x2="21" y2="10"></line></svg>
|
||||
Expires: {{ expires_at[0..10] }} {{ expires_at[11..16] }}
|
||||
{% if url.expired %}
|
||||
<span class="badge badge-dead" style="font-size: 0.6rem; padding: 0.1rem 0.25rem; margin-left: 0.25rem;">Expired</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% if let Some(max) = url.max_access_count %}
|
||||
<div style="font-size: 0.75rem; color: #38bdf8; margin-top: 0.25rem; display: flex; align-items: center; gap: 0.25rem;">
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"></circle><polyline points="12 6 12 12 16 14"></polyline></svg>
|
||||
Clicks: {{ url.access_count }} / {{ max }}
|
||||
{% if url.is_access_exhausted() %}
|
||||
<span class="badge badge-dead" style="font-size: 0.6rem; padding: 0.1rem 0.25rem; margin-left: 0.25rem;">Limit Reached</span>
|
||||
<td data-label="Short Link">
|
||||
<div class="short-link-wrap">
|
||||
<div class="short-link-primary">
|
||||
<a href="/{{ url.code }}" target="_blank" class="short-link-anchor">
|
||||
{{ base_url.replace("https://", "").replace("http://", "") }}/{{ url.code }}
|
||||
</a>
|
||||
{% if url.is_password_protected() %}
|
||||
<span title="Password Protected" aria-label="Password protected link" style="color: var(--warning-color); display: inline-flex; align-items: center;">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect><path d="M7 11V7a5 5 0 0 1 10 0v4"></path></svg>
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
{% if url.access_count > 0 %}
|
||||
<div style="font-size: 0.75rem; color: var(--text-secondary); margin-top: 0.25rem;">
|
||||
Clicks: {{ url.access_count }}
|
||||
</div>
|
||||
{% if let Some(title) = url.title.as_deref() %}
|
||||
{% if !title.is_empty() %}
|
||||
<div class="short-link-title">{{ title }}</div>
|
||||
{% endif %}
|
||||
{% 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 class="short-link-meta">Created {{ url.created_at[0..10] }}</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<td data-label="Destination">
|
||||
<div class="destination-block">
|
||||
<a href="{{ url.destination }}" target="_blank" class="destination-link" title="{{ url.destination }}" aria-label="Destination URL: {{ url.destination }}">
|
||||
{{ url.destination }}
|
||||
</a>
|
||||
{% if let Some(desc) = url.description.as_deref() %}
|
||||
{% if !desc.is_empty() %}
|
||||
<div class="destination-description" title="{{ desc }}">{{ desc }}</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% if let Some(expires_at) = url.expires_at.as_deref() %}
|
||||
{% if !expires_at.is_empty() %}
|
||||
<div class="destination-meta expiry">
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect><line x1="16" y1="2" x2="16" y2="6"></line><line x1="8" y1="2" x2="8" y2="6"></line><line x1="3" y1="10" x2="21" y2="10"></line></svg>
|
||||
Expires: {{ expires_at[0..10] }} {{ expires_at[11..16] }}
|
||||
{% if url.expired %}
|
||||
<span class="badge badge-dead" style="font-size: 0.6rem; padding: 0.1rem 0.25rem; margin-left: 0.25rem;">Expired</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% if let Some(max) = url.max_access_count %}
|
||||
<div class="destination-meta access">
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"></circle><polyline points="12 6 12 12 16 14"></polyline></svg>
|
||||
Clicks: {{ url.access_count }} / {{ max }}
|
||||
{% if url.is_access_exhausted() %}
|
||||
<span class="badge badge-dead" style="font-size: 0.6rem; padding: 0.1rem 0.25rem; margin-left: 0.25rem;">Limit Reached</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
{% if url.access_count > 0 %}
|
||||
<div class="destination-meta neutral">
|
||||
Clicks: {{ url.access_count }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
{% let code = url.code.as_str() %}
|
||||
{% include "components/qr_preview.html" %}
|
||||
<td data-label="Health" class="status-cell">
|
||||
<span class="badge badge-{{ url.status }}">
|
||||
{{ url.status }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<div style="display: flex; gap: 0.25rem; flex-wrap: wrap; max-width: 150px;">
|
||||
{% for t in url.tags %}
|
||||
<a href="/admin/urls?tag={{ t }}" class="badge" style="background-color: rgba(255,255,255,0.05); color: var(--text-secondary); text-decoration: none; font-size: 0.7rem; border: 1px solid var(--border-color);">
|
||||
{{ t }}
|
||||
</a>
|
||||
{% endfor %}
|
||||
<td data-label="Tags">
|
||||
{% if url.tags.is_empty() %}
|
||||
<span class="tags-empty">—</span>
|
||||
{% else %}
|
||||
<div class="tags-wrap">
|
||||
{% for t in url.tags %}
|
||||
<a href="/admin/urls?tag={{ t }}" class="badge tag-badge">
|
||||
{{ t }}
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td data-label="Actions" class="actions-cell">
|
||||
<div class="action-stack">
|
||||
<a href="/admin/analytics/url/{{ url.id }}" class="btn btn-secondary" style="padding: 0.4rem 0.6rem; font-size: 0.8rem; display: inline-flex; align-items: center; gap: 0.25rem;">
|
||||
📊 Analytics
|
||||
</a>
|
||||
<form action="/admin/urls/delete/{{ url.id }}" method="POST" onsubmit="return confirm('Are you sure you want to delete this link?');">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button type="submit" class="btn btn-danger" style="padding: 0.4rem 0.6rem; font-size: 0.8rem;">
|
||||
Delete
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<a href="/admin/analytics/url/{{ url.id }}" class="btn btn-secondary" style="padding: 0.4rem 0.6rem; font-size: 0.8rem; display: inline-flex; align-items: center; gap: 0.25rem;">
|
||||
📊 Analytics
|
||||
</a>
|
||||
</td>
|
||||
<td style="font-size: 0.8rem; color: var(--text-secondary);">
|
||||
{{ url.created_at[0..10] }}
|
||||
</td>
|
||||
<td>
|
||||
<form action="/admin/urls/delete/{{ url.id }}" method="POST" onsubmit="return confirm('Are you sure you want to delete this link?');">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button type="submit" class="btn btn-danger" style="padding: 0.4rem 0.6rem; font-size: 0.8rem;">
|
||||
Delete
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
@@ -237,8 +248,7 @@
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination Controls -->
|
||||
<div class="pagination" style="display: flex; justify-content: center; align-items: center; gap: 0.5rem; margin-top: 1rem; padding: 1rem; border-top: 1px solid var(--border-color);">
|
||||
<div class="pagination">
|
||||
{% if current_page > 1 %}
|
||||
<a href="/admin/urls?page=1{% if let Some(t) = tag_filter %}&tag={{ t }}{% endif %}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem;"><< First</a>
|
||||
<a href="/admin/urls?page={{ current_page - 1 }}{% if let Some(t) = tag_filter %}&tag={{ t }}{% endif %}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem;">< Prev</a>
|
||||
|
||||
@@ -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 %}
|
||||
+80
-67
@@ -13,7 +13,7 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; align-items: start;">
|
||||
<div class="urls-dashboard">
|
||||
<div class="card">
|
||||
<h3 style="font-size: 1.1rem; margin-bottom: 1.25rem; 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"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||
@@ -23,40 +23,44 @@
|
||||
<form action="/user/pages/create" method="POST">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
|
||||
<div class="form-group">
|
||||
<label for="title">Page Title *</label>
|
||||
<input type="text" id="title" name="title" class="form-input" placeholder="e.g. Summer Campaign" required>
|
||||
<div class="page-form-grid-2">
|
||||
<div class="form-group">
|
||||
<label for="title">Page Title *</label>
|
||||
<input type="text" id="title" name="title" class="form-input" placeholder="e.g. Summer Campaign" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="slug">SEO Slug *</label>
|
||||
<input type="text" id="slug" name="slug" class="form-input" placeholder="e.g. summer-promo" required pattern="[a-zA-Z0-9\-_]+" title="Only alphanumeric characters, dashes, and underscores allowed">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="slug">SEO Slug *</label>
|
||||
<input type="text" id="slug" name="slug" class="form-input" placeholder="e.g. summer-promo" required pattern="[a-zA-Z0-9\-_]+" title="Only alphanumeric characters, dashes, and underscores allowed">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="code">Short Code (4-Hex, optional)</label>
|
||||
<input type="text" id="code" name="code" class="form-input" placeholder="e.g. a1b2" pattern="[0-9a-fA-F]{4}" title="Must be exactly 4 hex characters">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="custom_slug">Custom Slug (optional)</label>
|
||||
<input type="text" id="custom_slug" name="custom_slug" class="form-input" placeholder="e.g. !my-page" pattern="![a-z0-9\-_]{1,24}" title="Must start with ! followed by 1-24 characters (a-z, 0-9, -, _)">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="state">Publish State</label>
|
||||
<select id="state" name="state" class="form-input">
|
||||
<option value="draft">Draft</option>
|
||||
<option value="published" selected>Published</option>
|
||||
<option value="archived">Archived</option>
|
||||
</select>
|
||||
<div class="page-form-grid-3">
|
||||
<div class="form-group">
|
||||
<label for="code">Short Code (4-Hex, optional)</label>
|
||||
<input type="text" id="code" name="code" class="form-input" placeholder="e.g. a1b2" pattern="[0-9a-fA-F]{4}" title="Must be exactly 4 hex characters">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="custom_slug">Custom Slug (optional)</label>
|
||||
<input type="text" id="custom_slug" name="custom_slug" class="form-input" placeholder="e.g. !my-page" pattern="![a-z0-9\-_]{1,24}" title="Must start with ! followed by 1-24 characters (a-z, 0-9, -, _)">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="state">Publish State</label>
|
||||
<select id="state" name="state" class="form-input">
|
||||
<option value="draft">Draft</option>
|
||||
<option value="published" selected>Published</option>
|
||||
<option value="archived">Archived</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="html_content">HTML Content *</label>
|
||||
<textarea id="html_content" name="html_content" class="form-input" style="height: 260px; font-family: monospace;" placeholder="<!DOCTYPE html> <html> <head> <title>My Page</title> </head> <body> <h1>Hello</h1> </body> </html>" required></textarea>
|
||||
<textarea id="html_content" name="html_content" class="form-input page-html-editor" placeholder="<!DOCTYPE html> <html> <head> <title>My Page</title> </head> <body> <h1>Hello</h1> </body> </html>" required></textarea>
|
||||
</div>
|
||||
<button type="submit" class="btn">Save Page</button>
|
||||
<button type="submit" class="btn" style="width: 100%; margin-top: 0.5rem;">Save Page</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card" style="padding: 0; overflow: hidden;">
|
||||
<div style="padding: 1.25rem 1.5rem; border-bottom: 1px solid var(--border-color); display: flex; justify-content: space-between; align-items: center;">
|
||||
<div class="card registered-links-shell" style="padding: 0; overflow: hidden;">
|
||||
<div class="registered-links-header">
|
||||
<h3 style="font-size: 1.1rem; 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>
|
||||
My Landing Pages
|
||||
@@ -64,66 +68,75 @@
|
||||
</div>
|
||||
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<table class="registered-pages-table">
|
||||
<colgroup>
|
||||
<col class="col-page-title">
|
||||
<col class="col-page-paths">
|
||||
<col class="col-page-status">
|
||||
<col class="col-page-qr">
|
||||
<col class="col-page-actions">
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Page Title</th>
|
||||
<th>Short Path</th>
|
||||
<th>SEO Preview Path</th>
|
||||
<th>Page</th>
|
||||
<th>Paths</th>
|
||||
<th>Status</th>
|
||||
<th>Analytics</th>
|
||||
<th>Created</th>
|
||||
<th>Action</th>
|
||||
<th>QR Code</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% if pages.is_empty() %}
|
||||
<tr>
|
||||
<td colspan="7" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
|
||||
<td colspan="5" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
|
||||
No landing pages created yet.
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
{% for page in pages %}
|
||||
<tr>
|
||||
<td>
|
||||
<div style="font-weight: 700; color: var(--text-primary);">
|
||||
{{ page.title }}
|
||||
</div>
|
||||
<div style="font-size: 0.75rem; color: var(--text-muted); font-family: monospace;">
|
||||
UUID: {{ page.id[0..8] }}...
|
||||
<td data-label="Page">
|
||||
<div class="page-title-block">
|
||||
<div class="page-title-heading">{{ page.title }}</div>
|
||||
<div class="page-title-meta">UUID: {{ page.id[0..8] }}...</div>
|
||||
<div class="page-title-meta">Created {{ page.created_at[0..10] }}</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<a href="/p/{{ page.code }}" target="_blank" style="color: var(--accent-color); font-weight: 700; text-decoration: none; font-family: monospace;">
|
||||
/p/{{ page.code }}
|
||||
</a>
|
||||
<td data-label="Paths">
|
||||
<div class="page-paths">
|
||||
<div class="page-path-group">
|
||||
<span class="page-path-caption">Short Path</span>
|
||||
<a href="/p/{{ page.code }}" target="_blank" class="page-path-link">
|
||||
/p/{{ page.code }}
|
||||
</a>
|
||||
</div>
|
||||
<div class="page-path-group">
|
||||
<span class="page-path-caption">SEO Preview Path</span>
|
||||
<a href="/p/{{ page.code }}/{{ page.slug }}" target="_blank" class="page-path-link secondary">
|
||||
/p/{{ page.code }}/{{ page.slug }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<a href="/p/{{ page.code }}/{{ page.slug }}" target="_blank" style="color: var(--text-secondary); text-decoration: none; font-size: 0.85rem; font-family: monospace;">
|
||||
/p/{{ page.code }}/{{ page.slug }}
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<td data-label="Status" class="status-cell">
|
||||
<span class="badge badge-{{ page.state }}">
|
||||
{{ page.state }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="/user/analytics/page/{{ page.id }}" class="btn btn-secondary" style="padding: 0.4rem 0.6rem; font-size: 0.8rem; display: inline-flex; align-items: center; gap: 0.25rem;">
|
||||
📊 Analytics
|
||||
</a>
|
||||
</td>
|
||||
<td style="font-size: 0.8rem; color: var(--text-secondary);">
|
||||
{{ page.created_at[0..10] }}
|
||||
</td>
|
||||
<td>
|
||||
<form action="/user/pages/delete/{{ page.id }}" method="POST" onsubmit="return confirm('Are you sure you want to delete this page?');">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button type="submit" class="btn btn-danger" style="padding: 0.4rem 0.6rem; font-size: 0.8rem;">
|
||||
Delete
|
||||
</button>
|
||||
</form>
|
||||
{% let code = page.code.as_str() %}
|
||||
{% include "components/qr_preview.html" %}
|
||||
<td data-label="Actions" class="actions-cell">
|
||||
<div class="action-stack">
|
||||
<a href="/user/analytics/page/{{ page.id }}" class="btn btn-secondary" style="padding: 0.4rem 0.6rem; font-size: 0.8rem; display: inline-flex; align-items: center; gap: 0.25rem;">
|
||||
📊 Analytics
|
||||
</a>
|
||||
<form action="/user/pages/delete/{{ page.id }}" method="POST" onsubmit="return confirm('Are you sure you want to delete this page?');">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button type="submit" class="btn btn-danger" style="padding: 0.4rem 0.6rem; font-size: 0.8rem;">
|
||||
Delete
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
@@ -132,7 +145,7 @@
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="pagination" style="display: flex; justify-content: center; align-items: center; gap: 0.5rem; margin-top: 1rem; padding: 1rem; border-top: 1px solid var(--border-color);">
|
||||
<div class="pagination">
|
||||
{% if current_page > 1 %}
|
||||
<a href="/user/pages?page=1" class="btn btn-secondary" style="padding: 0.4rem 0.8rem;"><< First</a>
|
||||
<a href="/user/pages?page={{ current_page - 1 }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem;">< Prev</a>
|
||||
|
||||
@@ -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 %}
|
||||
+116
-36
@@ -13,8 +13,8 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div style="display: grid; grid-template-columns: 1fr 2fr; gap: 1.5rem; align-items: start;">
|
||||
<div class="card">
|
||||
<div class="urls-dashboard">
|
||||
<div class="card url-form-card">
|
||||
<h3 style="font-size: 1.1rem; margin-bottom: 1.25rem; 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"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||
Create a New URL
|
||||
@@ -47,20 +47,34 @@
|
||||
<label for="tags">Tags (comma-separated, optional)</label>
|
||||
<input type="text" id="tags" name="tags" class="form-input" placeholder="e.g. blog, tech, personal">
|
||||
</div>
|
||||
<button type="submit" class="btn">Create Link</button>
|
||||
<button type="submit" class="btn" style="width: 100%; margin-top: 0.5rem;">Create Link</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card" style="padding: 0; overflow: hidden;">
|
||||
<div style="padding: 1.25rem 1.5rem; border-bottom: 1px solid var(--border-color); display: flex; justify-content: space-between; align-items: center;">
|
||||
<div class="card registered-links-shell" style="padding: 0; overflow: hidden;">
|
||||
<div class="registered-links-header">
|
||||
<h3 style="font-size: 1.1rem; 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="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/></svg>
|
||||
My Short Links
|
||||
</h3>
|
||||
{% if let Some(tag) = tag_filter %}
|
||||
<span class="badge badge-healthy" style="text-transform: none;">
|
||||
Filtered by tag: {{ tag }}
|
||||
<a href="/user/urls" style="color: inherit; text-decoration: none; margin-left: 0.5rem; font-weight: bold;">×</a>
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<table class="registered-links-table">
|
||||
<colgroup>
|
||||
<col class="col-short">
|
||||
<col class="col-destination">
|
||||
<col class="col-qr">
|
||||
<col class="col-health">
|
||||
<col class="col-tags">
|
||||
<col class="col-actions">
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Short Link</th>
|
||||
@@ -68,45 +82,111 @@
|
||||
<th>QR Code</th>
|
||||
<th>Health</th>
|
||||
<th>Tags</th>
|
||||
<th>Analytics</th>
|
||||
<th>Created</th>
|
||||
<th>Action</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% if urls.is_empty() %}
|
||||
<tr>
|
||||
<td colspan="9" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
|
||||
<td colspan="6" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
|
||||
No short URLs created yet.
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
{% for url in urls %}
|
||||
<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>
|
||||
<td data-label="Short Link">
|
||||
<div class="short-link-wrap">
|
||||
<div class="short-link-primary">
|
||||
<a href="/{{ url.code }}" target="_blank" class="short-link-anchor">
|
||||
{{ base_url.replace("https://", "").replace("http://", "") }}/{{ url.code }}
|
||||
</a>
|
||||
{% if url.is_password_protected() %}
|
||||
<span title="Password Protected" aria-label="Password protected link" style="color: var(--warning-color); display: inline-flex; align-items: center;">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect><path d="M7 11V7a5 5 0 0 1 10 0v4"></path></svg>
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if let Some(title) = url.title.as_deref() %}
|
||||
{% if !title.is_empty() %}
|
||||
<div class="short-link-title">{{ title }}</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
<div class="short-link-meta">Created {{ url.created_at[0..10] }}</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>{{ url.status }}</td>
|
||||
<td>{% if url.tags.is_empty() %}-{% else %}{{ url.tags.join(", ") }}{% endif %}</td>
|
||||
<td>
|
||||
<a href="/user/analytics/url/{{ url.id }}" class="btn btn-secondary" style="padding: 0.4rem 0.75rem; font-size: 0.85rem; text-decoration: none; display: inline-flex; align-items: center; justify-content: center;">View</a>
|
||||
<td data-label="Destination">
|
||||
<div class="destination-block">
|
||||
<a href="{{ url.destination }}" target="_blank" class="destination-link" title="{{ url.destination }}" aria-label="Destination URL: {{ url.destination }}">
|
||||
{{ url.destination }}
|
||||
</a>
|
||||
{% if let Some(desc) = url.description.as_deref() %}
|
||||
{% if !desc.is_empty() %}
|
||||
<div class="destination-description" title="{{ desc }}">{{ desc }}</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% if let Some(expires_at) = url.expires_at.as_deref() %}
|
||||
{% if !expires_at.is_empty() %}
|
||||
<div class="destination-meta expiry">
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect><line x1="16" y1="2" x2="16" y2="6"></line><line x1="8" y1="2" x2="8" y2="6"></line><line x1="3" y1="10" x2="21" y2="10"></line></svg>
|
||||
Expires: {{ expires_at[0..10] }} {{ expires_at[11..16] }}
|
||||
{% if url.expired %}
|
||||
<span class="badge badge-dead" style="font-size: 0.6rem; padding: 0.1rem 0.25rem; margin-left: 0.25rem;">Expired</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% if let Some(max) = url.max_access_count %}
|
||||
<div class="destination-meta access">
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"></circle><polyline points="12 6 12 12 16 14"></polyline></svg>
|
||||
Clicks: {{ url.access_count }} / {{ max }}
|
||||
{% if url.is_access_exhausted() %}
|
||||
<span class="badge badge-dead" style="font-size: 0.6rem; padding: 0.1rem 0.25rem; margin-left: 0.25rem;">Limit Reached</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
{% if url.access_count > 0 %}
|
||||
<div class="destination-meta neutral">
|
||||
Clicks: {{ url.access_count }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
<td>{{ url.created_at[0..10] }}</td>
|
||||
<td>
|
||||
<form action="/user/urls/delete/{{ url.id }}" method="POST" onsubmit="return confirm('Delete this link?');">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button type="submit" class="btn btn-secondary" style="padding: 0.4rem 0.75rem;">Delete</button>
|
||||
</form>
|
||||
{% let code = url.code.as_str() %}
|
||||
{% include "components/qr_preview.html" %}
|
||||
<td data-label="Health" class="status-cell">
|
||||
<span class="badge badge-{{ url.status }}">
|
||||
{{ url.status }}
|
||||
</span>
|
||||
</td>
|
||||
<td data-label="Tags">
|
||||
{% if url.tags.is_empty() %}
|
||||
<span class="tags-empty">—</span>
|
||||
{% else %}
|
||||
<div class="tags-wrap">
|
||||
{% for t in url.tags %}
|
||||
<a href="/user/urls?tag={{ t }}" class="badge tag-badge">
|
||||
{{ t }}
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td data-label="Actions" class="actions-cell">
|
||||
<div class="action-stack">
|
||||
<a href="/user/analytics/url/{{ url.id }}" class="btn btn-secondary" style="padding: 0.4rem 0.75rem; font-size: 0.85rem; text-decoration: none; display: inline-flex; align-items: center; justify-content: center;">
|
||||
📊 Analytics
|
||||
</a>
|
||||
<form action="/user/urls/delete/{{ url.id }}" method="POST" onsubmit="return confirm('Delete this link?');">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button type="submit" class="btn btn-danger" style="padding: 0.4rem 0.75rem;">
|
||||
Delete
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
@@ -115,10 +195,10 @@
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="pagination" style="display: flex; justify-content: center; align-items: center; gap: 0.5rem; margin-top: 1rem; padding: 1rem; border-top: 1px solid var(--border-color);">
|
||||
<div class="pagination">
|
||||
{% if current_page > 1 %}
|
||||
<a href="/user/urls?page=1" class="btn btn-secondary" style="padding: 0.4rem 0.8rem;"><< First</a>
|
||||
<a href="/user/urls?page={{ current_page - 1 }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem;">< Prev</a>
|
||||
<a href="/user/urls?page=1{% if let Some(t) = tag_filter %}&tag={{ t }}{% endif %}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem;"><< First</a>
|
||||
<a href="/user/urls?page={{ current_page - 1 }}{% if let Some(t) = tag_filter %}&tag={{ t }}{% endif %}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem;">< Prev</a>
|
||||
{% else %}
|
||||
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem;"><< First</span>
|
||||
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem;">< Prev</span>
|
||||
@@ -128,13 +208,13 @@
|
||||
{% if self.is_current(p) %}
|
||||
<span class="btn" style="padding: 0.4rem 0.8rem; font-weight: 700;">{{ p }}</span>
|
||||
{% else %}
|
||||
<a href="/user/urls?page={{ p }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem;">{{ p }}</a>
|
||||
<a href="/user/urls?page={{ p }}{% if let Some(t) = tag_filter %}&tag={{ t }}{% endif %}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem;">{{ p }}</a>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% if current_page < total_pages %}
|
||||
<a href="/user/urls?page={{ current_page + 1 }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem;">Next ></a>
|
||||
<a href="/user/urls?page={{ total_pages }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem;">Last >></a>
|
||||
<a href="/user/urls?page={{ current_page + 1 }}{% if let Some(t) = tag_filter %}&tag={{ t }}{% endif %}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem;">Next ></a>
|
||||
<a href="/user/urls?page={{ total_pages }}{% if let Some(t) = tag_filter %}&tag={{ t }}{% endif %}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem;">Last >></a>
|
||||
{% else %}
|
||||
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem;">Next ></span>
|
||||
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem;">Last >></span>
|
||||
|
||||
@@ -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"
|
||||
|
||||
Loaded 100 of 118 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user