13 Commits
56 changed files with 12266 additions and 2804 deletions

No files matched your search

Generated
+2 -554
View File
@@ -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.5.1"
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
View File
@@ -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.5.1"
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"
+931 -574
View File
File diff suppressed because it is too large. Load diff
+888
View File
@@ -0,0 +1,888 @@
# BZOD Administrator Guide
Version: v0.5.1
---
# 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.
+650
View File
@@ -0,0 +1,650 @@
# BZOD Architecture Guide
Version: v0.5.1
---
# 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.rs
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
↓
Record analytics
↓
302 Redirect
```
---
## 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
v0.5.0 includes more than 90 automated 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 v0.5.0 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
+584
View File
@@ -0,0 +1,584 @@
# Backup & Restore Guide
Version: v0.5.1
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.
+271 -3
View File
@@ -1,9 +1,277 @@
# 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.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 +280,13 @@
* Advanced pagination
* Visitor log tables
### Improved
## Improved
* Registry pagination
* Analytics navigation
* Export performance
### Fixed
## Fixed
* Pagination edge cases
* Analytics sorting consistency
+271
View File
@@ -0,0 +1,271 @@
# 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.5.1 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
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
---
# 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
View File
@@ -1,382 +1,354 @@
# BZOD vs Other Self-Hosted URL Shorteners
# BZOD v0.5.1 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.
+503
View File
@@ -0,0 +1,503 @@
# DATABASES.md
# BZOD Database Architecture
BZOD v0.5.1 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
View File
@@ -0,0 +1,604 @@
# BZOD Installation Guide
Version: v0.5.1
---
# 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.5.0-linux-amd64.tar.gz
```
Extract:
```bash
tar -xzf bzod-v0.5.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.
+732
View File
@@ -0,0 +1,732 @@
# BZOD Multi-User Architecture Guide
Version: v0.5.1
---
# 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.
+273
View File
@@ -0,0 +1,273 @@
# 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
## 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.
+662
View File
@@ -0,0 +1,662 @@
# BZOD Security Guide
Version: v0.5.1
---
# 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.5.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
```
---
# 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.5.0 includes tests covering:
* Authentication
* Authorization
* Session validation
* CSRF enforcement
* Slug ownership
* User isolation
* Upgrade migrations
* Backup integrity
* Disaster recovery
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.5.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.
+365 -315
View File
@@ -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,335 @@ 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.
---
# 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
View File
@@ -0,0 +1,795 @@
# Upgrade Guide
Version: v0.5.1
This document describes the upgrade process for existing BZOD deployments upgrading to BZOD v0.5.1.
---
# Overview
BZOD v0.5.1 is a platform hardening release focused on:
* Global namespace integrity
* Multi-tenant safety
* Dashboard parity
* QR reliability
* Upgrade validation
* Restore collision protection
* Ownership isolation
While v0.5.0 introduced the multi-user architecture, v0.5.1 strengthens the operational and data integrity guarantees required for production deployments.
---
# Supported Upgrade Paths
Supported:
```text
v0.5.0 → v0.5.1
v0.4.x → v0.5.1
```
Recommended:
```text
v0.4.x → v0.5.0 → v0.5.1
```
Unsupported:
```text
v0.3.x → v0.5.1
```
Older installations should first upgrade to v0.4.x.
---
# Major Changes in v0.5.1
## Global Namespace Enforcement
BZOD now enforces a single platform-wide slug namespace.
The following resources can no longer share the same slug:
* Administrator URLs
* Administrator Landing Pages
* User URLs
* User Landing Pages
Example:
```text
Admin URL:
hello
User URL:
hello
```
Result:
```text
Upgrade aborted.
Namespace conflict detected.
```
---
## Global Slug Registry
BZOD now treats the slug registry as the authoritative source of truth.
All slugs are registered in:
```text
system.db
```
Table:
```text
global_slugs
```
The registry tracks:
```text
slug
owner_user_id
target_type
target_id
status
```
---
## Reservation-Based Slug Allocation
Slug creation now follows:
```text
Quota Validation
↓
Reserve Global Slug
↓
Create Resource
↓
Activate Slug
↓
Update Quotas
↓
Audit Log
```
Benefits:
* Prevents race conditions
* Prevents duplicate allocations
* Improves rollback safety
* Improves multi-user integrity
---
## Stale Reservation Recovery
BZOD automatically cleans abandoned reservations created by:
* Server crashes
* Interrupted requests
* Failed transactions
Stale reservations are validated and cleaned during startup.
---
# Breaking Changes
## Global Slug Uniqueness
Deployments containing duplicate slugs will not upgrade.
Example:
```text
User 1:
!nx9-dns-server
User 3:
!nx9-dns-server
```
Result:
```text
Upgrade aborted.
Database upgrade aborted due to slug conflicts.
```
Conflicts must be resolved before migration can continue.
---
## Restore Collision Protection
Restore operations now validate namespace integrity.
Example:
```text
Existing slug:
company
Backup slug:
company
```
Result:
```text
Restore aborted.
Slug conflict detected.
```
No partial restore occurs.
---
# Pre-Upgrade Checklist
Before upgrading:
* Create backup
* Verify backup integrity
* Stop active traffic
* Run diagnostics
* Resolve namespace conflicts
---
# Step 1: Create Backup
Full backup:
```bash
bzod backup
```
Manual backup:
```bash
tar czf bzod-backup.tar.gz data/
```
---
# Step 2: Verify Backup
Verify archive contents:
```text
users.db
system.db
users/
```
If upgrading from legacy versions:
```text
admin.db
content.db
analytics.db
```
should also be present.
---
# Step 3: Run Diagnostics
Execute:
```bash
bzod doctor
```
Expected:
```text
Overall Status: HEALTHY
```
Verify:
```text
No namespace conflicts detected
No ownership violations detected
No registry corruption detected
```
---
# Step 4: Stop Service
Systemd:
```bash
sudo systemctl stop bzod
```
Docker:
```bash
docker compose down
```
---
# Upgrade Procedure
## Install New Version
Build:
```bash
cargo build --release
```
Or install official release binary.
---
## Start BZOD
```bash
bzod serve
```
or:
```bash
docker compose up -d
```
---
# Automatic Upgrade Actions
During startup BZOD automatically performs:
1. Database migration checks
2. Namespace integrity validation
3. Registry validation
4. Stale reservation cleanup
5. Global slug verification
6. Schema migration execution
---
# Namespace Validation
BZOD scans:
```text
legacy databases
administrator databases
tenant databases
```
for duplicate slugs.
Example:
```text
Owner 1:
hello
Owner 3:
hello
```
Result:
```text
Namespace conflict detected.
Upgrade aborted.
```
---
# Registry Validation
BZOD validates:
* Duplicate slug entries
* Missing owners
* Missing targets
* Invalid target types
* Invalid status values
Allowed target types:
```text
url
page
```
Allowed statuses:
```text
reserving
active
disabled
```
---
# Post-Upgrade Validation
Run:
```bash
bzod doctor
```
Expected:
```text
Namespace Integrity: PASS
Registry Integrity: PASS
Ownership Integrity: PASS
Database Integrity: PASS
```
---
# Login Validation
Verify:
```text
Administrator login succeeds
User login succeeds
```
---
# URL Validation
Verify:
```text
https://example.com/abc123
```
redirects correctly.
Expected:
```http
302 Found
```
or configured redirect behavior.
---
# Landing Page Validation
Verify:
```text
https://example.com/p/demo
```
renders successfully.
Verify:
```text
https://example.com/demo
```
redirects permanently:
```http
301 Moved Permanently
```
to:
```text
/p/demo
```
---
# QR Validation
Verify:
```text
/api/qr/demo.png
/api/qr/demo.svg
```
Expected:
```http
200 OK
```
Content types:
```text
image/png
image/svg+xml
```
Disabled resources:
```http
410 Gone
```
Missing resources:
```http
404 Not Found
```
---
# Dashboard Validation
Verify Administrator Dashboards:
* URLs
* Landing Pages
* Analytics
* QR Preview
* PNG Download
* SVG Download
Verify Standard User Dashboards:
* URLs
* Landing Pages
* Analytics
* QR Preview
* PNG Download
* SVG Download
Both should provide equivalent functionality except for administrator-only operations.
---
# Ownership Isolation Validation
Verify:
```text
User A
```
cannot access:
```text
User B Analytics
User B URLs
User B Landing Pages
User B Exports
```
Expected:
```http
403 Forbidden
```
---
# Backup & Restore Validation
Create backup:
```bash
bzod backup
```
Restore backup:
```bash
bzod restore backup.tar.gz
```
Expected:
* No namespace conflicts
* No ownership conflicts
* No partial restores
---
# Rollback Procedure
If upgrade validation fails:
Stop service:
```bash
sudo systemctl stop bzod
```
or:
```bash
docker compose down
```
Restore backup:
```bash
bzod restore backup.tar.gz
```
or restore archived data directory.
Reinstall previous release.
---
# Docker Upgrade
Pull image:
```bash
docker compose pull
```
Restart:
```bash
docker compose up -d
```
Monitor:
```bash
docker compose logs -f
```
Expected:
```text
Namespace validation passed
Registry validation passed
Server started successfully
```
---
# Systemd Upgrade
Replace binary:
```bash
sudo cp bzod /usr/local/bin/
```
Restart:
```bash
sudo systemctl restart bzod
```
Verify:
```bash
sudo systemctl status bzod
```
Expected:
```text
active (running)
```
---
# Automated Upgrade Validation
Execute:
```bash
cargo fmt --check
cargo clippy --all-targets -- -D warnings
cargo test --all-targets -- --nocapture
```
Particularly validate:
```text
upgrade_validation_tests
backup_restore_tests
slug_registry_tests
ownership_tests
analytics_parity_tests
transaction_tests
```
---
# Recommended Upgrade Workflow
```text
1. Create Backup
2. Verify Backup
3. Run bzod doctor
4. Resolve Namespace Conflicts
5. Stop Service
6. Install v0.5.1
7. Start Service
8. Validate Registry
9. Validate URLs
10. Validate Landing Pages
11. Validate QR Endpoints
12. Validate Dashboards
13. Validate Ownership Isolation
14. Return To Production
```
---
# Troubleshooting
## Upgrade Aborted Due To Slug Conflicts
Example:
```text
Slug '!nx9-dns-server'
is defined in multiple content databases
by owners [1,3]
```
Cause:
```text
Duplicate slug detected.
```
Resolution:
```text
Rename or remove conflicting resources.
Restart upgrade.
```
---
## QR Codes Return 404
Verify:
```text
global_slugs
```
contains the slug.
Verify slug status:
```text
active
```
---
## Landing Page Redirect Fails
Verify:
```text
target_type = page
```
in:
```text
global_slugs
```
---
## Ownership Errors
Run:
```bash
bzod doctor
```
Verify ownership integrity passes.
---
# Upgrade Status
BZOD v0.5.1 upgrade path has been validated through:
* Migration Tests
* Upgrade Validation Tests
* Namespace Integrity Tests
* Ownership Isolation Tests
* Backup & Restore Tests
* Dashboard Parity Tests
* QR Endpoint Tests
* Routing Tests
The v0.5.1 upgrade path is considered production-ready.
+67 -3
View File
@@ -22,11 +22,23 @@ 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 dbs = vec![
("admin", admin_dir.join("admin.db")),
("system", admin_dir.join("system.db")),
("users", admin_dir.join("users.db")),
("legacy content", config.data_dir.join("content.db")),
("legacy analytics", config.data_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 +87,58 @@ 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::db::users::verify_global_slug_registry_integrity(
&sys_conn,
&usr_conn,
&config.data_dir,
) {
Ok((errors, warnings)) => {
if errors.is_empty() && warnings.is_empty() {
println!(" Status: HEALTHY (no issues found)");
} else {
if !errors.is_empty() {
println!(" Errors (Action Required):");
for err in &errors {
println!(" - {}", err);
}
all_healthy = false;
}
if !warnings.is_empty() {
println!(" Warnings (Attention Needed):");
for warn in &warnings {
println!(" - {}", warn);
}
}
}
}
Err(e) => {
println!(" Status: ERROR running integrity check: {}", 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");
+83 -22
View File
@@ -15,34 +15,95 @@ pub fn perform_restore(
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;
// 2. Unpack to temporary directory first
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());
}
// 3. Run validation on 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());
}
}
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());
// Registry integrity check
let system_db_path = if temp_dir.join("admin/system.db").exists() {
temp_dir.join("admin/system.db")
} else {
temp_dir.join("system.db")
};
let users_db_path = if temp_dir.join("admin/users.db").exists() {
temp_dir.join("admin/users.db")
} else {
temp_dir.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::db::users::verify_global_slug_registry_integrity(
&system_conn,
&users_conn,
&temp_dir,
) {
Ok((errors, _warnings)) => {
if !errors.is_empty() {
let _ = std::fs::remove_dir_all(&temp_dir);
return Err(format!("Registry integrity errors in backup: {:?}", errors).into());
}
}
Err(e) => {
let _ = std::fs::remove_dir_all(&temp_dir);
return Err(format!("Failed to verify registry integrity in backup: {}", 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)?;
// 4. If validation succeeds, copy temp_dir contents to data_dir
if data_dir.exists() {
let _ = std::fs::remove_dir_all(data_dir);
}
std::fs::create_dir_all(data_dir)?;
fn copy_dir_all(src: &std::path::Path, dst: &std::path::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
View File
@@ -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,
+29 -7
View File
@@ -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()),
}
}
+58
View File
@@ -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");
@@ -317,6 +337,44 @@ 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::db::users::verify_global_slug_registry_integrity(
&system_conn,
&users_conn,
&config.data_dir,
) {
Ok((errors, warnings)) => {
for err in errors {
tracing::error!("Global registry integrity error: {}", err);
}
for warn in warnings {
tracing::warn!("Global registry integrity warning: {}", warn);
}
}
Err(e) => {
tracing::error!("Failed to verify global registry integrity: {}", e);
}
}
}
Ok(db)
}
+469 -2
View File
@@ -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,459 @@ 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 verify_global_slug_registry_integrity(
system_conn: &Connection,
users_conn: &Connection,
data_dir: &std::path::Path,
) -> Result<(Vec<String>, Vec<String>), Box<dyn std::error::Error>> {
use chrono::{DateTime, Utc};
let mut errors = Vec::new();
let mut warnings = Vec::new();
// 1. Check duplicate slugs
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 {
errors.push(format!(
"Duplicate slugs found in global_slugs table (total rows: {}, distinct slugs: {})",
total_count, distinct_count
));
}
// 2. Scan all global slugs
let mut stmt = system_conn.prepare(
"SELECT slug, owner_user_id, target_type, target_id, created_at, status FROM global_slugs;",
)?;
let mut rows = stmt.query([])?;
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)?;
// Target type check
if target_type != "url" && target_type != "page" {
errors.push(format!(
"Slug '{}' has invalid target_type '{}'",
slug, target_type
));
}
// Status check
if status != "active" && status != "disabled" && status != "reserving" {
errors.push(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 {
errors.push(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::minutes(15) {
warnings.push(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())
{
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")
};
if !content_db_path.exists() {
errors.push(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 {
errors.push(format!("Slug '{}' (type: '{}', id: '{}') references missing target record in owner's content database", slug, target_type, target_id));
}
}
Err(e) => {
errors.push(format!(
"Slug '{}' owner content database could not be opened: {}",
slug, e
));
}
}
}
}
}
Ok((errors, warnings))
}
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,
+2
View File
@@ -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
View File
@@ -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(),
}
}
}
+1046 -162
View File
File diff suppressed because it is too large. Load diff
+209 -45
View File
@@ -149,20 +149,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 +274,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(),
}
}
@@ -434,16 +512,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 +628,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(),
}
}
+137 -13
View File
@@ -165,7 +165,53 @@ pub async fn api_bulk_url(
.into_response();
}
let mut conn = state.content_db.lock().unwrap();
// 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: "Database error".to_string(),
}),
)
.into_response()
}
};
(u.id, user_dbs.content.clone())
}
};
// Check quota
{
let users_conn = state.users_db.lock().unwrap();
if let Some(quotas) =
crate::db::users::get_user_quotas(&users_conn, target_user_id).unwrap_or(None)
{
if quotas.current_urls + (payload.len() as i64) > quotas.max_urls {
return (
StatusCode::FORBIDDEN,
Json(BulkErrorResponse {
error: "Quota limit exceeded".to_string(),
}),
)
.into_response();
}
} else {
return (
StatusCode::FORBIDDEN,
Json(BulkErrorResponse {
error: "User quota not found".to_string(),
}),
)
.into_response();
}
}
let mut conn = content_db.lock().unwrap();
let tx = match conn.transaction() {
Ok(t) => t,
Err(e) => {
@@ -180,6 +226,7 @@ pub async fn api_bulk_url(
};
let mut created_urls = Vec::new();
let mut reserved_slugs: Vec<String> = Vec::new();
for item in payload {
let mut code = item.code.unwrap_or_default().trim().to_lowercase();
@@ -188,6 +235,12 @@ pub async fn api_bulk_url(
} else {
if code.len() != 6 || !code.chars().all(|c| c.is_ascii_hexdigit()) {
let _ = tx.rollback();
// Release reserving slugs
let system_conn = state.system_db.lock().unwrap();
for slug in &reserved_slugs {
let _ =
crate::db::users::release_global_slug(&system_conn, slug, target_user_id);
}
return (
StatusCode::BAD_REQUEST,
Json(BulkErrorResponse {
@@ -198,11 +251,66 @@ pub async fn api_bulk_url(
}
}
// Reserve slug
{
let system_conn = state.system_db.lock().unwrap();
// Check availability in system.db and also check in our currently reserved slugs in this batch
let available = crate::db::users::is_slug_available(&system_conn, &code)
.unwrap_or(false)
&& !reserved_slugs.contains(&code);
if !available {
let _ = tx.rollback();
for slug in &reserved_slugs {
let _ =
crate::db::users::release_global_slug(&system_conn, slug, target_user_id);
}
return (
StatusCode::CONFLICT,
Json(BulkErrorResponse {
error: format!("Short code '{}' already exists", code),
}),
)
.into_response();
}
if let Err(e) = crate::db::users::register_global_slug(
&system_conn,
&code,
target_user_id,
"url",
"",
"reserving",
) {
let _ = tx.rollback();
for slug in &reserved_slugs {
let _ =
crate::db::users::release_global_slug(&system_conn, slug, target_user_id);
}
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(BulkErrorResponse {
error: format!("Failed to reserve slug '{}': {}", code, e),
}),
)
.into_response();
}
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) => {
let _ = tx.rollback();
let system_conn = state.system_db.lock().unwrap();
for slug in &reserved_slugs {
let _ = crate::db::users::release_global_slug(
&system_conn,
slug,
target_user_id,
);
}
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(BulkErrorResponse {
@@ -229,20 +337,13 @@ pub async fn api_bulk_url(
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();
let system_conn = state.system_db.lock().unwrap();
for slug in &reserved_slugs {
let _ =
crate::db::users::release_global_slug(&system_conn, slug, target_user_id);
}
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(BulkErrorResponse {
@@ -255,6 +356,10 @@ pub async fn api_bulk_url(
}
if let Err(e) = tx.commit() {
let system_conn = state.system_db.lock().unwrap();
for slug in &reserved_slugs {
let _ = crate::db::users::release_global_slug(&system_conn, slug, target_user_id);
}
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(BulkErrorResponse {
@@ -264,6 +369,25 @@ pub async fn api_bulk_url(
.into_response();
}
// Activate slugs
{
let system_conn = state.system_db.lock().unwrap();
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 = state.users_db.lock().unwrap();
for _ in 0..created_urls.len() {
let _ = crate::db::users::increment_quota_counter(&users_conn, target_user_id, "urls");
}
}
// 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());
+10 -5
View File
@@ -63,14 +63,19 @@ 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,
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
// 2. Get content database connection - admin (user_id=1) uses legacy content_db,
// tenant users use per-user content databases
let content_conn = if owner_user_id == 1 {
state.content_db.clone()
} else {
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(),
+59 -71
View File
@@ -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)
+32 -12
View File
@@ -24,8 +24,10 @@ 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();
}
@@ -49,7 +51,7 @@ pub async fn resolve_redirect(
.optional()
};
let (owner_user_id, _target_type, _target_id, slug_status) = match slug_info {
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
@@ -67,14 +69,24 @@ pub async fn resolve_redirect(
.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(),
// If target type is page, redirect permanently to /p/slug
if target_type == "page" {
return Redirect::permanent(&format!("/p/{}", code)).into_response();
}
// 2. Get content database connection - admin (user_id=1) uses legacy content_db,
// tenant users use per-user content databases
let content_conn = if owner_user_id == 1 {
state.content_db.clone()
} else {
match state.get_user_dbs(owner_user_id) {
Ok(dbs) => dbs.content,
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
}
};
let url_opt = {
let conn = user_dbs.content.lock().unwrap();
let conn = content_conn.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(),
@@ -96,7 +108,7 @@ pub async fn resolve_redirect(
if expires_at.with_timezone(&Utc) < Utc::now() {
// Mark as expired in DB asynchronously/immediately
{
let conn = user_dbs.content.lock().unwrap();
let conn = content_conn.lock().unwrap();
let _ = conn.execute(
"UPDATE urls SET expired = 1 WHERE id = ?1;",
[url.id.clone()],
@@ -131,12 +143,12 @@ pub async fn resolve_redirect(
// 6. Increment access count & retrieve preview config
let _new_access_count = {
let conn = user_dbs.content.lock().unwrap();
let conn = content_conn.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();
let conn = content_conn.lock().unwrap();
crate::db::preview::get_preview(&conn, &url.id).unwrap_or(None)
};
@@ -188,6 +200,14 @@ pub async fn resolve_redirect(
}
.into_response()
} else {
Redirect::temporary(&url.destination).into_response()
{
use axum::http::{header, HeaderValue};
let mut resp = (StatusCode::MOVED_PERMANENTLY, "").into_response();
resp.headers_mut().insert(
header::LOCATION,
HeaderValue::from_str(&url.destination).unwrap(),
);
resp
}
}
}
+16
View File
@@ -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(
+9
View File
@@ -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>
+32
View File
@@ -7,6 +7,38 @@
{% block header_title %}System Health Dashboard{% endblock %}
{% block content %}
{% if !registry_errors.is_empty() || !registry_warnings.is_empty() %}
<div style="display: grid; grid-template-columns: 1fr; gap: 1rem; margin-bottom: 1.5rem;">
{% if !registry_errors.is_empty() %}
<div class="card" style="border: 1px solid var(--dead-color); background-color: rgba(220, 53, 69, 0.1); padding: 1.5rem;">
<h3 style="font-size: 1.15rem; color: var(--dead-color); display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.5rem; margin-top: 0;">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
Global Registry Errors (Action Required)
</h3>
<ul style="margin: 0; padding-left: 1.5rem; color: var(--text-primary); display: flex; flex-direction: column; gap: 0.25rem;">
{% for err in registry_errors %}
<li>{{ err }}</li>
{% endfor %}
</ul>
</div>
{% endif %}
{% if !registry_warnings.is_empty() %}
<div class="card" style="border: 1px solid #ffc107; background-color: rgba(255, 193, 7, 0.1); padding: 1.5rem;">
<h3 style="font-size: 1.15rem; color: #ffc107; display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.5rem; margin-top: 0;">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
Global Registry Warnings (Attention Needed)
</h3>
<ul style="margin: 0; padding-left: 1.5rem; color: var(--text-primary); display: flex; flex-direction: column; gap: 0.25rem;">
{% for warn in registry_warnings %}
<li>{{ warn }}</li>
{% endfor %}
</ul>
</div>
{% endif %}
</div>
{% endif %}
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; align-items: start; margin-bottom: 1.5rem;">
<!-- DB Health Report -->
<div class="card" style="padding: 0; overflow: hidden;">
+104 -10
View File
@@ -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;">&lt;&lt; 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;">&lt; 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;">&lt;&lt; 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;">&lt; Prev</a>
{% else %}
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem; font-size: 0.85rem;">&lt;&lt; First</span>
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem; font-size: 0.85rem;">&lt; 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 &gt;</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 &gt;&gt;</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 &gt;</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 &gt;&gt;</a>
{% else %}
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem; font-size: 0.85rem;">Next &gt;</span>
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem; font-size: 0.85rem;">Last &gt;&gt;</span>
+4 -1
View File
@@ -84,6 +84,7 @@
<th>SEO Preview Path</th>
<th>Status</th>
<th>Analytics</th>
<th>QR Code</th>
<th>Created</th>
<th>Action</th>
</tr>
@@ -91,7 +92,7 @@
<tbody>
{% if pages.is_empty() %}
<tr>
<td colspan="7" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
<td colspan="8" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
No landing pages registered. Create one to get started!
</td>
</tr>
@@ -126,6 +127,8 @@
📊 Analytics
</a>
</td>
{% let code = page.code.as_str() %}
{% include "components/qr_preview.html" %}
<td style="font-size: 0.8rem; color: var(--text-secondary);">
{{ page.created_at[0..10] }}
</td>
+104 -10
View File
@@ -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;">&lt;&lt; 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;">&lt; 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;">&lt;&lt; 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;">&lt; Prev</a>
{% else %}
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem; font-size: 0.85rem;">&lt;&lt; First</span>
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem; font-size: 0.85rem;">&lt; 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 &gt;</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 &gt;&gt;</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 &gt;</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 &gt;&gt;</a>
{% else %}
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem; font-size: 0.85rem;">Next &gt;</span>
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem; font-size: 0.85rem;">Last &gt;&gt;</span>
+2 -9
View File
@@ -191,15 +191,8 @@
{% endif %}
{% endif %}
</td>
<td style="text-align: center; vertical-align: middle;">
<a href="/api/qr/{{ url.code }}.png" target="_blank" title="View QR Code">
<img src="/api/qr/{{ url.code }}.svg" alt="QR" style="width: 32px; height: 32px; border-radius: 4px; border: 1px solid var(--border-color); background: white; padding: 1px;">
</a>
<div style="margin-top: 0.25rem; display: flex; gap: 0.25rem; justify-content: center;">
<a href="/api/qr/{{ url.code }}.png" download class="badge" style="font-size: 0.65rem; background-color: rgba(99, 102, 241, 0.1); color: #818cf8; text-decoration: none; padding: 0.1rem 0.25rem;">PNG</a>
<a href="/api/qr/{{ url.code }}.svg" download class="badge" style="font-size: 0.65rem; background-color: rgba(99, 102, 241, 0.1); color: #818cf8; text-decoration: none; padding: 0.1rem 0.25rem;">SVG</a>
</div>
</td>
{% let code = url.code.as_str() %}
{% include "components/qr_preview.html" %}
<td>
<span class="badge badge-{{ url.status }}">
{{ url.status }}
-87
View File
@@ -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 %}
+4 -1
View File
@@ -72,6 +72,7 @@
<th>SEO Preview Path</th>
<th>Status</th>
<th>Analytics</th>
<th>QR Code</th>
<th>Created</th>
<th>Action</th>
</tr>
@@ -79,7 +80,7 @@
<tbody>
{% if pages.is_empty() %}
<tr>
<td colspan="7" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
<td colspan="8" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
No landing pages created yet.
</td>
</tr>
@@ -114,6 +115,8 @@
📊 Analytics
</a>
</td>
{% let code = page.code.as_str() %}
{% include "components/qr_preview.html" %}
<td style="font-size: 0.8rem; color: var(--text-secondary);">
{{ page.created_at[0..10] }}
</td>
-89
View File
@@ -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 %}
+2 -11
View File
@@ -85,17 +85,8 @@
<tr>
<td><a href="/{{ url.code }}" target="_blank" style="color: var(--accent-color); text-decoration: none; font-family: monospace;">/{{ url.code }}</a></td>
<td style="max-width: 260px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">{{ url.destination }}</td>
<td>
<div style="display: flex; flex-direction: column; align-items: center; gap: 0.25rem;">
<a href="/api/qr/{{ url.code }}.png" target="_blank" title="View QR Code">
<img src="/api/qr/{{ url.code }}.svg" alt="QR" style="width: 32px; height: 32px; border-radius: 4px; border: 1px solid var(--border-color); background: white; padding: 1px;">
</a>
<div style="display: flex; gap: 0.25rem;">
<a href="/api/qr/{{ url.code }}.png" download class="badge" style="font-size: 0.65rem; background-color: rgba(99, 102, 241, 0.1); color: #818cf8; text-decoration: none; padding: 0.1rem 0.25rem;">PNG</a>
<a href="/api/qr/{{ url.code }}.svg" download class="badge" style="font-size: 0.65rem; background-color: rgba(99, 102, 241, 0.1); color: #818cf8; text-decoration: none; padding: 0.1rem 0.25rem;">SVG</a>
</div>
</div>
</td>
{% let code = url.code.as_str() %}
{% include "components/qr_preview.html" %}
<td>{{ url.status }}</td>
<td>{% if url.tags.is_empty() %}-{% else %}{{ url.tags.join(", ") }}{% endif %}</td>
<td>
+333
View File
@@ -0,0 +1,333 @@
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 queue = AnalyticsQueue::new(db.clone(), 100);
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);
}
+31 -10
View File
@@ -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
{
+2 -2
View File
@@ -177,7 +177,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 +573,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"
+2 -2
View File
@@ -30,7 +30,7 @@ async fn test_concurrent_slug_creation() {
let task1 = tokio::spawn(async move {
let conn = rusqlite::Connection::open(&path1).unwrap();
b1.wait().await;
bzod::db::users::register_global_slug(&conn, "!conc-slug", 10, "url", "url10")
bzod::db::users::register_global_slug(&conn, "!conc-slug", 10, "url", "url10", "active")
});
let b2 = barrier.clone();
@@ -38,7 +38,7 @@ async fn test_concurrent_slug_creation() {
let task2 = tokio::spawn(async move {
let conn = rusqlite::Connection::open(&path2).unwrap();
b2.wait().await;
bzod::db::users::register_global_slug(&conn, "!conc-slug", 20, "url", "url20")
bzod::db::users::register_global_slug(&conn, "!conc-slug", 20, "url", "url20", "active")
});
let res1 = task1.await.unwrap();
+9 -2
View File
@@ -58,8 +58,15 @@ async fn test_global_slug_index_consistency() {
// Register globally
{
let system_conn = db.system.lock().unwrap();
bzod::db::users::register_global_slug(&system_conn, "!integ-slug", user_id, "url", &url_id)
.unwrap();
bzod::db::users::register_global_slug(
&system_conn,
"!integ-slug",
user_id,
"url",
&url_id,
"active",
)
.unwrap();
}
// Consistency Check:
+2 -1
View File
@@ -25,7 +25,8 @@ async fn test_content_flagging_and_disabling() {
let system_conn = db.system.lock().unwrap();
// Register slug
bzod::db::users::register_global_slug(&system_conn, "!badslug", 10, "url", "url_abc").unwrap();
bzod::db::users::register_global_slug(&system_conn, "!badslug", 10, "url", "url_abc", "active")
.unwrap();
// Verify it is active initially
let status: String = system_conn
+226
View File
@@ -0,0 +1,226 @@
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 queue = AnalyticsQueue::new(db.clone(), 100);
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_ownership_isolation_endpoints() {
let temp_dir =
std::env::temp_dir().join(format!("bzod_ownership_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;
// 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();
// Create User B
let _ = bzod::cli::create_user::run(
Some("userb".to_string()),
Some("password123".to_string()),
None,
create_temp_config(temp_dir.clone()),
)
.await
.unwrap();
let (id_a, _id_b) = {
let conn = db.users.lock().unwrap();
let a = bzod::db::users::get_user_by_username(&conn, "usera")
.unwrap()
.unwrap()
.id;
let b = bzod::db::users::get_user_by_username(&conn, "userb")
.unwrap()
.unwrap()
.id;
(a, b)
};
// 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
};
// 1. Log in as User B
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", "userb");
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); // Redirects after login
// 2. User B tries to view User A's URL analytics -> 403 Forbidden
let url_analytics_url = format!("{}/user/analytics/url/{}", base_url, urla_id);
let res = client.get(&url_analytics_url).send().await.unwrap();
assert_eq!(res.status(), reqwest::StatusCode::FORBIDDEN);
// 3. User B tries to view User A's Page analytics -> 403 Forbidden
let page_analytics_url = format!("{}/user/analytics/page/{}", base_url, pagea_id);
let res = client.get(&page_analytics_url).send().await.unwrap();
assert_eq!(res.status(), reqwest::StatusCode::FORBIDDEN);
// 4. User B tries to export CSV of User A's URL analytics -> 403 Forbidden
let csv_export_url = format!("{}/user/analytics/url/{}/export/csv", base_url, urla_id);
let res = client.get(&csv_export_url).send().await.unwrap();
assert_eq!(res.status(), reqwest::StatusCode::FORBIDDEN);
// 5. User B tries to export JSON of User A's URL analytics -> 403 Forbidden
let json_export_url = format!("{}/user/analytics/url/{}/export/json", base_url, urla_id);
let res = client.get(&json_export_url).send().await.unwrap();
assert_eq!(res.status(), reqwest::StatusCode::FORBIDDEN);
// 6. User B tries to export CSV of User A's Page analytics -> 403 Forbidden
let csv_page_export_url = format!("{}/user/analytics/page/{}/export/csv", base_url, pagea_id);
let res = client.get(&csv_page_export_url).send().await.unwrap();
assert_eq!(res.status(), reqwest::StatusCode::FORBIDDEN);
// 7. User B tries to export JSON of User A's Page analytics -> 403 Forbidden
let json_page_export_url = format!("{}/user/analytics/page/{}/export/json", base_url, pagea_id);
let res = client.get(&json_page_export_url).send().await.unwrap();
assert_eq!(res.status(), reqwest::StatusCode::FORBIDDEN);
let _ = fs::remove_dir_all(&temp_dir);
}
+300
View File
@@ -0,0 +1,300 @@
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
}
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 queue = AnalyticsQueue::new(db.clone(), 100);
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_qr_endpoints_and_redirection_hardening() {
let temp_dir = std::env::temp_dir().join(format!("bzod_qr_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;
// 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 an active URL
{
let conn_a = bzod::jobs::open_user_content_conn(&db, id_a).unwrap();
let url = bzod::db::content::create_url_extended(
&conn_a,
"a1b2c3",
"https://active.com",
Some("active-url-title"),
None,
&vec![],
None,
None,
None,
)
.unwrap();
let system_conn = db.system.lock().unwrap();
bzod::db::users::register_global_slug(
&system_conn,
"a1b2c3",
id_a,
"url",
&url.id,
"active",
)
.unwrap();
}
// User A adds a disabled URL
{
let conn_a = bzod::jobs::open_user_content_conn(&db, id_a).unwrap();
let url = bzod::db::content::create_url_extended(
&conn_a,
"d4e5f6",
"https://disabled.com",
Some("disabled-url-title"),
None,
&vec![],
None,
None,
None,
)
.unwrap();
let system_conn = db.system.lock().unwrap();
bzod::db::users::register_global_slug(
&system_conn,
"d4e5f6",
id_a,
"url",
&url.id,
"disabled",
)
.unwrap();
}
// User A adds an active Page
{
let conn_a = bzod::jobs::open_user_content_conn(&db, id_a).unwrap();
let page = bzod::db::content::create_landing_page(
&conn_a,
"a1b2",
"active-page",
"Active Page",
"<html></html>",
"published",
)
.unwrap();
let system_conn = db.system.lock().unwrap();
bzod::db::users::register_global_slug(
&system_conn,
"a1b2",
id_a,
"page",
&page.id,
"active",
)
.unwrap();
}
// User A adds a disabled Page
{
let conn_a = bzod::jobs::open_user_content_conn(&db, id_a).unwrap();
let page = bzod::db::content::create_landing_page(
&conn_a,
"c3d4",
"disabled-page",
"Disabled Page",
"<html></html>",
"draft",
)
.unwrap();
let system_conn = db.system.lock().unwrap();
bzod::db::users::register_global_slug(
&system_conn,
"c3d4",
id_a,
"page",
&page.id,
"disabled",
)
.unwrap();
}
// 1. Verify Active URL QR endpoints return 200 with correct content types
let res = client
.get(format!("{}/api/qr/a1b2c3.png", base_url))
.send()
.await
.unwrap();
assert_eq!(res.status(), reqwest::StatusCode::OK);
assert_eq!(
res.headers().get("content-type").unwrap().to_str().unwrap(),
"image/png"
);
let res = client
.get(format!("{}/api/qr/a1b2c3.svg", base_url))
.send()
.await
.unwrap();
assert_eq!(res.status(), reqwest::StatusCode::OK);
assert_eq!(
res.headers().get("content-type").unwrap().to_str().unwrap(),
"image/svg+xml"
);
// 2. Verify Disabled URL redirect returns 410 Gone
let res = client
.get(format!("{}/a1b2c3", base_url))
.send()
.await
.unwrap();
assert_eq!(res.status(), reqwest::StatusCode::MOVED_PERMANENTLY); // Redirects to destination
let res = client
.get(format!("{}/d4e5f6", base_url))
.send()
.await
.unwrap();
assert_eq!(res.status(), reqwest::StatusCode::GONE);
// 3. Verify Disabled URL QR endpoints return 410 Gone
let res = client
.get(format!("{}/api/qr/d4e5f6.png", base_url))
.send()
.await
.unwrap();
assert_eq!(res.status(), reqwest::StatusCode::GONE);
let res = client
.get(format!("{}/api/qr/d4e5f6.svg", base_url))
.send()
.await
.unwrap();
assert_eq!(res.status(), reqwest::StatusCode::GONE);
// 4. Verify Active Page QR endpoints return 200
let res = client
.get(format!("{}/api/qr/a1b2.png", base_url))
.send()
.await
.unwrap();
assert_eq!(res.status(), reqwest::StatusCode::OK);
let res = client
.get(format!("{}/api/qr/a1b2.svg", base_url))
.send()
.await
.unwrap();
assert_eq!(res.status(), reqwest::StatusCode::OK);
// 5. Verify Disabled Page redirection returns 410 Gone
let res = client
.get(format!("{}/p/c3d4", base_url))
.send()
.await
.unwrap();
assert_eq!(res.status(), reqwest::StatusCode::GONE);
// 6. Verify Disabled Page QR endpoints return 410 Gone
let res = client
.get(format!("{}/api/qr/c3d4.png", base_url))
.send()
.await
.unwrap();
assert_eq!(res.status(), reqwest::StatusCode::GONE);
let res = client
.get(format!("{}/api/qr/c3d4.svg", base_url))
.send()
.await
.unwrap();
assert_eq!(res.status(), reqwest::StatusCode::GONE);
// 7. Verify Missing URL/Page QR endpoints return 404 Not Found
let res = client
.get(format!("{}/api/qr/missing-slug.png", base_url))
.send()
.await
.unwrap();
assert_eq!(res.status(), reqwest::StatusCode::NOT_FOUND);
let _ = fs::remove_dir_all(&temp_dir);
}
+2
View File
@@ -80,6 +80,7 @@ async fn test_global_slug_lookup_and_redirection() {
user_id,
"url",
"xyz",
"active",
)
.unwrap();
}
@@ -173,6 +174,7 @@ async fn test_disabled_slug_returns_410() {
user_id,
"url",
"xyz",
"active",
)
.unwrap();
+3 -1
View File
@@ -23,7 +23,8 @@ async fn test_global_slug_uniqueness() {
let system_conn = db.system.lock().unwrap();
// Register a slug
bzod::db::users::register_global_slug(&system_conn, "!myslug", 1, "url", "url1").unwrap();
bzod::db::users::register_global_slug(&system_conn, "!myslug", 1, "url", "url1", "active")
.unwrap();
// Verify it is not available
let avail = bzod::db::users::is_slug_available(&system_conn, "!myslug").unwrap();
@@ -113,6 +114,7 @@ async fn test_slug_release_on_user_deletion() {
user_id,
"url",
"url_xyz",
"active",
)
.unwrap();
let avail = bzod::db::users::is_slug_available(&system_conn, "!user-slug").unwrap();
+242
View File
@@ -0,0 +1,242 @@
use bzod::config::Config;
use bzod::db::Db;
use std::fs;
use std::path::PathBuf;
fn create_temp_config(temp_dir: PathBuf) -> Config {
let mut config = Config::load();
config.data_dir = temp_dir.clone();
config.backup_dir = temp_dir.clone();
config.base_url = Some("http://bzo.in".to_string());
config
}
#[tokio::test]
async fn test_admin_url_slug_collision_rejected() {
let temp_dir = std::env::temp_dir().join(format!("bzod_test_col_1_{}", uuid::Uuid::new_v4()));
fs::create_dir_all(&temp_dir).unwrap();
let config = create_temp_config(temp_dir.clone());
let db = Db::init(&config).expect("Failed to init Db");
let system_conn = db.system.lock().unwrap();
bzod::db::users::register_global_slug(&system_conn, "collision", 1, "url", "url1", "active")
.unwrap();
let res = bzod::db::users::register_global_slug(
&system_conn,
"collision",
1,
"url",
"url2",
"active",
);
assert!(res.is_err());
let _ = fs::remove_dir_all(&temp_dir);
}
#[tokio::test]
async fn test_admin_page_slug_collision_rejected() {
let temp_dir = std::env::temp_dir().join(format!("bzod_test_col_2_{}", uuid::Uuid::new_v4()));
fs::create_dir_all(&temp_dir).unwrap();
let config = create_temp_config(temp_dir.clone());
let db = Db::init(&config).expect("Failed to init Db");
let system_conn = db.system.lock().unwrap();
bzod::db::users::register_global_slug(&system_conn, "collision", 1, "page", "page1", "active")
.unwrap();
let res = bzod::db::users::register_global_slug(
&system_conn,
"collision",
1,
"page",
"page2",
"active",
);
assert!(res.is_err());
let _ = fs::remove_dir_all(&temp_dir);
}
#[tokio::test]
async fn test_user_url_slug_collision_rejected() {
let temp_dir = std::env::temp_dir().join(format!("bzod_test_col_3_{}", uuid::Uuid::new_v4()));
fs::create_dir_all(&temp_dir).unwrap();
let config = create_temp_config(temp_dir.clone());
let db = Db::init(&config).expect("Failed to init Db");
let system_conn = db.system.lock().unwrap();
bzod::db::users::register_global_slug(&system_conn, "collision", 2, "url", "url1", "active")
.unwrap();
let res = bzod::db::users::register_global_slug(
&system_conn,
"collision",
2,
"url",
"url2",
"active",
);
assert!(res.is_err());
let _ = fs::remove_dir_all(&temp_dir);
}
#[tokio::test]
async fn test_user_page_slug_collision_rejected() {
let temp_dir = std::env::temp_dir().join(format!("bzod_test_col_4_{}", uuid::Uuid::new_v4()));
fs::create_dir_all(&temp_dir).unwrap();
let config = create_temp_config(temp_dir.clone());
let db = Db::init(&config).expect("Failed to init Db");
let system_conn = db.system.lock().unwrap();
bzod::db::users::register_global_slug(&system_conn, "collision", 2, "page", "page1", "active")
.unwrap();
let res = bzod::db::users::register_global_slug(
&system_conn,
"collision",
2,
"page",
"page2",
"active",
);
assert!(res.is_err());
let _ = fs::remove_dir_all(&temp_dir);
}
#[tokio::test]
async fn test_url_page_slug_collision_rejected() {
let temp_dir = std::env::temp_dir().join(format!("bzod_test_col_5_{}", uuid::Uuid::new_v4()));
fs::create_dir_all(&temp_dir).unwrap();
let config = create_temp_config(temp_dir.clone());
let db = Db::init(&config).expect("Failed to init Db");
let system_conn = db.system.lock().unwrap();
bzod::db::users::register_global_slug(&system_conn, "collision", 1, "url", "url1", "active")
.unwrap();
let res = bzod::db::users::register_global_slug(
&system_conn,
"collision",
1,
"page",
"page1",
"active",
);
assert!(res.is_err());
let _ = fs::remove_dir_all(&temp_dir);
}
#[tokio::test]
async fn test_page_url_slug_collision_rejected() {
let temp_dir = std::env::temp_dir().join(format!("bzod_test_col_6_{}", uuid::Uuid::new_v4()));
fs::create_dir_all(&temp_dir).unwrap();
let config = create_temp_config(temp_dir.clone());
let db = Db::init(&config).expect("Failed to init Db");
let system_conn = db.system.lock().unwrap();
bzod::db::users::register_global_slug(&system_conn, "collision", 1, "page", "page1", "active")
.unwrap();
let res = bzod::db::users::register_global_slug(
&system_conn,
"collision",
1,
"url",
"url1",
"active",
);
assert!(res.is_err());
let _ = fs::remove_dir_all(&temp_dir);
}
#[tokio::test]
async fn test_slug_reusable_after_delete() {
let temp_dir = std::env::temp_dir().join(format!("bzod_test_col_7_{}", uuid::Uuid::new_v4()));
fs::create_dir_all(&temp_dir).unwrap();
let config = create_temp_config(temp_dir.clone());
let db = Db::init(&config).expect("Failed to init Db");
let system_conn = db.system.lock().unwrap();
bzod::db::users::register_global_slug(&system_conn, "slug", 1, "url", "url1", "active")
.unwrap();
assert!(!bzod::db::users::is_slug_available(&system_conn, "slug").unwrap());
bzod::db::users::release_global_slug(&system_conn, "slug", 1).unwrap();
assert!(bzod::db::users::is_slug_available(&system_conn, "slug").unwrap());
bzod::db::users::register_global_slug(&system_conn, "slug", 1, "page", "page1", "active")
.unwrap();
assert!(!bzod::db::users::is_slug_available(&system_conn, "slug").unwrap());
let _ = fs::remove_dir_all(&temp_dir);
}
#[tokio::test]
async fn test_disabled_slug_still_blocks_registration() {
let temp_dir = std::env::temp_dir().join(format!("bzod_test_col_8_{}", uuid::Uuid::new_v4()));
fs::create_dir_all(&temp_dir).unwrap();
let config = create_temp_config(temp_dir.clone());
let db = Db::init(&config).expect("Failed to init Db");
let system_conn = db.system.lock().unwrap();
bzod::db::users::register_global_slug(&system_conn, "slug", 1, "url", "url1", "disabled")
.unwrap();
assert!(!bzod::db::users::is_slug_available(&system_conn, "slug").unwrap());
let res =
bzod::db::users::register_global_slug(&system_conn, "slug", 2, "page", "page1", "active");
assert!(res.is_err());
let _ = fs::remove_dir_all(&temp_dir);
}
#[tokio::test]
async fn test_transferred_slug_remains_unique() {
let temp_dir = std::env::temp_dir().join(format!("bzod_test_col_9_{}", uuid::Uuid::new_v4()));
fs::create_dir_all(&temp_dir).unwrap();
let config = create_temp_config(temp_dir.clone());
let db = Db::init(&config).expect("Failed to init Db");
let system_conn = db.system.lock().unwrap();
bzod::db::users::register_global_slug(&system_conn, "slug", 1, "url", "url1", "active")
.unwrap();
assert!(!bzod::db::users::is_slug_available(&system_conn, "slug").unwrap());
// Transfer slug (by setting owner_user_id to 2)
system_conn
.execute(
"UPDATE global_slugs SET owner_user_id = 2 WHERE slug = 'slug'",
[],
)
.unwrap();
let res =
bzod::db::users::register_global_slug(&system_conn, "slug", 1, "url", "url2", "active");
assert!(res.is_err());
let _ = fs::remove_dir_all(&temp_dir);
}
#[tokio::test]
async fn test_transfer_preserves_global_slug_record() {
let temp_dir = std::env::temp_dir().join(format!("bzod_test_col_10_{}", uuid::Uuid::new_v4()));
fs::create_dir_all(&temp_dir).unwrap();
let config = create_temp_config(temp_dir.clone());
let db = Db::init(&config).expect("Failed to init Db");
let system_conn = db.system.lock().unwrap();
bzod::db::users::register_global_slug(&system_conn, "slug", 1, "url", "url1", "active")
.unwrap();
// Verify it exists in global_slugs
let owner_id: i64 = system_conn
.query_row(
"SELECT owner_user_id FROM global_slugs WHERE slug = 'slug'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(owner_id, 1);
let _ = fs::remove_dir_all(&temp_dir);
}
+9 -2
View File
@@ -72,8 +72,15 @@ async fn test_slug_transfer() {
// Register globally
{
let system_conn = db.system.lock().unwrap();
bzod::db::users::register_global_slug(&system_conn, "!trans-slug", id_a, "url", &url.id)
.unwrap();
bzod::db::users::register_global_slug(
&system_conn,
"!trans-slug",
id_a,
"url",
&url.id,
"active",
)
.unwrap();
let users_conn = db.users.lock().unwrap();
bzod::db::users::increment_quota_counter(&users_conn, id_a, "urls").unwrap();
+19 -5
View File
@@ -22,8 +22,15 @@ async fn test_soft_delete_reserves_slug() {
let system_conn = db.system.lock().unwrap();
// Register slug
bzod::db::users::register_global_slug(&system_conn, "!slug-to-delete", 10, "url", "url_123")
.unwrap();
bzod::db::users::register_global_slug(
&system_conn,
"!slug-to-delete",
10,
"url",
"url_123",
"active",
)
.unwrap();
// Soft delete slug
bzod::db::users::soft_delete_global_slug(&system_conn, "!slug-to-delete", 10).unwrap();
@@ -39,7 +46,7 @@ async fn test_soft_delete_reserves_slug() {
|row| row.get(0),
)
.unwrap();
assert_eq!(status, "soft_deleted");
assert_eq!(status, "disabled");
let _ = fs::remove_dir_all(&temp_dir);
}
@@ -55,8 +62,15 @@ async fn test_permanent_delete_releases_slug() {
let system_conn = db.system.lock().unwrap();
// Register slug
bzod::db::users::register_global_slug(&system_conn, "!slug-to-purge", 10, "url", "url_456")
.unwrap();
bzod::db::users::register_global_slug(
&system_conn,
"!slug-to-purge",
10,
"url",
"url_456",
"active",
)
.unwrap();
// Release global slug (permanent deletion)
bzod::db::users::release_global_slug(&system_conn, "!slug-to-purge", 10).unwrap();
+90
View File
@@ -0,0 +1,90 @@
use bzod::config::Config;
use bzod::db::Db;
use std::fs;
use std::path::PathBuf;
fn create_temp_config(temp_dir: PathBuf) -> Config {
let mut config = Config::load();
config.data_dir = temp_dir.clone();
config.backup_dir = temp_dir.clone();
config.base_url = Some("http://bzo.in".to_string());
config
}
#[tokio::test]
async fn test_cleanup_stale_reservations() {
let temp_dir = std::env::temp_dir().join(format!("bzod_tx_test_1_{}", uuid::Uuid::new_v4()));
fs::create_dir_all(&temp_dir).unwrap();
let config = create_temp_config(temp_dir.clone());
let db = Db::init(&config).expect("Failed to init Db");
let system_conn = db.system.lock().unwrap();
// Insert a reserving slug older than 15 minutes (e.g. 20 minutes ago)
let old_time = (chrono::Utc::now() - chrono::Duration::minutes(20)).to_rfc3339();
system_conn.execute(
"INSERT INTO global_slugs (slug, owner_user_id, target_type, target_id, created_at, updated_at, status)
VALUES ('stale-slug', 2, 'url', '', ?1, ?1, 'reserving')",
[&old_time]
).unwrap();
// Verify it exists before cleanup
let exists: bool = system_conn
.query_row(
"SELECT EXISTS(SELECT 1 FROM global_slugs WHERE slug = 'stale-slug')",
[],
|r| r.get(0),
)
.unwrap();
assert!(exists);
// Run cleanup
let cleaned = bzod::db::users::cleanup_stale_reservations(&system_conn, &temp_dir).unwrap();
assert_eq!(cleaned, 1);
// Verify it is gone
let exists: bool = system_conn
.query_row(
"SELECT EXISTS(SELECT 1 FROM global_slugs WHERE slug = 'stale-slug')",
[],
|r| r.get(0),
)
.unwrap();
assert!(!exists);
let _ = fs::remove_dir_all(&temp_dir);
}
#[tokio::test]
async fn test_cleanup_preserves_valid_reservations() {
let temp_dir = std::env::temp_dir().join(format!("bzod_tx_test_2_{}", uuid::Uuid::new_v4()));
fs::create_dir_all(&temp_dir).unwrap();
let config = create_temp_config(temp_dir.clone());
let db = Db::init(&config).expect("Failed to init Db");
let system_conn = db.system.lock().unwrap();
// Insert a reserving slug that is brand new (e.g. 1 minute ago)
let new_time = (chrono::Utc::now() - chrono::Duration::minutes(1)).to_rfc3339();
system_conn.execute(
"INSERT INTO global_slugs (slug, owner_user_id, target_type, target_id, created_at, updated_at, status)
VALUES ('fresh-slug', 2, 'url', '', ?1, ?1, 'reserving')",
[&new_time]
).unwrap();
// Run cleanup
let cleaned = bzod::db::users::cleanup_stale_reservations(&system_conn, &temp_dir).unwrap();
assert_eq!(cleaned, 0);
// Verify it is still there
let exists: bool = system_conn
.query_row(
"SELECT EXISTS(SELECT 1 FROM global_slugs WHERE slug = 'fresh-slug')",
[],
|r| r.get(0),
)
.unwrap();
assert!(exists);
let _ = fs::remove_dir_all(&temp_dir);
}
+648 -300
View File
@@ -1,324 +1,672 @@
<!doctype html>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>BZOD — Private • Fast • Beautiful URL Shortener</title>
<script src="https://cdn.tailwindcss.com"></script>
<link
rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.6.0/css/all.min.css"
/>
<style>
body {
font-family: "Inter", system-ui, sans-serif;
}
.hero-bg {
background: linear-gradient(135deg, #1a1a2e 0%, #0f0f1e 100%);
}
</style>
</head>
<body class="bg-zinc-950 text-zinc-200">
<!-- Hero -->
<section class="hero-bg py-24">
<div class="max-w-5xl mx-auto px-6 text-center">
<div
class="inline-flex items-center gap-2 bg-zinc-900 border border-zinc-700 rounded-full px-4 py-1.5 mb-6"
>
<span class="text-emerald-400">●</span>
<span class="text-sm font-medium"
>Self-hosted • Rust • Single Binary</span
>
</div>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="BZOD ? Self-hosted Multi-User URL Management Platform written in Rust with landing pages, QR codes, analytics, audit logging, and SQLite backend.">
<title>BZOD | Self-Hosted URL Management in Rust</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;500;600&family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<style>
:root {
--bg: #0b0f14; --bg2: #131820; --bg3: #1a202a; --bg4: #212733;
--border: #29303d; --border2: #384152;
--a1: #6db1ff; --a2: #56d364; --a3: #f85149; --a4: #d2a8ff; --a5: #f0883e;
--rust: #e05c2f;
--t1: #e6edf3; --t2: #8d96a0; --t3: #656d76;
--glow: rgba(109, 177, 255, 0.08);
--mono: 'JetBrains Mono', monospace;
--sans: 'Inter', system-ui, sans-serif;
--r: 8px; --rl: 16px;
}
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
html{scroll-behavior:smooth}
body{background:var(--bg);color:var(--t1);font-family:var(--sans);font-size:15px;line-height:1.8;overflow-x:hidden}
::-webkit-scrollbar{width:8px}::-webkit-scrollbar-track{background:var(--bg)}::-webkit-scrollbar-thumb{background:var(--border2);border-radius:4px}
a{color:var(--a1);text-decoration:none;transition: color 0.2s}a:hover{color:var(--t1)}
<h1
class="text-6xl md:text-7xl font-bold tracking-tighter mb-6"
>
Short links.<br />
<span
class="bg-gradient-to-r from-violet-400 to-fuchsia-400 bg-clip-text text-transparent"
>Your domain.</span
>
</h1>
/* NAV */
nav{position:fixed;top:0;left:0;right:0;z-index:200;background:rgba(11, 15, 20, 0.85);backdrop-filter:blur(16px);border-bottom:1px solid var(--border);height:64px;display:flex;align-items:center;padding:0 2rem;gap:1.5rem}
.nav-logo{display:flex;align-items:center;gap:.75rem;font-family:var(--sans);font-weight:700;font-size:1.05rem;color:var(--t1);white-space:nowrap;letter-spacing:-0.02em}
.logo-box{width:32px;height:32px;background:linear-gradient(135deg,var(--rust) 0%,#ff7b47 100%);border-radius:8px;display:flex;align-items:center;justify-content:center;font-size:.85rem;font-weight:700;color:#fff;flex-shrink:0;box-shadow:0 2px 10px rgba(224,92,47,0.3)}
.nav-links{display:flex;gap:1.5rem;list-style:none;margin-left:1rem}
.nav-links a{color:var(--t2);font-size:.85rem;font-weight:500;transition:color .2s;display:flex;align-items:center;gap:0.4rem}
.nav-links svg{width:14px;height:14px;opacity:0.75;transition:opacity 0.2s}
.nav-links a:hover{color:var(--t1);text-decoration:none}
.nav-links a:hover svg{opacity:1}
.nav-right{margin-left:auto;display:flex;gap:.75rem;align-items:center;flex-wrap:wrap;justify-content:flex-end}
.btn-sm{padding:.4rem 1rem;border-radius:var(--r);font-size:.8rem;font-weight:500;cursor:pointer;font-family:var(--sans);transition:all .2s;text-decoration:none!important;display:inline-flex;align-items:center;justify-content:center;gap:.4rem}
.btn-sm svg{width:14px;height:14px}
.btn-ghost{border:1px solid var(--border);background:var(--bg2);color:var(--t1)}.btn-ghost:hover{border-color:var(--border2);background:var(--bg3)}
.btn-solid{background:var(--t1);color:var(--bg);border:none;font-weight:600}.btn-solid:hover{background:#ffffff;transform:translateY(-1px)}
<p class="text-2xl text-zinc-400 max-w-2xl mx-auto mb-10">
Beautiful, private, and powerful URL shortener with rich
landing pages, QR codes, analytics, and full CLI control.
</p>
/* LAYOUT */
.container{max-width:1140px;margin:0 auto;width:100%}
section{padding:5.5rem 2rem}
.eyebrow{font-family:var(--sans);font-weight:600;font-size:.75rem;letter-spacing:.08em;color:var(--t2);text-transform:uppercase;margin-bottom:.75rem}
.section-title{font-size:clamp(1.75rem,3vw,2.5rem);font-weight:700;line-height:1.2;margin-bottom:1rem;letter-spacing:-0.03em}
.section-sub{color:var(--t2);max-width:640px;font-size:1rem;margin-bottom:3rem;line-height:1.7}
.divider{height:1px;background:var(--border);max-width:1140px;margin:0 auto}
<div class="flex flex-wrap justify-center gap-4">
<a
href="https://github.com/thakares/nx9-url-shortener"
target="_blank"
class="bg-white text-black px-8 py-4 rounded-2xl font-semibold flex items-center gap-3 hover:scale-105 transition"
>
<i class="fab fa-github text-xl"></i>
View on GitHub
</a>
<a
href="/admin"
class="bg-violet-600 hover:bg-violet-700 px-8 py-4 rounded-2xl font-semibold transition"
>
Admin Dashboard →
</a>
</div>
/* HERO */
.hero{min-height:100vh;display:flex;align-items:center;padding:100px 2rem 4rem;position:relative;overflow:hidden}
.hero-bg{position:absolute;inset:0;background:radial-gradient(circle at 70% 30%, var(--glow) 0%, transparent 60%)}
.hero-inner{max-width:1140px;margin:0 auto;width:100%;position:relative;z-index:1;display:grid;grid-template-columns:1.15fr .85fr;gap:4rem;align-items:center}
.hero-badge{display:inline-flex;align-items:center;gap:.6rem;padding:.35rem 1rem;border-radius:999px;border:1px solid var(--border);background:var(--bg2);color:var(--t1);font-size:.8rem;font-weight:500;margin-bottom:1.5rem}
.pulse{width:8px;height:8px;border-radius:50%;background:var(--a2);box-shadow: 0 0 10px var(--a2)}
.hero h1{font-size:clamp(2.5rem,4.5vw,3.8rem);font-weight:700;line-height:1.1;letter-spacing:-0.03em;margin-bottom:1.25rem}
.hero h1 .hl{color:var(--t1)}.hero h1 .hr{color:var(--rust)}
.hero-desc{color:var(--t2);font-size:1.05rem;line-height:1.7;margin-bottom:2rem;max-width:500px}
.hero-ctas{display:flex;gap:1rem;flex-wrap:wrap}
.btn-lg{padding:.75rem 1.75rem;border-radius:var(--r);font-size:.95rem;font-weight:500;cursor:pointer;font-family:var(--sans);transition:all .2s;text-decoration:none!important;display:inline-flex;align-items:center;justify-content:center}
.btn-lg.p{background:var(--t1);color:var(--bg)}.btn-lg.p:hover{background:#ffffff;transform:translateY(-2px);box-shadow:0 8px 24px rgba(255,255,255,0.1)}
.btn-lg.o{border:1px solid var(--border);color:var(--t1);background:var(--bg2)}.btn-lg.o:hover{border-color:var(--border2);background:var(--bg3)}
.hero-meta{margin-top:2rem;display:flex;gap:1.5rem;flex-wrap:wrap}
.meta-tag{font-family:var(--mono);font-size:.75rem;color:var(--t3);display:flex;align-items:center;gap:.4rem}
<div class="mt-16 text-sm text-zinc-500">
Powered by
<span class="font-mono text-emerald-400">bzo.in</span>
</div>
/* TERMINAL */
.terminal{background:var(--bg2);border:1px solid var(--border);border-radius:var(--rl);overflow:hidden;box-shadow:0 24px 80px rgba(0,0,0,.5)}
.t-bar{background:var(--bg3);padding:.8rem 1.2rem;display:flex;align-items:center;gap:.5rem;border-bottom:1px solid var(--border)}
.dot{width:12px;height:12px;border-radius:50%}
.d-r{background:#ff5f57}.d-y{background:#ffbd2e}.d-g{background:#27c93f}
.t-title{margin:0 auto;font-family:var(--sans);font-weight:500;font-size:.8rem;color:var(--t3)}
.t-body{padding:1.5rem;font-family:var(--mono);font-size:.85rem;line-height:1.7;white-space:pre-wrap}
.tp{color:var(--t3)}.tc{color:var(--t1)}.to{color:var(--t2)}.tk{color:var(--a4)}.tv{color:var(--a1)}.tg{color:var(--a2)}
.t-spacer{display:block;height:.75rem}
/* STATS */
.stats-band{border-top:1px solid var(--border);border-bottom:1px solid var(--border);padding:2.5rem 2rem}
.stats-grid{max-width:1140px;margin:0 auto;display:grid;grid-template-columns:repeat(7,1fr);gap:1rem;text-align:center}
.stat-n{font-family:var(--sans);font-size:1.75rem;font-weight:600;color:var(--t1);display:block;letter-spacing:-0.02em}
.stat-l{font-size:.8rem;color:var(--t2);margin-top:.4rem;font-weight:400}
/* CARDS */
.grid-2{display:grid;grid-template-columns:repeat(2,1fr);gap:1.5rem}
.grid-3{display:grid;grid-template-columns:repeat(3,1fr);gap:1.5rem}
.grid-4{display:grid;grid-template-columns:repeat(4,1fr);gap:1.5rem}
.card{background:var(--bg2);border:1px solid var(--border);border-radius:var(--rl);padding:1.75rem;transition:transform .3s ease, border-color .3s ease;position:relative}
.card:hover{border-color:var(--border2);transform:translateY(-4px);box-shadow:0 12px 32px rgba(0,0,0,0.2)}
.card-icon{font-size:1.5rem;margin-bottom:1rem;display:inline-block}
.card-title{font-weight:600;font-size:1.05rem;margin-bottom:.5rem;color:var(--t1)}
.card-desc{color:var(--t2);font-size:.9rem;line-height:1.6}
/* TABLES & TREES */
.compare-tbl{width:100%;border-collapse:collapse;font-size:.9rem;text-align:left;background:var(--bg2);border-radius:var(--rl);overflow:hidden;border:1px solid var(--border)}
.compare-tbl th{background:var(--bg3);padding:1rem 1.5rem;font-weight:600;color:var(--t1);border-bottom:1px solid var(--border);white-space:nowrap}
.compare-tbl td{padding:1rem 1.5rem;border-bottom:1px solid var(--border);color:var(--t2)}
.compare-tbl tr:last-child td{border-bottom:none}
.compare-tbl tr:hover td{background:rgba(255,255,255,.02)}
.compare-tbl td:first-child{font-weight:500;color:var(--t1)}
.check-yes{color:var(--a2);font-weight:700}
.check-no{color:var(--a3)}
.check-partial{color:var(--a5)}
.file-tree{font-family:var(--mono);font-size:.85rem;line-height:1.8;color:var(--t2);background:var(--bg2);padding:1.5rem;border-radius:var(--rl);border:1px solid var(--border)}
.file-tree .dir{color:var(--a1);font-weight:600}
.file-tree .file{color:var(--t1)}
.file-tree .comment{color:var(--t3);font-style:italic;margin-left:1rem}
/* CODE & TABS */
pre.cb{background:var(--bg2);border:1px solid var(--border);border-radius:var(--r);padding:1.25rem;font-family:var(--mono);font-size:.85rem;line-height:1.7;overflow-x:auto;color:var(--t1);white-space:pre}
.cb .kw{color:var(--a1)}.cb .cm{color:var(--t3)}.cb .str{color:var(--a2)}
.tab-shell{background:var(--bg2);border:1px solid var(--border);border-radius:var(--rl);overflow:hidden}
.tab-strip{display:flex;border-bottom:1px solid var(--border);overflow-x:auto;background:var(--bg3)}
.tab-btn{padding:1rem 1.5rem;border:none;background:transparent;color:var(--t2);font-family:var(--sans);font-weight:500;font-size:.9rem;cursor:pointer;white-space:nowrap;border-bottom:2px solid transparent;transition:all .2s}
.tab-btn.on{color:var(--t1);border-bottom-color:var(--a1)}
.tab-btn:hover:not(.on){color:var(--t1);background:var(--bg4)}
.tab-panel{display:none;padding:2rem}
.tab-panel.on{display:block}
/* ARCHITECTURE */
.arch-wrap{display:grid;grid-template-columns:1fr 1fr;gap:3rem;align-items:center}
.flow-diagram{background:var(--bg2);border:1px solid var(--border);border-radius:var(--rl);padding:2rem;font-family:var(--mono);font-size:.8rem;line-height:2;text-align:center}
.fbox{border:1px solid var(--border);border-radius:6px;padding:.5rem 1rem;display:inline-block;color:var(--t1);background:var(--bg3);margin:.25rem 0}
.farrow{color:var(--t3);font-size:1rem;margin:.25rem 0}
/* ARCH */
.arch-wrap{display:grid;grid-template-columns:1fr 1fr;gap:2.5rem;align-items:start}
.flow-diagram{background:var(--bg2);border:1px solid var(--border);border-radius:var(--rl);padding:1.4rem;font-family:var(--mono);font-size:.76rem;line-height:2;text-align:center}
.fbox{border:1px solid var(--border);border-radius:5px;padding:.3rem .7rem;display:inline-block;color:var(--t1);background:var(--bg);margin:.1rem 0;font-size:.72rem}
.fbox.a1{border-color:var(--a1);color:var(--a1)}.fbox.a2{border-color:var(--a2);color:var(--a2)}.fbox.ar{border-color:var(--rust);color:var(--rust)}.fbox.a4{border-color:var(--a4);color:var(--a4)}
.farrow{color:var(--border2);font-size:.85rem}
/* COMPONENT LIST */
.comp-list{display:flex;flex-direction:column;gap:.8rem}
.comp{background:var(--bg2);border:1px solid var(--border);border-radius:var(--r);padding:.9rem 1.1rem;display:flex;gap:.85rem;align-items:flex-start;transition:border-color .2s}
.comp:hover{border-color:var(--border2)}
.comp-num{font-family:var(--mono);font-size:.7rem;color:var(--a1);background:rgba(88,166,255,.09);border:1px solid rgba(88,166,255,.28);border-radius:4px;padding:.12rem .45rem;flex-shrink:0;margin-top:2px}
.comp-title{font-weight:600;font-size:.875rem;margin-bottom:.2rem}
.comp-desc{color:var(--t2);font-size:.8rem;line-height:1.55}
/* STEPS */
.steps{display:flex;flex-direction:column;gap:1.5rem}
.step{display:grid;grid-template-columns:32px 1fr;gap:1rem;align-items:start}
.step-n{width:32px;height:32px;border-radius:50%;background:var(--bg3);border:1px solid var(--border);display:flex;align-items:center;justify-content:center;font-family:var(--mono);font-size:.8rem;color:var(--t1);flex-shrink:0}
.step-title{font-weight:600;font-size:.95rem;margin-bottom:.25rem;color:var(--t1)}
/* FAQ */
.faq{display:flex;flex-direction:column;gap:1rem}
.faq-item{background:var(--bg2);border:1px solid var(--border);border-radius:var(--r);overflow:hidden}
.faq-q{padding:1.25rem 1.5rem;cursor:pointer;display:flex;justify-content:space-between;align-items:center;font-weight:500;font-size:.95rem;user-select:none;transition:background .2s}
.faq-q:hover{background:var(--bg3)}
.faq-chevron{color:var(--t3);font-size:.8rem;transition:transform .3s ease}
.faq-item.open .faq-chevron{transform:rotate(180deg);color:var(--t1)}
.faq-a{display:none;padding:0 1.5rem 1.25rem;color:var(--t2);font-size:.95rem;line-height:1.7}
.faq-item.open .faq-a{display:block}
/* CTA */
.cta-box{background:var(--bg2);border:1px solid var(--border);border-radius:var(--rl);padding:4rem 2rem;text-align:center}
.cta-box h2{font-size:2rem;font-weight:700;margin-bottom:1rem;letter-spacing:-0.02em}
.cta-box p{color:var(--t2);margin-bottom:2rem;font-size:1.05rem}
.cta-actions{display:flex;gap:1rem;justify-content:center;flex-wrap:wrap}
/* FOOTER */
footer{background:var(--bg);border-top:1px solid var(--border);padding:4rem 2rem 2rem}
.footer-grid{max-width:1140px;margin:0 auto;display:grid;grid-template-columns:2fr 1fr 1fr 1fr;gap:3rem;margin-bottom:3rem}
.footer-brand{font-family:var(--sans);font-weight:700;font-size:1.05rem;display:flex;align-items:center;gap:.75rem;margin-bottom:1rem;letter-spacing:-0.02em}
.footer-about{color:var(--t2);font-size:.9rem;line-height:1.7;max-width:300px}
.footer-col-title{font-weight:600;font-size:.9rem;margin-bottom:1.25rem;color:var(--t1)}
.footer-links{list-style:none;display:flex;flex-direction:column;gap:.75rem}
.footer-links a{color:var(--t2);font-size:.9rem;transition:color .2s}.footer-links a:hover{color:var(--t1)}
.footer-bottom{max-width:1140px;margin:0 auto;padding-top:2rem;border-top:1px solid var(--border);display:flex;justify-content:space-between;align-items:center;font-size:.85rem;color:var(--t3)}
@media(max-width:1024px){
.hero-inner,.arch-wrap,.grid-2{grid-template-columns:1fr}
.terminal{display:none}
.nav-links{display:none}
.stats-grid{grid-template-columns:repeat(4,1fr)}
.compare-tbl {display: block; overflow-x: auto;}
}
@media(max-width:768px){
.grid-3,.grid-4{grid-template-columns:repeat(2,1fr)}
.footer-grid{grid-template-columns:1fr 1fr}
}
@media(max-width:480px){
.stats-grid{grid-template-columns:repeat(2,1fr)}
.grid-3,.grid-4{grid-template-columns:1fr}
.footer-grid{grid-template-columns:1fr}
.footer-bottom{flex-direction:column;gap:1rem;text-align:center}
}
</style>
</head>
<body>
<nav>
<a class="nav-logo" href="#home">
<div class="logo-box">BZ</div>
BZOD
</a>
<ul class="nav-links">
<li><a href="#features"><i data-lucide="sparkles"></i> Features</a></li>
<li><a href="#architecture"><i data-lucide="layers"></i> Architecture</a></li>
<li><a href="#databases"><i data-lucide="database"></i> Databases</a></li>
<li><a href="#api"><i data-lucide="terminal"></i> API</a></li>
<li><a href="#cli"><i data-lucide="terminal-square"></i> CLI</a></li>
<li><a href="#compare"><i data-lucide="git-compare"></i> Compare</a></li>
</ul>
<div class="nav-right">
<a class="btn-sm btn-ghost" href="/login"><i data-lucide="log-in"></i> User Login</a>
<a class="btn-sm btn-ghost" href="/admin"><i data-lucide="shield"></i> Admin Panel</a>
<a class="btn-sm btn-ghost" href="https://github.com/thakares/nx9-url-shortener" target="_blank">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 22v-4a4.8 4.8 0 0 0-1-3.02c3.18-.35 6.5-1.5 6.5-7a4.6 4.6 0 0 0-1.39-3.23 4.2 4.2 0 0 0-.1-3.2s-1.1-.35-3.5 1.25a11.39 11.39 0 0 0-7 0C6.2 2.75 5 3.1 5 3.1a4.2 4.2 0 0 0-.1 3.2A4.6 4.6 0 0 0 3.5 9.5c0 5.5 3.32 6.65 6.5 7-.33.26-.6.7-.7 1.35-.62.27-2.2.8-3.2-1.35-.1-.2-.3-.3-.5-.3-.2 0-.3.1-.1.3 1.1 2.3 3.1 2.5 3.8 2.5.1 1.2.2 2.6.2 3.1"/><path d="M9 20c-1.5.5-3 0-3-2"/></svg>
GitHub
</a>
<a class="btn-sm btn-ghost" href="https://codeberg.org/thakares/nx9-url-shortener" target="_blank">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m8 3 4 8 5-5 5 15H2L8 3z"/></svg>
CodeBerg
</a>
<a class="btn-sm btn-solid" href="#install"><i data-lucide="rocket"></i> Install</a>
</div>
</nav>
<header class="hero" id="home">
<div class="hero-bg"></div>
<div class="hero-inner">
<div>
<div class="hero-badge"><span class="pulse"></span> Production Ready</div>
<h1>🕹️ Control Your URLs.<span class="hl">&nbsp;Self-Hosted with</span> <span class="hr">🦀 Rust</span></h1>
<p class="hero-desc">BZOD is a complete multi-user URL management platform featuring custom landing pages, analytics, audit logging, and automated disaster recovery. Powered entirely by SQLite.</p>
<div class="hero-ctas">
<a href="#install" class="btn-lg p">Deployment Guide</a>
<a href="/login" class="btn-lg o">User Panel</a>
</div>
<!-- Highlights -->
<div class="max-w-6xl mx-auto px-6 -mt-10 relative z-10">
<div
class="grid grid-cols-2 lg:grid-cols-5 bg-zinc-950/70 backdrop-blur-md border border-zinc-800 rounded-3xl overflow-hidden"
>
<!-- Single Binary -->
<div
class="p-8 text-center border-b lg:border-b-0 lg:border-r border-zinc-800"
>
<i
class="fas fa-bolt text-emerald-400 text-4xl mb-4"
></i>
<h3 class="font-semibold text-xl mb-2">
Single Binary
</h3>
<p class="text-zinc-400 text-sm">
~18 MB Rust binary.<br />
No runtime required.
</p>
</div>
<!-- SQLite -->
<div
class="p-8 text-center border-b lg:border-b-0 lg:border-r border-zinc-800"
>
<i
class="fas fa-database text-emerald-400 text-4xl mb-4"
></i>
<h3 class="font-semibold text-xl mb-2">SQLite</h3>
<p class="text-zinc-400 text-sm">
Zero configuration.<br />
WAL mode enabled.
</p>
</div>
<!-- REST API -->
<div
class="p-8 text-center border-b lg:border-b-0 lg:border-r border-zinc-800"
>
<i
class="fas fa-code text-emerald-400 text-4xl mb-4"
></i>
<h3 class="font-semibold text-xl mb-2">REST API</h3>
<p class="text-zinc-400 text-sm">
Full automation.<br />
JSON everywhere.
</p>
</div>
<!-- Self Hosted -->
<div
class="p-8 text-center border-b lg:border-b-0 lg:border-r border-zinc-800"
>
<i
class="fas fa-server text-emerald-400 text-4xl mb-4"
></i>
<h3 class="font-semibold text-xl mb-2">Self Hosted</h3>
<p class="text-zinc-400 text-sm">
Your links.<br />
Your infrastructure.
</p>
</div>
<!-- MIT -->
<div class="p-8 text-center">
<i
class="fas fa-shield-alt text-emerald-400 text-4xl mb-4"
></i>
<h3 class="font-semibold text-xl mb-2">MIT License</h3>
<p class="text-zinc-400 text-sm">
Open source.<br />
Commercial friendly.
</p>
</div>
</div>
<!-- One-Command Installer -->
<div style="margin-top:2.5rem;background:var(--bg2);border:1px solid var(--border);border-radius:12px;padding:1.25rem 1.5rem">
<div style="font-family:var(--mono);font-size:0.85rem;color:var(--t2);margin-bottom:0.75rem">One-command installer</div>
<div style="display:flex;align-items:center;gap:1rem;background:var(--bg3);padding:0.85rem 1.25rem;border-radius:8px;font-family:var(--mono);font-size:0.9rem;color:var(--t1);overflow-x:auto">
<span style="color:var(--a2)">$</span>
<span id="install-cmd" style="flex:1">curl -fsSL https://bzo.in/deploy.sh | sudo bash</span>
<button onclick="copyInstallCommand()"
style="background:transparent;border:none;color:var(--a1);cursor:pointer;font-size:1.2rem;padding:4px 8px;border-radius:6px;transition:all .2s">
📋
</button>
</div>
<div id="copy-msg" style="display:none;color:var(--a2);font-size:0.8rem;margin-top:0.5rem;text-align:center">✅ Copied to clipboard!</div>
</div>
</section>
<!-- Install -->
<section class="hero-bg py-24">
<div class="max-w-4xl mx-auto px-6 text-center">
<h2 class="text-4xl font-bold mb-4">Install BZOD</h2>
<p class="text-zinc-400 mb-10">
Deploy on any Debian/Ubuntu VPS, homelab, mini-PC, or
Raspberry Pi.
</p>
<div class="hero-meta">
<span class="meta-tag">Rust 2021</span>
<span class="meta-tag">Axum</span>
<span class="meta-tag">SQLite</span>
<span class="meta-tag">Docker Ready</span>
</div>
<div class="bg-black max-w-6xl mx-auto px-6 -mt-10 relative z-10 rounded-3xl overflow-hidden">
<div
class="flex items-center justify-between px-5 py-3 border-b border-zinc-800"
>
<span class="text-zinc-500 text-sm">
Downloads the latest release and installs BZOD as a systemd
service. Linux Installation
</span>
<button
onclick="copyInstallCommand()"
class="text-sm bg-violet-600 hover:bg-violet-700 px-4 py-2 rounded-lg transition"
>
Copy
</button>
</div>
<pre
class="text-left overflow-x-auto p-6 text-emerald-400 font-mono text-sm"
><code id="install-command">curl -fsSL https://bzo.in/deploy.sh | sudo bash</code></pre>
</div>
<script>
function copyInstallCommand() {
const text = "curl -fsSL https://bzo.in/deploy.sh | sudo bash";
</div>
navigator.clipboard.writeText(text);
<!-- Terminal remains the same -->
<div class="terminal">
<div class="t-bar"><span class="dot d-r"></span><span class="dot d-y"></span><span class="dot d-g"></span><span class="t-title">bzod >_ running</span></div>
<div class="t-body"><span class="tp">$</span> <span class="tc">git clone https://github.com/thakares/bzod</span>
<span class="to">Cloning into 'bzod'...</span>
const btn = event.target;
const old = btn.innerText;
<span class="tp">$</span> <span class="tc">cargo build --release</span>
<span class="to"> Compiling bzod v0.5.0</span>
<span class="tg"> Finished release [optimized] in 12.58s</span>
btn.innerText = "Copied!";
setTimeout(() => {
btn.innerText = old;
}, 2000);
}
</script>
</section>
<span class="tp">$</span> <span class="tc">./target/release/bzod serve</span>
<!-- Features -->
<section class="py-20 bg-zinc-900">
<div class="max-w-5xl mx-auto px-6">
<h2 class="text-4xl font-bold text-center mb-16">
Why people love BZOD
</h2>
<span class="tg">[INFO]</span> <span class="tk">database</span>=<span class="tv">"SQLite"</span> <span class="tk">multi_user</span>=<span class="tv">enabled</span>
<span class="tg">[INFO]</span> <span class="tk">audit_logging</span>=<span class="tv">enabled</span> <span class="tk">backup</span>=<span class="tv">supported</span>
<span class="tg">[INFO]</span> Listening on 0.0.0.0:3000
<div class="grid md:grid-cols-3 gap-8">
<div
class="bg-zinc-950 border border-zinc-800 rounded-3xl p-8"
>
<div class="text-4xl mb-6">⚡</div>
<h3 class="text-2xl font-semibold mb-3">
Lightning Fast
</h3>
<p class="text-zinc-400">
~18 MB single Rust binary. Starts instantly. Uses
SQLite with WAL mode. Minimal resource usage.
</p>
</div>
<span class="tp">$</span> <span class="tc">curl -X POST http://localhost:3000/api/users \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"secure"}'</span>
<div
class="bg-zinc-950 border border-zinc-800 rounded-3xl p-8"
>
<div class="text-4xl mb-6">🔒</div>
<h3 class="text-2xl font-semibold mb-3">
Private by Design
</h3>
<p class="text-zinc-400">
No telemetry. No third-party services. Everything
runs on your server. Strong password hashing & audit
logs.
</p>
</div>
<span class="tv">{</span>
<span class="to"> "id": 1,
"username": "admin",
"created_at": "2026-06-19T13:17:28Z"</span>
<span class="tv">}</span>
<div
class="bg-zinc-950 border border-zinc-800 rounded-3xl p-8"
>
<div class="text-4xl mb-6">🎨</div>
<h3 class="text-2xl font-semibold mb-3">
Beautiful Links
</h3>
<p class="text-zinc-400">
Rich custom landing pages with title, description,
OG metadata, and branded preview.
</p>
</div>
</div>
<span class="tp">$</span> <span class="tc">curl -X POST http://localhost:3000/api/short-urls \
-H "Authorization: Bearer TOKEN" \
-d '{"target":"https://example.com","slug":"my-link"}'</span>
<span class="tg">? Short URL created: http://localhost:3000/my-link</span></div>
</div>
</div>
</header>
<div class="grid md:grid-cols-3 gap-8 mt-8">
<div
class="bg-zinc-950 border border-zinc-800 rounded-3xl p-8"
>
<div class="text-4xl mb-6">📱</div>
<h3 class="text-2xl font-semibold mb-3">
QR Codes Built-in
</h3>
<p class="text-zinc-400">
Generate PNG & SVG QR codes. Track scans separately
in analytics.
</p>
</div>
<div class="stats-band">
<div class="stats-grid">
<div><span class="stat-n">∞</span><div class="stat-l">Short URLs</div></div>
<div><span class="stat-n">QR</span><div class="stat-l">Generation</div></div>
<div><span class="stat-n">Realtime</span><div class="stat-l">Analytics</div></div>
<div><span class="stat-n">Multi</span><div class="stat-l">User Config</div></div>
<div><span class="stat-n">Logs</span><div class="stat-l">Audit Trail</div></div>
<div><span class="stat-n">Backup</span><div class="stat-l">& Restore</div></div>
<div><span class="stat-n">FOSS</span><div class="stat-l">MIT/Apache 2.0 License</div></div>
</div>
</div>
<div
class="bg-zinc-950 border border-zinc-800 rounded-3xl p-8"
>
<div class="text-4xl mb-6">🛠️</div>
<h3 class="text-2xl font-semibold mb-3">CLI First</h3>
<p class="text-zinc-400">
backup, restore, doctor, stats, validate,
create-admin — everything from terminal.
</p>
</div>
<section id="features">
<div class="container">
<div class="eyebrow">Core Features</div>
<h2 class="section-title">🛠️ Everything for professional URL management</h2>
<p class="section-sub">Production-grade platform for organizations requiring complete ownership of their links, analytics, and operational data.</p>
<div class="grid-4" style="margin-bottom:1.25rem">
<div class="card"><span class="card-icon">🔗</span><div class="card-title">URL Shortening</div><p class="card-desc">Create short, memorable URLs with custom slugs. Password protection, expiration dates, and smart preview pages included.</p><div class="tags"><span class="tag b">Custom Slugs</span><span class="tag g">Protected</span></div></div>
<div class="card"><span class="card-icon">📄</span><div class="card-title">Landing Pages</div><p class="card-desc">Host custom landing pages with full analytics tracking. QR code support, custom domains, and rich content editing.</p><div class="tags"><span class="tag b">Hosted</span><span class="tag g">Analytics</span></div></div>
<div class="card"><span class="card-icon">📊</span><div class="card-title">Analytics</div><p class="card-desc">Comprehensive visitor tracking with browser detection, referrer analysis, daily/monthly statistics, and CSV/JSON export.</p><div class="tags"><span class="tag r">Real-time</span><span class="tag b">Export</span></div></div>
<div class="card"><span class="card-icon">🦀</span><div class="card-title">Memory Safe</div><p class="card-desc">Built with Rust for zero buffer overflows, use-after-free bugs, or null pointer issues. Complete memory safety at compile time.</p><div class="tags"><span class="tag r">Rust 2021</span><span class="tag g">Safe</span></div></div>
</div>
<div class="grid-4">
<div class="card"><span class="card-icon">👥</span><div class="card-title">Multi-User</div><p class="card-desc">Full user management with quotas, API tokens, session management, and tenant isolation. Role-based access control.</p><div class="tags"><span class="tag b">RBAC</span><span class="tag g">Isolated</span></div></div>
<div class="card"><span class="card-icon">🔐</span><div class="card-title">Audit Logging</div><p class="card-desc">Complete audit trail of all operations. Moderation tools, security events, and compliance-ready logging for enterprises.</p><div class="tags"><span class="tag b">Compliance</span><span class="tag r">Secure</span></div></div>
<div class="card"><span class="card-icon">💾</span><div class="card-title">Backup & Restore</div><p class="card-desc">Disaster recovery workflows with automated backups. One-command restore. No external dependencies required.</p><div class="tags"><span class="tag g">Automated</span><span class="tag b">Recovery</span></div></div>
<div class="card"><span class="card-icon">🗄️</span><div class="card-title">SQLite Backend</div><p class="card-desc">Reliable file-based storage with ACID transactions. No PostgreSQL, Redis, or Kubernetes needed. Single binary deployment.</p><div class="tags"><span class="tag b">ACID</span><span class="tag r">SQLite</span></div></div>
</div>
</div>
</section>
<div
class="bg-zinc-950 border border-zinc-800 rounded-3xl p-8"
>
<div class="text-4xl mb-6">🔑</div>
<h3 class="text-2xl font-semibold mb-3">
Powerful Features
</h3>
<p class="text-zinc-400">
Password protection • Expiry • Access limits • Tags
• REST API • Bulk QR export
</p>
</div>
</div>
</div>
</section>
<div class="divider"></div>
<section id="architecture">
<div class="container">
<div class="eyebrow">Architecture</div>
<h2 class="section-title">🏗️📦 Modular, scalable design</h2>
<p class="section-sub">Tenant-isolated multi-user architecture with separate databases for content and analytics. Built on Axum web framework and SQLite.</p>
<div class="arch-wrap">
<div>
<div class="flow-diagram">
<div><span class="fbox">Web Browser</span></div>
<div class="farrow">↓ HTTP/REST</div>
<div><span class="fbox a1">Axum Server</span></div>
<div class="farrow">↓ Auth · Routing</div>
<div><span class="fbox">Request Type?</span></div>
<div style="display:flex;justify-content:center;gap:1.5rem;align-items:flex-start;margin:.2rem 0">
<div><div class="farrow">URL Mgmt</div><div><span class="fbox a1">Content DB</span></div></div>
<div><div class="farrow">Analytics</div><div><span class="fbox">Analytics DB</span></div></div>
</div>
<div class="farrow">↓ Query</div>
<div><span class="fbox a2">Tenant Data</span></div>
<div class="farrow">↑ Results</div>
<div><span class="fbox">Audit Log?</span></div>
<div style="display:flex;justify-content:center;gap:1.5rem;margin:.2rem 0">
<div><div class="farrow">Yes ↓</div></div>
</div>
<div><span class="fbox ar">System DB (Audit)</span></div>
<div class="farrow">↓ Response</div>
<div><span class="fbox a1">Browser</span></div>
</div>
</div>
<div class="comp-list">
<div class="comp"><span class="comp-num">01</span><div><div class="comp-title">Axum Web Server</div><p class="comp-desc">High-performance async HTTP server. Handles routing, middleware, authentication, and request validation. Graceful shutdown and error handling.</p></div></div>
<div class="comp"><span class="comp-num">02</span><div><div class="comp-title">Multi-Tenant Storage</div><p class="comp-desc">Isolated databases per user. System DB for global state, users DB for accounts/sessions, tenant DBs for content and analytics.</p></div></div>
<div class="comp"><span class="comp-num">03</span><div><div class="comp-title">Analytics Engine</div><p class="comp-desc">Real-time visitor tracking with browser detection, referrer analysis, and aggregated statistics. CSV/JSON export support.</p></div></div>
<div class="comp"><span class="comp-num">04</span><div><div class="comp-title">Audit & Moderation</div><p class="comp-desc">Complete event logging, user activity tracking, and moderation tools. Compliance-ready for regulated industries.</p></div></div>
</div>
</div>
</div>
</section>
<div class="divider"></div>
<section id="databases">
<div class="container">
<div class="eyebrow">Storage Architecture</div>
<h2 class="section-title">👥 Strict Tenant Isolation</h2>
<p class="section-sub">Instead of a monolithic database, BZOD securely segments administrative data from tenant analytics and content via dedicated SQLite files operating in WAL mode.</p>
<div class="arch-wrap">
<div>
<div class="file-tree">
data/<br>
├── <span class="dir">admin/</span> <span class="comment"># Global Metadata</span><br>
│ ├── <span class="file">admin.db</span> <span class="comment"># Internal management</span><br>
│ ├── <span class="file">system.db</span> <span class="comment"># Global slugs & audit logs</span><br>
│ └── <span class="file">users.db</span> <span class="comment"># Auth, sessions, quotas</span><br>
│<br>
└── <span class="dir">users/</span> <span class="comment"># Tenant Isolation</span><br>
&nbsp;&nbsp;&nbsp;&nbsp;├── <span class="dir">1/</span> <span class="comment"># User 1</span><br>
&nbsp;&nbsp;&nbsp;&nbsp;│ ├── <span class="file">analytics.db</span> <span class="comment"># Isolated metrics</span><br>
&nbsp;&nbsp;&nbsp;&nbsp;│ └── <span class="file">content.db</span> <span class="comment"># Isolated URLs/Pages</span><br>
&nbsp;&nbsp;&nbsp;&nbsp;│<br>
&nbsp;&nbsp;&nbsp;&nbsp;└── <span class="dir">2/</span> <span class="comment"># User 2</span><br>
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;├── <span class="file">analytics.db</span><br>
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;└── <span class="file">content.db</span>
</div>
</div>
<div>
<div class="steps">
<div class="step">
<div class="step-n">1</div>
<div><div class="step-title">No Cross-Tenant Bleed</div><p style="color:var(--t2);font-size:.9rem;line-height:1.6">By assigning physical `content.db` and `analytics.db` files to each user, cross-user data leakage is eliminated at the file-system level.</p></div>
</div>
<div class="step">
<div class="step-n">2</div>
<div><div class="step-title">Global Slug Registry</div><p style="color:var(--t2);font-size:.9rem;line-height:1.6">The centralized `system.db` resolves global paths and routes the visitor to the correct tenant's isolated database instantly.</p></div>
</div>
<div class="step">
<div class="step-n">3</div>
<div><div class="step-title">WAL Concurrency</div><p style="color:var(--t2);font-size:.9rem;line-height:1.6">Write-Ahead Logging provides robust concurrency and zero-contention crash recovery without the overhead of PostgreSQL.</p></div>
</div>
</div>
</div>
</div>
</div>
</section>
<!-- CTA -->
<section class="py-20 bg-black border-t border-zinc-800">
<div class="max-w-2xl mx-auto text-center px-6">
<h2 class="text-4xl font-bold mb-6">
Ready to own your short links?
</h2>
<p class="text-zinc-400 mb-10">
Self-host BZOD in under 5 minutes on any VPS, homelab, or
even a Raspberry Pi.
</p>
<div class="flex flex-col sm:flex-row gap-4 justify-center">
<a
href="https://github.com/thakares/nx9-url-shortener"
target="_blank"
class="bg-white text-black px-10 py-4 rounded-2xl font-semibold text-lg"
>
Get BZOD Now
</a>
<a
href="/admin"
class="border border-zinc-700 hover:bg-zinc-900 px-10 py-4 rounded-2xl font-semibold text-lg transition"
>
Open Dashboard
</a>
</div>
</div>
</section>
<div class="divider"></div>
<footer class="bg-zinc-950 py-12 border-t border-zinc-800">
<div
class="max-w-5xl mx-auto px-6 text-center text-zinc-500 text-sm"
>
© 2026 BZOD • Made with ❤️ in Rust by Sunil Purushottam Thakare
• Running on <span class="font-mono">bzo.in</span>
</div>
</footer>
</body>
<section id="api">
<div class="container">
<div class="eyebrow">REST API</div>
<h2 class="section-title">🔑💻 Programmatic Access</h2>
<p class="section-sub">Integrate BZOD with CI/CD pipelines, home automation, or custom applications using secure, token-based authentication.</p>
<div class="grid-2">
<div>
<h3 style="font-size:1rem;font-weight:600;margin-bottom:1rem;color:var(--t1)">Create a Short URL</h3>
<pre class="cb"><span class="kw">POST</span> /api/v1/urls
Authorization: Bearer bzo_xxxxxxxxxxxx
{
<span class="str">"code"</span>: <span class="str">"rust"</span>,
<span class="str">"target_url"</span>: <span class="str">"https://www.rust-lang.org"</span>,
<span class="str">"description"</span>: <span class="str">"Rust Language"</span>
}</pre>
</div>
<div>
<h3 style="font-size:1rem;font-weight:600;margin-bottom:1rem;color:var(--t1)">Fetch Global Statistics</h3>
<pre class="cb"><span class="kw">GET</span> /api/v1/stats
Authorization: Bearer bzo_xxxxxxxxxxxx
{
<span class="str">"success"</span>: true,
<span class="str">"data"</span>: {
<span class="str">"total_urls"</span>: 125,
<span class="str">"total_clicks"</span>: 8431,
<span class="str">"total_pages"</span>: 12
}
}</pre>
</div>
</div>
</div>
</section>
<div class="divider"></div>
<section id="cli">
<div class="container">
<div class="eyebrow">Operations & CLI</div>
<h2 class="section-title">🧑‍💻⚙️🔧 Built for Systems Administrators</h2>
<p class="section-sub">Full control via a comprehensive command-line interface. Handle migrations, disaster recovery, and user management directly from the terminal.</p>
<div class="tab-shell">
<div class="tab-strip">
<button class="tab-btn on" onclick="switchTab(event, 'cli-backup')">Backup & Restore</button>
<button class="tab-btn" onclick="switchTab(event, 'cli-users')">User Management</button>
<button class="tab-btn" onclick="switchTab(event, 'cli-diag')">Diagnostics</button>
</div>
<div id="cli-backup" class="tab-panel on">
<pre class="cb"><span class="cm"># Create a full compressed snapshot of the entire platform</span>
<span class="kw">bzod</span> backup
<span class="cm"># Restore platform from a specific archive</span>
<span class="kw">bzod</span> restore backup-20260619-020000.zip
<span class="cm"># Backup and export only a single tenant's data</span>
<span class="kw">bzod</span> backup-user 42</pre>
</div>
<div id="cli-users" class="tab-panel">
<pre class="cb"><span class="cm"># Bootstrap a new administrator</span>
<span class="kw">bzod</span> create-admin admin
<span class="cm"># Provision a new standard user</span>
<span class="kw">bzod</span> create-user alice
<span class="cm"># Immediately invalidate user sessions and prevent login</span>
<span class="kw">bzod</span> disable-user alice</pre>
</div>
<div id="cli-diag" class="tab-panel">
<pre class="cb"><span class="cm"># Run SQLite integrity checks, WAL validation, and storage checks</span>
<span class="kw">bzod</span> doctor
<span class="cm"># Verify all registered short link destinations exist</span>
<span class="kw">bzod</span> validate
<span class="cm"># Apply schema upgrades safely</span>
<span class="kw">bzod</span> migrate</pre>
</div>
</div>
</div>
</section>
<div class="divider"></div>
<section id="compare">
<div class="container">
<div class="eyebrow">Market Comparison</div>
<h2 class="section-title">⚖️ How BZOD Compares</h2>
<p class="section-sub">Unlike standard shorteners, BZOD provides a comprehensive suite of features—from landing pages to multi-tenant user isolation—in a single, compiled binary.</p>
<table class="compare-tbl">
<thead>
<tr>
<th>Feature</th>
<th>BZOD v0.5.0</th>
<th>Shlink</th>
<th>YOURLS</th>
<th>Chhoto URL</th>
</tr>
</thead>
<tbody>
<tr>
<td>Language</td>
<td>Rust 🦀</td>
<td>PHP 🐘</td>
<td>PHP 🐘</td>
<td>Rust 🦀</td>
</tr>
<tr>
<td>Deployment</td>
<td><span class="check-yes">✅ Single Binary</span></td>
<td><span class="check-no">❌ Requires PHP/Web Server</span></td>
<td><span class="check-no">❌ Requires PHP/Web Server</span></td>
<td><span class="check-yes">✅ Single Binary</span></td>
</tr>
<tr>
<td>External DB Required</td>
<td><span class="check-yes">✅ None (SQLite)</span></td>
<td><span class="check-partial">⚠️ Usually (PostgreSQL/Maria)</span></td>
<td><span class="check-partial">⚠️ Usually (MySQL)</span></td>
<td><span class="check-yes">✅ None</span></td>
</tr>
<tr>
<td>Landing Pages</td>
<td><span class="check-yes">✅ Native</span></td>
<td><span class="check-no">❌ No</span></td>
<td><span class="check-partial">⚠️ Via Plugin</span></td>
<td><span class="check-no">❌ No</span></td>
</tr>
<tr>
<td>QR + Analytics</td>
<td><span class="check-yes">✅ Native</span></td>
<td><span class="check-partial">⚠️ Partial</span></td>
<td><span class="check-partial">⚠️ Via Plugin</span></td>
<td><span class="check-partial">⚠️ Partial</span></td>
</tr>
<tr>
<td>Multi-User / Quotas</td>
<td><span class="check-yes">✅ Native (Isolated)</span></td>
<td><span class="check-partial">⚠️ Partial</span></td>
<td><span class="check-partial">⚠️ Via Plugin</span></td>
<td><span class="check-no">❌ No</span></td>
</tr>
<tr>
<td>Backup & Recovery</td>
<td><span class="check-yes">✅ Built-in CLI & Web UI</span></td>
<td><span class="check-no">❌ External Tools Needed</span></td>
<td><span class="check-no">❌ External Tools Needed</span></td>
<td><span class="check-no">❌ No</span></td>
</tr>
</tbody>
</table>
</div>
</section>
<div class="divider"></div>
<section id="install">
<div class="container">
<div class="eyebrow">Deployment</div>
<h2 class="section-title">🚀 Get Started in Minutes</h2>
<p class="section-sub">Deploy via Docker for containerized environments, or compile directly from source for bare-metal performance.</p>
<div class="tab-shell">
<div class="tab-strip">
<button class="tab-btn on" onclick="switchTab(event, 'install-docker')">Docker Compose</button>
<button class="tab-btn" onclick="switchTab(event, 'install-native')">Native Binary</button>
</div>
<div id="install-docker" class="tab-panel on">
<pre class="cb"><span class="cm"># Clone the official repository</span>
<span class="kw">git</span> clone https://github.com/thakares/nx9-url-shortener bzod
<span class="kw">cd</span> bzod
<span class="cm"># Launch containerized services</span>
<span class="kw">docker compose</span> up -d --build
<span class="cm"># Initialize base admin</span>
<span class="kw">docker exec</span> -it bzod bzod create-admin</pre>
</div>
<div id="install-native" class="tab-panel">
<pre class="cb"><span class="cm"># Clone the official repository</span>
<span class="kw">git</span> clone https://github.com/thakares/nx9-url-shortener bzod
<span class="kw">cd</span> bzod
<span class="cm"># Compile optimized release</span>
<span class="kw">cargo</span> build --release
<span class="cm"># Initialize base admin</span>
./target/release/bzod create-admin
<span class="cm"># Serve application</span>
./target/release/bzod serve</pre>
</div>
</div>
</div>
</section>
<footer>
<div class="footer-grid">
<div>
<div class="footer-brand">
<div class="logo-box" style="width:28px;height:28px;font-size:.75rem">BZ</div>
BZOD
</div>
<p class="footer-about">A streamlined, self-hosted multi-user URL management platform written in Rust. Retain absolute sovereignty over your digital links.</p>
</div>
<div>
<div class="footer-col-title">Platform</div>
<ul class="footer-links">
<li><a href="#features">Features Overview</a></li>
<li><a href="#architecture">Architecture</a></li>
<li><a href="#install">Installation</a></li>
</ul>
</div>
<div>
<div class="footer-col-title">Access</div>
<ul class="footer-links">
<li><a href="/login">User Login</a></li>
<li><a href="/admin">Admin Panel</a></li>
<li><a href="#api">API Documentation</a></li>
</ul>
</div>
<div>
<div class="footer-col-title">Repositories</div>
<ul class="footer-links">
<li><a href="https://github.com/thakares/nx9-url-shortener" target="_blank">GitHub</a></li>
<li><a href="https://codeberg.org/thakares/nx9-url-shortener" target="_blank">CodeBerg</a></li>
</ul>
</div>
</div>
<div class="footer-bottom">
<span>©️ 2026 BZOD. Dual licensed under MIT/Apache 2.0</span>
</div>
</footer>
<script src="https://unpkg.com/lucide@latest"></script>
<script>
lucide.createIcons();
function switchTab(e, tabName) {
const shell = e.currentTarget.closest('.tab-shell');
const tabs = shell.querySelectorAll('.tab-panel');
const btns = shell.querySelectorAll('.tab-btn');
tabs.forEach(tab => tab.classList.remove('on'));
btns.forEach(btn => btn.classList.remove('on'));
document.getElementById(tabName).classList.add('on');
e.currentTarget.classList.add('on');
}
/* copy & paste installation link */
function copyInstallCommand() {
const cmd = "curl -fsSL https://bzo.in/deploy.sh | sudo bash";
navigator.clipboard.writeText(cmd).then(() => {
const msg = document.getElementById('copy-msg');
msg.style.display = 'block';
setTimeout(() => msg.style.display = 'none', 2000);
});
}
</script>
<script src="https://unpkg.com/lucide@latest"></script>
<script>lucide.createIcons();</script>
</body>
</html>