5 Commits
96 changed files with 5623 additions and 841 deletions

No files matched your search

Generated
+695 -1
View File
@@ -29,6 +29,24 @@ 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"
@@ -94,6 +112,26 @@ version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "arbitrary"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
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"
@@ -106,6 +144,21 @@ 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"
@@ -147,7 +200,7 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "acb1161c6b64d1c3d83108213c2a2533a342ac225aabd0bda218278c2ddb00c0"
dependencies = [
"nom",
"nom 7.1.3",
]
[[package]]
@@ -173,6 +226,49 @@ 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"
@@ -285,12 +381,27 @@ 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"
@@ -309,12 +420,30 @@ 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"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
[[package]]
name = "bytemuck"
version = "1.25.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec"
[[package]]
name = "byteorder-lite"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
[[package]]
name = "bytes"
version = "1.11.1"
@@ -334,6 +463,8 @@ dependencies = [
"dotenvy",
"flate2",
"hex",
"image",
"qrcode",
"rand 0.8.6",
"reqwest",
"rusqlite",
@@ -347,6 +478,7 @@ dependencies = [
"tracing",
"tracing-subscriber",
"uuid",
"zip",
]
[[package]]
@@ -356,6 +488,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f"
dependencies = [
"find-msvc-tools",
"jobserver",
"libc",
"shlex",
]
@@ -425,6 +559,12 @@ 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"
@@ -466,6 +606,37 @@ 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"
@@ -485,6 +656,17 @@ dependencies = [
"powerfmt",
]
[[package]]
name = "derive_arbitrary"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "digest"
version = "0.10.7"
@@ -513,6 +695,12 @@ 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"
@@ -522,6 +710,26 @@ 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"
@@ -538,6 +746,21 @@ 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"
@@ -556,6 +779,21 @@ 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"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c"
dependencies = [
"simd-adler32",
]
[[package]]
name = "filetime"
version = "0.2.29"
@@ -680,6 +918,27 @@ 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"
@@ -972,6 +1231,46 @@ dependencies = [
"icu_properties",
]
[[package]]
name = "image"
version = "0.25.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
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"
@@ -984,6 +1283,17 @@ 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"
@@ -996,12 +1306,31 @@ 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"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "jobserver"
version = "0.1.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33"
dependencies = [
"getrandom 0.3.4",
"libc",
]
[[package]]
name = "js-sys"
version = "0.3.100"
@@ -1025,12 +1354,28 @@ 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"
@@ -1075,6 +1420,15 @@ 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"
@@ -1096,6 +1450,16 @@ 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"
@@ -1145,6 +1509,16 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "moxcms"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b"
dependencies = [
"num-traits",
"pxfm",
]
[[package]]
name = "multer"
version = "3.1.0"
@@ -1162,6 +1536,21 @@ 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"
@@ -1172,6 +1561,21 @@ 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"
@@ -1181,12 +1585,53 @@ 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"
@@ -1242,6 +1687,18 @@ 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"
@@ -1260,6 +1717,19 @@ version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
[[package]]
name = "png"
version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
dependencies = [
"bitflags",
"crc32fast",
"fdeflate",
"flate2",
"miniz_oxide",
]
[[package]]
name = "potential_utf"
version = "0.1.5"
@@ -1303,6 +1773,55 @@ 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 = "pxfm"
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"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d68782463e408eb1e668cf6152704bd856c78c5b6417adaee3203d8f4c1fc9ec"
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"
@@ -1438,6 +1957,76 @@ 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"
@@ -1502,6 +2091,12 @@ 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"
@@ -1725,6 +2320,15 @@ 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"
@@ -1842,6 +2446,20 @@ 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"
@@ -2157,6 +2775,17 @@ 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"
@@ -2326,6 +2955,12 @@ 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"
@@ -2660,6 +3295,12 @@ 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"
@@ -2763,8 +3404,61 @@ dependencies = [
"syn",
]
[[package]]
name = "zip"
version = "2.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50"
dependencies = [
"arbitrary",
"crc32fast",
"crossbeam-utils",
"displaydoc",
"flate2",
"indexmap",
"memchr",
"thiserror",
"zopfli",
]
[[package]]
name = "zmij"
version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
[[package]]
name = "zopfli"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249"
dependencies = [
"bumpalo",
"crc32fast",
"log",
"simd-adler32",
]
[[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",
]
+3 -2
View File
@@ -26,5 +26,6 @@ chrono = { version = "0.4", features = ["serde"] }
hex = "0.4"
time = "0.3"
toml = "0.8"
qrcode = "0.14"
image = "0.25"
zip = { version = "2.1", default-features = false, features = ["deflate"] }
+9 -7
View File
@@ -1,7 +1,8 @@
# ==========================================
# Stage 1: Build
# ==========================================
FROM rust:1.82-slim-bookworm AS builder
# FROM rust:1.82-slim-bookworm AS builder
FROM rust:1.89-bookworm AS builder
WORKDIR /app
@@ -13,7 +14,8 @@ RUN apt-get update && apt-get install -y \
&& rm -rf /var/lib/apt/lists/*
# Copy configuration files
COPY Cargo.toml ./
# COPY Cargo.toml ./
COPY Cargo.toml Cargo.lock ./
# Pre-build dependencies to cache them
RUN mkdir src && echo "fn main() {}" > src/main.rs
@@ -46,22 +48,22 @@ RUN apt-get update && apt-get install -y \
COPY --from=builder /app/target/release/bzod /usr/local/bin/bzod
# Create non-root user and data directory
RUN groupadd -g 10001 bzod && \
useradd -u 10001 -g bzod -m -s /bin/bash bzod
RUN groupadd -g 1000 bzod && \
useradd -u 1000 -g bzod -m -s /bin/bash bzod
RUN mkdir -p /app/data && chown -R bzod:bzod /app/data
USER bzod
ENV DATA_DIR=/app/data
ENV PORT=8080
ENV PORT=8654
ENV HOST=0.0.0.0
ENV COOKIE_SECURE=true
EXPOSE 8080
EXPOSE 8654
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
CMD curl -f http://localhost:$${PORT:-8080}/status || exit 1
CMD curl -f http://localhost:$${PORT:-8654}/status || exit 1
ENTRYPOINT ["bzod"]
CMD ["serve"]
+29 -8
View File
@@ -1,8 +1,8 @@
# nx9-url-shortner
# nx9-url-shortener
A lightweight, self-hosted URL shortener and landing page platform written in Rust.
`nx9-url-shortner` is designed for individuals, organizations, and homelab operators who want complete control over their short links without relying on third-party services.
`nx9-url-shortener` is designed for individuals, organizations, and homelab operators who want complete control over their short links without relying on third-party services.
Built with Rust, SQLite, Axum, and Askama, it provides URL shortening, landing pages, analytics, audit logging, API access, and a web-based administration interface while maintaining a small deployment footprint.
@@ -17,7 +17,7 @@ Create short links using compact hexadecimal identifiers.
Example:
```text
https://<your-domain>/1bb170
https://<your-short-domain>/1bb170
```
Redirects to:
@@ -35,7 +35,7 @@ Create standalone landing pages using dedicated page identifiers.
Example:
```text
https://<your-domain>/p/1a2b
https://<your-short-domain>/p/1a2b
```
---
@@ -136,6 +136,30 @@ Change the password immediately after first login.
---
## Screenshots
### Dashboard
![Dashboard](screenshots/dashboard.png)
### Short URL Management
![Short URL Management](screenshots/short-url-panel.png)
### Landing Pages
![Landing Pages](screenshots/landing-page-panel.png)
### Settings
![Settings](screenshots/settings.png)
### Server Status
![Server Status](screenshots/server-status.png)
---
## Docker Deployment
### Build
@@ -261,7 +285,7 @@ Internet
Nginx Proxy Manager
│
▼
nx9-url-shortner
nx9-url-shortener
│
▼
SQLite
@@ -282,6 +306,3 @@ Apache License 2.0
Sunil Purushottam Thakare
Built using Rust, SQLite, Axum, Askama, and a preference for simple, maintainable software.
=======
# nx9-url-shortener
A simple, self-hosted URL shortener built with Rust and SQLite: no Node.js, no Redis, no external dependencies.
+53 -14
View File
@@ -1,28 +1,67 @@
name: app-bzod
services:
bzod:
image: nx9-url-shortener:v0.1.0
build:
context: .
dockerfile: Dockerfile
container_name: bzod
restart: unless-stopped
environment:
- COOKIE_SECURE=false
- DATA_DIR=/app/data
- HOST=0.0.0.0
- PORT=8654
- RUST_LOG=info
ports:
- "8654:8654"
- mode: ingress
target: 8654
published: "8654"
protocol: tcp
restart: unless-stopped
volumes:
- /DATA/AppData/bzod/data:/app/data
- /DATA/AppData/bzod/config:/app/config
- type: bind
source: /DATA/AppData/bzod/data
target: /app/data
bind:
create_host_path: true
environment:
HOST: 0.0.0.0
PORT: 8654
DATA_DIR: /app/data
COOKIE_SECURE: "false"
- type: bind
source: /DATA/AppData/bzod/config
target: /app/config
bind:
create_host_path: true
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8654/status"]
interval: 30s
timeout: 5s
retries: 3
networks:
- default
hostname: bzod
privileged: false
cpu_shares: 90
deploy:
resources:
limits:
memory: 31940M
networks:
default:
name: app_default
x-casaos:
hostname: ""
scheme: http
index: /
port_map: "8654"
author: self
category: self
icon: ""
title:
custom: nx9-url-shortener
Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

+67 -24
View File
@@ -1,6 +1,6 @@
use rusqlite::{Connection, params};
use crate::db::analytics::{clean_referrer, parse_ua};
use rusqlite::{params, Connection};
use std::collections::HashMap;
use crate::db::analytics::{parse_ua, clean_referrer};
// Run aggregation for a specific day
pub fn aggregate_day(conn: &mut Connection, date: &str) -> rusqlite::Result<()> {
@@ -10,7 +10,7 @@ pub fn aggregate_day(conn: &mut Connection, date: &str) -> rusqlite::Result<()>
let mut stmt = conn.prepare(
"SELECT target_type, target_id, user_agent, referer, country, status_code FROM visits WHERE date(timestamp) = ?1;"
)?;
struct RawVisit {
target_type: String,
target_id: String,
@@ -18,7 +18,7 @@ pub fn aggregate_day(conn: &mut Connection, date: &str) -> rusqlite::Result<()>
referer: String,
country: String,
}
let rows = stmt.query_map(params![date], |row| {
Ok(RawVisit {
target_type: row.get(0)?,
@@ -28,7 +28,7 @@ pub fn aggregate_day(conn: &mut Connection, date: &str) -> rusqlite::Result<()>
country: row.get(4)?,
})
})?;
for r in rows {
visits.push(r?);
}
@@ -46,7 +46,11 @@ pub fn aggregate_day(conn: &mut Connection, date: &str) -> rusqlite::Result<()>
for v in visits {
let (browser, os, device) = parse_ua(&v.user_agent);
let referrer = clean_referrer(&v.referer);
let country = if v.country.is_empty() { "Unknown".to_string() } else { v.country.clone() };
let country = if v.country.is_empty() {
"Unknown".to_string()
} else {
v.country.clone()
};
let targets = vec![
(v.target_type.clone(), v.target_id.clone()),
@@ -55,22 +59,59 @@ pub fn aggregate_day(conn: &mut Connection, date: &str) -> rusqlite::Result<()>
for (t_type, t_id) in targets {
// Clicks
*aggregates.entry((t_type.clone(), t_id.clone(), "clicks".to_string(), "".to_string())).or_insert(0) += 1;
*aggregates
.entry((
t_type.clone(),
t_id.clone(),
"clicks".to_string(),
"".to_string(),
))
.or_insert(0) += 1;
// Country
*aggregates.entry((t_type.clone(), t_id.clone(), "country".to_string(), country.clone())).or_insert(0) += 1;
*aggregates
.entry((
t_type.clone(),
t_id.clone(),
"country".to_string(),
country.clone(),
))
.or_insert(0) += 1;
// Browser
*aggregates.entry((t_type.clone(), t_id.clone(), "browser".to_string(), browser.clone())).or_insert(0) += 1;
*aggregates
.entry((
t_type.clone(),
t_id.clone(),
"browser".to_string(),
browser.clone(),
))
.or_insert(0) += 1;
// OS
*aggregates.entry((t_type.clone(), t_id.clone(), "os".to_string(), os.clone())).or_insert(0) += 1;
*aggregates
.entry((t_type.clone(), t_id.clone(), "os".to_string(), os.clone()))
.or_insert(0) += 1;
// Device
*aggregates.entry((t_type.clone(), t_id.clone(), "device".to_string(), device.clone())).or_insert(0) += 1;
*aggregates
.entry((
t_type.clone(),
t_id.clone(),
"device".to_string(),
device.clone(),
))
.or_insert(0) += 1;
// Referrer
*aggregates.entry((t_type.clone(), t_id.clone(), "referrer".to_string(), referrer.clone())).or_insert(0) += 1;
*aggregates
.entry((
t_type.clone(),
t_id.clone(),
"referrer".to_string(),
referrer.clone(),
))
.or_insert(0) += 1;
}
}
@@ -78,7 +119,10 @@ pub fn aggregate_day(conn: &mut Connection, date: &str) -> rusqlite::Result<()>
let tx = conn.transaction()?;
{
// Delete old aggregates for this day
tx.execute("DELETE FROM daily_summaries WHERE date = ?1;", params![date])?;
tx.execute(
"DELETE FROM daily_summaries WHERE date = ?1;",
params![date],
)?;
let mut insert_stmt = tx.prepare(
"INSERT INTO daily_summaries (date, target_type, target_id, metric_type, metric_key, metric_value)
@@ -86,14 +130,7 @@ pub fn aggregate_day(conn: &mut Connection, date: &str) -> rusqlite::Result<()>
)?;
for ((t_type, t_id, m_type, m_key), value) in aggregates {
insert_stmt.execute(params![
date,
t_type,
t_id,
m_type,
m_key,
value
])?;
insert_stmt.execute(params![date, t_type, t_id, m_type, m_key, value])?;
}
}
tx.commit()?;
@@ -108,7 +145,10 @@ pub fn aggregate_day(conn: &mut Connection, date: &str) -> rusqlite::Result<()>
pub fn aggregate_month_from_daily(conn: &mut Connection, year_month: &str) -> rusqlite::Result<()> {
let tx = conn.transaction()?;
{
tx.execute("DELETE FROM monthly_summaries WHERE year_month = ?1;", params![year_month])?;
tx.execute(
"DELETE FROM monthly_summaries WHERE year_month = ?1;",
params![year_month],
)?;
tx.execute(
"INSERT INTO monthly_summaries (year_month, target_type, target_id, metric_type, metric_key, metric_value)
SELECT ?1, target_type, target_id, metric_type, metric_key, SUM(metric_value)
@@ -125,7 +165,10 @@ pub fn aggregate_month_from_daily(conn: &mut Connection, year_month: &str) -> ru
pub fn aggregate_year_from_daily(conn: &mut Connection, year: &str) -> rusqlite::Result<()> {
let tx = conn.transaction()?;
{
tx.execute("DELETE FROM yearly_summaries WHERE year = ?1;", params![year])?;
tx.execute(
"DELETE FROM yearly_summaries WHERE year = ?1;",
params![year],
)?;
tx.execute(
"INSERT INTO yearly_summaries (year, target_type, target_id, metric_type, metric_key, metric_value)
SELECT ?1, target_type, target_id, metric_type, metric_key, SUM(metric_value)
+1 -1
View File
@@ -1,4 +1,4 @@
use serde::{Serialize, Deserialize};
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Clone, Debug)]
pub enum AnalyticsEvent {
+6 -6
View File
@@ -1,10 +1,10 @@
pub mod aggregate;
pub mod events;
pub mod location;
pub mod queue;
pub mod worker;
pub mod location;
pub mod events;
pub mod aggregate;
pub use queue::AnalyticsQueue;
pub use location::get_client_country;
pub use events::AnalyticsEvent;
pub use aggregate::{aggregate_day, aggregate_month_from_daily, aggregate_year_from_daily};
pub use events::AnalyticsEvent;
pub use location::get_client_country;
pub use queue::AnalyticsQueue;
+2 -2
View File
@@ -1,6 +1,6 @@
use tokio::sync::mpsc;
use crate::models::VisitRecord;
use crate::db::Db;
use crate::models::VisitRecord;
use tokio::sync::mpsc;
#[derive(Clone)]
pub struct AnalyticsQueue {
+3 -3
View File
@@ -1,11 +1,11 @@
use std::time::Duration;
use tokio::sync::mpsc;
use tokio::time::{interval, MissedTickBehavior};
use std::time::Duration;
use tracing::{info, error};
use tracing::{error, info};
use crate::db::analytics::insert_visits_batch;
use crate::db::Db;
use crate::models::VisitRecord;
use crate::db::analytics::insert_visits_batch;
pub async fn run_worker(db: Db, mut receiver: mpsc::Receiver<VisitRecord>) {
let mut batch = Vec::new();
+1 -1
View File
@@ -1,4 +1,4 @@
use sha2::{Sha256, Digest};
use sha2::{Digest, Sha256};
// Deterministic CSRF token derived from session token
pub fn generate_csrf_token(session_id: &str) -> String {
+6 -5
View File
@@ -1,10 +1,10 @@
use crate::auth::session::authenticate_api_key;
use crate::models::User;
use crate::state::AppState;
use axum::{
extract::{FromRequestParts, FromRef},
extract::{FromRef, FromRequestParts},
http::{request::Parts, StatusCode},
};
use crate::state::AppState;
use crate::models::User;
use crate::auth::session::authenticate_api_key;
// Extractor: Authenticate API requests using Bearer token
pub struct ApiUser(pub User);
@@ -19,7 +19,8 @@ where
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
let app_state = AppState::from_ref(state);
let auth_header = parts.headers
let auth_header = parts
.headers
.get("Authorization")
.and_then(|h| h.to_str().ok())
.ok_or((StatusCode::UNAUTHORIZED, "Missing Authorization header"))?;
+4 -4
View File
@@ -1,9 +1,9 @@
pub mod password;
pub mod session;
pub mod csrf;
pub mod middleware;
pub mod password;
pub mod session;
pub use password::{hash_password, verify_password, verify_sha256};
pub use session::{generate_token, authenticate_session, authenticate_api_key};
pub use csrf::{generate_csrf_token, verify_csrf};
pub use middleware::ApiUser;
pub use password::{hash_password, verify_password, verify_sha256};
pub use session::{authenticate_api_key, authenticate_session, generate_token};
+7 -3
View File
@@ -1,21 +1,25 @@
use sha2::{Sha256, Digest};
use argon2::{
password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
Argon2,
};
use sha2::{Digest, Sha256};
// Hashing password with Argon2id
pub fn hash_password(password: &str) -> Result<String, argon2::password_hash::Error> {
let salt = SaltString::generate(&mut OsRng);
let argon2 = Argon2::default();
let password_hash = argon2.hash_password(password.as_bytes(), &salt)?.to_string();
let password_hash = argon2
.hash_password(password.as_bytes(), &salt)?
.to_string();
Ok(password_hash)
}
// Verifying Argon2id password hash
pub fn verify_password(password: &str, hash: &str) -> bool {
if let Ok(parsed_hash) = PasswordHash::new(hash) {
Argon2::default().verify_password(password.as_bytes(), &parsed_hash).is_ok()
Argon2::default()
.verify_password(password.as_bytes(), &parsed_hash)
.is_ok()
} else {
false
}
+16 -14
View File
@@ -1,10 +1,12 @@
use sha2::{Sha256, Digest};
use rand::{RngCore, thread_rng};
use axum_extra::extract::CookieJar;
use rusqlite::Connection;
use chrono::Utc;
use crate::db::admin::{get_session, get_user_by_id, update_api_key_last_used, get_api_key_by_hash};
use crate::db::admin::{
get_api_key_by_hash, get_session, get_user_by_id, update_api_key_last_used,
};
use crate::models::User;
use axum_extra::extract::CookieJar;
use chrono::Utc;
use rand::{thread_rng, RngCore};
use rusqlite::Connection;
use sha2::{Digest, Sha256};
// Generate a secure random token (hex-encoded)
pub fn generate_token(bytes_len: usize) -> String {
@@ -22,13 +24,13 @@ pub fn authenticate_session(
Some(c) => c,
None => return Ok(None),
};
let session_id = cookie.value();
let session = match get_session(conn, session_id)? {
Some(s) => s,
None => return Ok(None),
};
// Check expiration
if let Ok(expires) = chrono::DateTime::parse_from_rfc3339(&session.expires_at) {
if expires.with_timezone(&Utc) < Utc::now() {
@@ -38,7 +40,7 @@ pub fn authenticate_session(
} else {
return Ok(None);
}
// Get user
if let Some(user) = get_user_by_id(conn, &session.user_id)? {
Ok(Some((user, session.id)))
@@ -55,26 +57,26 @@ pub fn authenticate_api_key(
if !auth_header.starts_with("Bearer ") {
return Ok(None);
}
let key = auth_header.trim_start_matches("Bearer ").trim();
if key.is_empty() {
return Ok(None);
}
// Hash the API key using SHA-256 to compare with stored hash
let mut hasher = Sha256::new();
hasher.update(key.as_bytes());
let hashed_key = hex::encode(hasher.finalize());
if let Some(api_key_rec) = get_api_key_by_hash(conn, &hashed_key)? {
// Update last used timestamp
update_api_key_last_used(conn, &api_key_rec.id)?;
// Get user
if let Some(user) = get_user_by_id(conn, &api_key_rec.user_id)? {
return Ok(Some(user));
}
}
Ok(None)
}
+2 -2
View File
@@ -14,7 +14,7 @@ pub fn generate_bar_chart(data: &[(String, i64)]) -> String {
let pad_left = 130.0;
let pad_right = 70.0;
let width = 600.0;
let height = pad_top + pad_bottom + (data.len() as f64 * (bar_height + gap)) - gap;
let chart_w = width - pad_left - pad_right;
@@ -27,7 +27,7 @@ pub fn generate_bar_chart(data: &[(String, i64)]) -> String {
for (i, (label, val)) in data.iter().enumerate() {
let y = pad_top + (i as f64 * (bar_height + gap));
let bar_w = (*val as f64 / max_x) * chart_w;
let pct = if total_count > 0 {
(*val as f64 / total_count as f64) * 100.0
} else {
+16 -4
View File
@@ -37,7 +37,11 @@ pub fn generate_line_chart(data: &[(String, i64)]) -> String {
// Coordinates calculations
let count = data.len();
let step_x = if count > 1 { chart_w / (count - 1) as f64 } else { chart_w };
let step_x = if count > 1 {
chart_w / (count - 1) as f64
} else {
chart_w
};
let mut points = Vec::new();
for (i, &(_, val)) in data.iter().enumerate() {
@@ -70,15 +74,23 @@ pub fn generate_line_chart(data: &[(String, i64)]) -> String {
for (i, (label, _)) in data.iter().enumerate() {
if i % label_step == 0 || i == count - 1 {
let x = points[i].0;
let short_label = if label.len() == 10 { &label[5..] } else { label };
let short_label = if label.len() == 10 {
&label[5..]
} else {
label
};
x_labels.push_str(&format!(
r##"<text x="{}" y="{}" fill="{}" font-size="11" font-family="system-ui, sans-serif" text-anchor="middle">{}</text>"##,
x, height - 15.0, DEFAULT_TEXT_COLOR, short_label
));
x_labels.push_str(&format!(
r##"<line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{}" stroke-width="1"/>"##,
x, pad_top + chart_h, x, pad_top + chart_h + 5.0, DEFAULT_GRID_COLOR
x,
pad_top + chart_h,
x,
pad_top + chart_h + 5.0,
DEFAULT_GRID_COLOR
));
}
}
+3 -3
View File
@@ -1,10 +1,10 @@
pub mod svg;
pub mod line;
pub mod bar;
pub mod line;
pub mod pie;
pub mod svg;
pub mod timeseries;
pub use line::generate_line_chart;
pub use bar::generate_bar_chart;
pub use line::generate_line_chart;
pub use pie::generate_pie_chart;
pub use timeseries::generate_timeseries_chart;
+9 -5
View File
@@ -1,20 +1,24 @@
use std::path::PathBuf;
use tracing::info;
use crate::config::Config;
use crate::db::Db;
use crate::jobs::backup::perform_backup;
use std::path::PathBuf;
use tracing::info;
pub async fn run(
out: Option<String>,
data_dir: Option<String>,
mut config: Config,
) -> Result<(), Box<dyn std::error::Error>> {
if let Some(d) = data_dir { config.data_dir = PathBuf::from(d); }
if let Some(o) = out { config.backup_dir = PathBuf::from(o); }
if let Some(d) = data_dir {
config.data_dir = PathBuf::from(d);
}
if let Some(o) = out {
config.backup_dir = PathBuf::from(o);
}
// Init DB connections to ensure databases exist and migrate if needed
let db = Db::init(&config)?;
info!("Starting database backup...");
let backup_path = perform_backup(&db, &config).await?;
info!("Database backup generated successfully: {}", backup_path);
+11 -6
View File
@@ -1,16 +1,18 @@
use std::path::PathBuf;
use std::io::{self, Write};
use tracing::{info, error};
use crate::auth::hash_password;
use crate::config::Config;
use crate::db::Db;
use crate::auth::hash_password;
use std::io::{self, Write};
use std::path::PathBuf;
use tracing::{error, info};
pub async fn run(
username: Option<String>,
data_dir: Option<String>,
mut config: Config,
) -> Result<(), Box<dyn std::error::Error>> {
if let Some(d) = data_dir { config.data_dir = PathBuf::from(d); }
if let Some(d) = data_dir {
config.data_dir = PathBuf::from(d);
}
let db = Db::init(&config)?;
let final_username = match username {
@@ -32,7 +34,10 @@ pub async fn run(
let hash = hash_password(&password).map_err(|e| e.to_string())?;
let conn = db.admin.lock().unwrap();
let u = crate::db::admin::create_user(&conn, &final_username, &hash)?;
info!("Successfully created admin user: {} (ID: {})", u.username, u.id);
info!(
"Successfully created admin user: {} (ID: {})",
u.username, u.id
);
Ok(())
}
+30 -20
View File
@@ -1,8 +1,8 @@
use std::path::PathBuf;
use tracing::info;
use crate::config::Config;
use crate::db::sqlite;
use rusqlite::Connection;
use std::path::PathBuf;
use tracing::info;
/// Run comprehensive database diagnostics.
///
@@ -12,7 +12,9 @@ pub async fn run(
data_dir: Option<String>,
mut config: Config,
) -> Result<(), Box<dyn std::error::Error>> {
if let Some(d) = data_dir { config.data_dir = PathBuf::from(d); }
if let Some(d) = data_dir {
config.data_dir = PathBuf::from(d);
}
info!("Running BZOD database diagnostics...");
println!("BZOD Database Doctor");
@@ -35,27 +37,35 @@ pub async fn run(
}
match Connection::open(&db_path) {
Ok(conn) => {
match sqlite::collect_health_report(&conn, db_name) {
Ok(report) => {
println!("Database: {}", report.database);
println!(" Path: {:?}", db_path);
println!(" Schema version: {}", report.schema_version);
println!(" Journal mode: {}", report.journal_mode);
println!(" Foreign keys: {}", if report.foreign_keys_enabled { "enabled" } else { "DISABLED" });
println!(" Integrity: {}", if report.integrity_ok { "ok" } else { "FAILED" });
if !report.integrity_ok || !report.foreign_keys_enabled {
all_healthy = false;
Ok(conn) => match sqlite::collect_health_report(&conn, db_name) {
Ok(report) => {
println!("Database: {}", report.database);
println!(" Path: {:?}", db_path);
println!(" Schema version: {}", report.schema_version);
println!(" Journal mode: {}", report.journal_mode);
println!(
" Foreign keys: {}",
if report.foreign_keys_enabled {
"enabled"
} else {
"DISABLED"
}
}
Err(e) => {
println!("Database: {}", db_name);
println!(" Status: ERROR collecting health report: {}", e);
);
println!(
" Integrity: {}",
if report.integrity_ok { "ok" } else { "FAILED" }
);
if !report.integrity_ok || !report.foreign_keys_enabled {
all_healthy = false;
}
}
}
Err(e) => {
println!("Database: {}", db_name);
println!(" Status: ERROR collecting health report: {}", e);
all_healthy = false;
}
},
Err(e) => {
println!("Database: {}", db_name);
println!(" Status: FAILED to open: {}", e);
+5 -3
View File
@@ -1,14 +1,16 @@
use std::path::PathBuf;
use tracing::info;
use crate::config::Config;
use crate::db::Db;
use std::path::PathBuf;
use tracing::info;
pub async fn run(
data_dir: Option<String>,
dry_run: bool,
mut config: Config,
) -> Result<(), Box<dyn std::error::Error>> {
if let Some(d) = data_dir { config.data_dir = PathBuf::from(d); }
if let Some(d) = data_dir {
config.data_dir = PathBuf::from(d);
}
if dry_run {
info!("Dry run enabled: pending database migrations will be reported but not applied.");
+5 -5
View File
@@ -1,13 +1,13 @@
use clap::{Parser, Subcommand};
pub mod serve;
pub mod backup;
pub mod restore;
pub mod migrate;
pub mod stats;
pub mod validate;
pub mod create_admin;
pub mod doctor;
pub mod migrate;
pub mod restore;
pub mod serve;
pub mod stats;
pub mod validate;
#[derive(Parser)]
#[command(name = "bzod")]
+12 -7
View File
@@ -1,17 +1,19 @@
use std::path::PathBuf;
use crate::config::Config;
use flate2::read::GzDecoder;
use std::fs::File;
use std::io::{self, Write};
use tracing::{info, error};
use flate2::read::GzDecoder;
use std::path::PathBuf;
use tar::Archive;
use crate::config::Config;
use tracing::{error, info};
pub async fn run(
file: String,
data_dir: Option<String>,
mut config: Config,
) -> Result<(), Box<dyn std::error::Error>> {
if let Some(d) = data_dir { config.data_dir = PathBuf::from(d); }
if let Some(d) = data_dir {
config.data_dir = PathBuf::from(d);
}
let file_path = PathBuf::from(file);
if !file_path.exists() {
@@ -19,12 +21,15 @@ pub async fn run(
return Ok(());
}
info!("WARNING: Restoring will overwrite existing databases in {:?}", config.data_dir);
info!(
"WARNING: Restoring will overwrite existing databases in {:?}",
config.data_dir
);
print!("Are you sure you want to restore? (y/N): ");
let _ = io::stdout().flush();
let mut confirm = String::new();
let _ = io::stdin().read_line(&mut confirm);
if !confirm.trim().eq_ignore_ascii_case("y") {
info!("Restore cancelled.");
return Ok(());
+21 -10
View File
@@ -1,11 +1,11 @@
use crate::analytics::AnalyticsQueue;
use crate::config::Config;
use crate::db::Db;
use crate::state::AppState;
use crate::web::create_router;
use std::path::PathBuf;
use std::time::Instant;
use tracing::info;
use crate::config::Config;
use crate::db::Db;
use crate::analytics::AnalyticsQueue;
use crate::state::AppState;
use crate::web::create_router;
pub async fn run(
host: Option<String>,
@@ -13,16 +13,22 @@ pub async fn run(
data_dir: Option<String>,
mut config: Config,
) -> Result<(), Box<dyn std::error::Error>> {
if let Some(h) = host { config.host = h; }
if let Some(p) = port { config.port = p; }
if let Some(d) = data_dir { config.data_dir = PathBuf::from(d); }
if let Some(h) = host {
config.host = h;
}
if let Some(p) = port {
config.port = p;
}
if let Some(d) = data_dir {
config.data_dir = PathBuf::from(d);
}
info!("Starting BZOD server on {}:{}", config.host, config.port);
info!("Database directory: {:?}", config.data_dir);
// Init DBs
let db = Db::init(&config)?;
// Init Queue
let queue = AnalyticsQueue::new(db.clone(), 1000);
@@ -52,6 +58,11 @@ pub async fn run(
crate::jobs::backup::run_backup_scheduler(backup_db, backup_config).await;
});
let expiry_db = db.clone();
tokio::spawn(async move {
crate::jobs::run_expiry_checker(expiry_db).await;
});
let state = AppState {
admin_db: db.admin.clone(),
content_db: db.content.clone(),
@@ -67,7 +78,7 @@ pub async fn run(
let router = create_router(state);
let addr = format!("{}:{}", config.host, config.port);
let listener = tokio::net::TcpListener::bind(&addr).await?;
info!("Listening for requests on http://{}", addr);
axum::serve(listener, router).await?;
+16 -6
View File
@@ -1,23 +1,30 @@
use std::path::PathBuf;
use crate::config::Config;
use crate::db::Db;
use std::path::PathBuf;
pub async fn run(
data_dir: Option<String>,
mut config: Config,
) -> Result<(), Box<dyn std::error::Error>> {
if let Some(d) = data_dir { config.data_dir = PathBuf::from(d); }
if let Some(d) = data_dir {
config.data_dir = PathBuf::from(d);
}
let db = Db::init(&config)?;
println!("=== BZOD Database Stats ===");
println!("Storage Directory: {:?}", config.data_dir);
let files = vec!["admin.db", "content.db", "analytics.db", "system.db"];
for f in files {
let p = config.data_dir.join(f);
if p.exists() {
let sz = std::fs::metadata(&p)?.len();
println!(" File: {} - Size: {} bytes ({:.2} MB)", f, sz, sz as f64 / 1_048_576.0);
println!(
" File: {} - Size: {} bytes ({:.2} MB)",
f,
sz,
sz as f64 / 1_048_576.0
);
}
}
@@ -31,7 +38,10 @@ pub async fn run(
let conn = db.content.lock().unwrap();
crate::db::content::get_url_counts(&conn)?
};
println!("Shortened URLs: {} total ({} active / {} dead)", urls_total, urls_active, urls_dead);
println!(
"Shortened URLs: {} total ({} active / {} dead)",
urls_total, urls_active, urls_dead
);
let pages_count = {
let conn = db.content.lock().unwrap();
+9 -7
View File
@@ -1,24 +1,26 @@
use std::path::PathBuf;
use tracing::info;
use reqwest::Client;
use std::time::Duration;
use crate::config::Config;
use crate::db::Db;
use reqwest::Client;
use std::path::PathBuf;
use std::time::Duration;
use tracing::info;
pub async fn run(
data_dir: Option<String>,
mut config: Config,
) -> Result<(), Box<dyn std::error::Error>> {
if let Some(d) = data_dir { config.data_dir = PathBuf::from(d); }
if let Some(d) = data_dir {
config.data_dir = PathBuf::from(d);
}
let db = Db::init(&config)?;
info!("Running one-shot link validation...");
let client = Client::builder()
.timeout(Duration::from_secs(10))
.user_agent("bzod-cli-checker/0.1")
.build()?;
crate::jobs::perform_link_check(&db, &client).await?;
info!("Link validation complete.");
+86 -27
View File
@@ -1,7 +1,7 @@
use std::path::PathBuf;
use serde::Deserialize;
use std::env;
use std::fs;
use serde::Deserialize;
use std::path::PathBuf;
#[derive(Clone, Debug)]
pub struct Config {
@@ -18,6 +18,7 @@ pub struct Config {
pub backup_enabled: bool,
pub backup_interval_mins: u64,
pub backup_dir: PathBuf,
pub base_url: Option<String>,
}
#[derive(Deserialize, Default)]
@@ -33,6 +34,7 @@ struct TomlConfig {
link_check_interval_mins: Option<u64>,
aggregation_interval_mins: Option<u64>,
backup: Option<TomlBackupConfig>,
base_url: Option<String>,
}
#[derive(Deserialize, Default)]
@@ -50,7 +52,8 @@ impl Config {
let mut data_dir = PathBuf::from("./data");
let mut admin_username = "admin".to_string();
let mut bootstrap_password_sha256 = "".to_string();
let mut session_secret = "bzod-default-session-secret-change-me-in-production-please-do-it".to_string();
let mut session_secret =
"bzod-default-session-secret-change-me-in-production-please-do-it".to_string();
let mut cookie_secure = true;
let mut data_retention_days = None;
let mut link_check_interval_mins = 60u64;
@@ -58,6 +61,7 @@ impl Config {
let mut backup_enabled = false;
let mut backup_interval_mins = 1440u64; // Default: once per day
let mut backup_dir = PathBuf::from("./backups");
let mut base_url = None;
// 2. Load bzod.toml if it exists
let mut toml_path = "bzod.toml".to_string();
@@ -66,13 +70,27 @@ impl Config {
}
if let Ok(toml_content) = fs::read_to_string(&toml_path) {
if let Ok(toml_config) = toml::from_str::<TomlConfig>(&toml_content) {
if let Some(h) = toml_config.host { host = h; }
if let Some(p) = toml_config.port { port = p; }
if let Some(d) = toml_config.data_dir { data_dir = PathBuf::from(d); }
if let Some(u) = toml_config.admin_username { admin_username = u; }
if let Some(s) = toml_config.bootstrap_password_sha256 { bootstrap_password_sha256 = s; }
if let Some(sec) = toml_config.session_secret { session_secret = sec; }
if let Some(c) = toml_config.cookie_secure { cookie_secure = c; }
if let Some(h) = toml_config.host {
host = h;
}
if let Some(p) = toml_config.port {
port = p;
}
if let Some(d) = toml_config.data_dir {
data_dir = PathBuf::from(d);
}
if let Some(u) = toml_config.admin_username {
admin_username = u;
}
if let Some(s) = toml_config.bootstrap_password_sha256 {
bootstrap_password_sha256 = s;
}
if let Some(sec) = toml_config.session_secret {
session_secret = sec;
}
if let Some(c) = toml_config.cookie_secure {
cookie_secure = c;
}
if let Some(ret) = toml_config.data_retention_days {
if ret.eq_ignore_ascii_case("unlimited") {
data_retention_days = None;
@@ -80,12 +98,25 @@ impl Config {
data_retention_days = Some(parsed);
}
}
if let Some(lc) = toml_config.link_check_interval_mins { link_check_interval_mins = lc; }
if let Some(ag) = toml_config.aggregation_interval_mins { aggregation_interval_mins = ag; }
if let Some(lc) = toml_config.link_check_interval_mins {
link_check_interval_mins = lc;
}
if let Some(ag) = toml_config.aggregation_interval_mins {
aggregation_interval_mins = ag;
}
if let Some(b) = toml_config.backup {
if let Some(be) = b.enabled { backup_enabled = be; }
if let Some(bi) = b.interval_mins { backup_interval_mins = bi; }
if let Some(bo) = b.out_dir { backup_dir = PathBuf::from(bo); }
if let Some(be) = b.enabled {
backup_enabled = be;
}
if let Some(bi) = b.interval_mins {
backup_interval_mins = bi;
}
if let Some(bo) = b.out_dir {
backup_dir = PathBuf::from(bo);
}
}
if let Some(bu) = toml_config.base_url {
base_url = Some(bu);
}
}
}
@@ -97,16 +128,30 @@ impl Config {
}
// 4. Load from Environment Variables (taking highest precedence)
if let Ok(h) = env::var("HOST") { host = h; }
if let Ok(h) = env::var("HOST") {
host = h;
}
if let Ok(p_str) = env::var("PORT") {
if let Ok(p) = p_str.parse::<u16>() { port = p; }
if let Ok(p) = p_str.parse::<u16>() {
port = p;
}
}
if let Ok(d_str) = env::var("DATA_DIR") {
data_dir = PathBuf::from(d_str);
}
if let Ok(u) = env::var("ADMIN_USERNAME") {
admin_username = u;
}
if let Ok(s) = env::var("BOOTSTRAP_PASSWORD_SHA256") {
bootstrap_password_sha256 = s;
}
if let Ok(sec) = env::var("SESSION_SECRET") {
session_secret = sec;
}
if let Ok(d_str) = env::var("DATA_DIR") { data_dir = PathBuf::from(d_str); }
if let Ok(u) = env::var("ADMIN_USERNAME") { admin_username = u; }
if let Ok(s) = env::var("BOOTSTRAP_PASSWORD_SHA256") { bootstrap_password_sha256 = s; }
if let Ok(sec) = env::var("SESSION_SECRET") { session_secret = sec; }
if let Ok(c_str) = env::var("COOKIE_SECURE") {
if let Ok(c) = c_str.parse::<bool>() { cookie_secure = c; }
if let Ok(c) = c_str.parse::<bool>() {
cookie_secure = c;
}
}
if let Ok(ret_str) = env::var("DATA_RETENTION_DAYS") {
if ret_str.eq_ignore_ascii_case("unlimited") {
@@ -116,18 +161,31 @@ impl Config {
}
}
if let Ok(lc_str) = env::var("LINK_CHECK_INTERVAL_MINS") {
if let Ok(lc) = lc_str.parse::<u64>() { link_check_interval_mins = lc; }
if let Ok(lc) = lc_str.parse::<u64>() {
link_check_interval_mins = lc;
}
}
if let Ok(ag_str) = env::var("AGGREGATION_INTERVAL_MINS") {
if let Ok(ag) = ag_str.parse::<u64>() { aggregation_interval_mins = ag; }
if let Ok(ag) = ag_str.parse::<u64>() {
aggregation_interval_mins = ag;
}
}
if let Ok(be_str) = env::var("BACKUP_ENABLED") {
if let Ok(be) = be_str.parse::<bool>() { backup_enabled = be; }
if let Ok(be) = be_str.parse::<bool>() {
backup_enabled = be;
}
}
if let Ok(bi_str) = env::var("BACKUP_INTERVAL_MINS") {
if let Ok(bi) = bi_str.parse::<u64>() { backup_interval_mins = bi; }
if let Ok(bi) = bi_str.parse::<u64>() {
backup_interval_mins = bi;
}
}
if let Ok(bo_str) = env::var("BACKUP_DIR") {
backup_dir = PathBuf::from(bo_str);
}
if let Ok(bu) = env::var("BASE_URL") {
base_url = Some(bu);
}
if let Ok(bo_str) = env::var("BACKUP_DIR") { backup_dir = PathBuf::from(bo_str); }
Self {
host,
@@ -143,6 +201,7 @@ impl Config {
backup_enabled,
backup_interval_mins,
backup_dir,
base_url,
}
}
}
+40 -25
View File
@@ -1,17 +1,21 @@
use rusqlite::{Connection, params};
use uuid::Uuid;
use crate::models::{ApiKey, AuditLog, Session, User};
use chrono::Utc;
use crate::models::{User, Session, ApiKey, AuditLog};
use rusqlite::{params, Connection};
use uuid::Uuid;
pub fn create_user(conn: &Connection, username: &str, password_hash: &str) -> rusqlite::Result<User> {
pub fn create_user(
conn: &Connection,
username: &str,
password_hash: &str,
) -> rusqlite::Result<User> {
let id = Uuid::new_v4().to_string();
let created_at = Utc::now().to_rfc3339();
conn.execute(
"INSERT INTO users (id, username, password_hash, created_at) VALUES (?1, ?2, ?3, ?4);",
params![id, username, password_hash, created_at],
)?;
Ok(User {
id,
username: username.to_string(),
@@ -21,9 +25,11 @@ pub fn create_user(conn: &Connection, username: &str, password_hash: &str) -> ru
}
pub fn get_user_by_username(conn: &Connection, username: &str) -> rusqlite::Result<Option<User>> {
let mut stmt = conn.prepare("SELECT id, username, password_hash, created_at FROM users WHERE username = ?1;")?;
let mut stmt = conn.prepare(
"SELECT id, username, password_hash, created_at FROM users WHERE username = ?1;",
)?;
let mut rows = stmt.query(params![username])?;
if let Some(row) = rows.next()? {
Ok(Some(User {
id: row.get(0)?,
@@ -37,9 +43,10 @@ pub fn get_user_by_username(conn: &Connection, username: &str) -> rusqlite::Resu
}
pub fn get_user_by_id(conn: &Connection, id: &str) -> rusqlite::Result<Option<User>> {
let mut stmt = conn.prepare("SELECT id, username, password_hash, created_at FROM users WHERE id = ?1;")?;
let mut stmt =
conn.prepare("SELECT id, username, password_hash, created_at FROM users WHERE id = ?1;")?;
let mut rows = stmt.query(params![id])?;
if let Some(row) = rows.next()? {
Ok(Some(User {
id: row.get(0)?,
@@ -63,12 +70,12 @@ pub fn create_session(
expires_at_rfc3339: &str,
) -> rusqlite::Result<Session> {
let created_at = Utc::now().to_rfc3339();
conn.execute(
"INSERT INTO sessions (id, user_id, expires_at, created_at) VALUES (?1, ?2, ?3, ?4);",
params![session_id, user_id, expires_at_rfc3339, created_at],
)?;
Ok(Session {
id: session_id.to_string(),
user_id: user_id.to_string(),
@@ -78,9 +85,10 @@ pub fn create_session(
}
pub fn get_session(conn: &Connection, session_id: &str) -> rusqlite::Result<Option<Session>> {
let mut stmt = conn.prepare("SELECT id, user_id, expires_at, created_at FROM sessions WHERE id = ?1;")?;
let mut stmt =
conn.prepare("SELECT id, user_id, expires_at, created_at FROM sessions WHERE id = ?1;")?;
let mut rows = stmt.query(params![session_id])?;
if let Some(row) = rows.next()? {
Ok(Some(Session {
id: row.get(0)?,
@@ -112,12 +120,12 @@ pub fn create_api_key(
) -> rusqlite::Result<ApiKey> {
let id = Uuid::new_v4().to_string();
let created_at = Utc::now().to_rfc3339();
conn.execute(
"INSERT INTO api_keys (id, user_id, key_hash, name, created_at) VALUES (?1, ?2, ?3, ?4, ?5);",
params![id, user_id, key_hash, name, created_at],
)?;
Ok(ApiKey {
id,
user_id: user_id.to_string(),
@@ -133,7 +141,7 @@ pub fn get_api_key_by_hash(conn: &Connection, key_hash: &str) -> rusqlite::Resul
"SELECT id, user_id, key_hash, name, created_at, last_used_at FROM api_keys WHERE key_hash = ?1;"
)?;
let mut rows = stmt.query(params![key_hash])?;
if let Some(row) = rows.next()? {
Ok(Some(ApiKey {
id: row.get(0)?,
@@ -162,7 +170,7 @@ pub fn list_api_keys(conn: &Connection, user_id: &str) -> rusqlite::Result<Vec<A
last_used_at: row.get(5)?,
})
})?;
let mut keys = Vec::new();
for key in rows {
keys.push(key?);
@@ -177,7 +185,10 @@ pub fn delete_api_key(conn: &Connection, id: &str) -> rusqlite::Result<()> {
pub fn update_api_key_last_used(conn: &Connection, id: &str) -> rusqlite::Result<()> {
let now = Utc::now().to_rfc3339();
conn.execute("UPDATE api_keys SET last_used_at = ?1 WHERE id = ?2;", params![now, id])?;
conn.execute(
"UPDATE api_keys SET last_used_at = ?1 WHERE id = ?2;",
params![now, id],
)?;
Ok(())
}
@@ -192,13 +203,13 @@ pub fn write_audit_log(
) -> rusqlite::Result<AuditLog> {
let id = Uuid::new_v4().to_string();
let timestamp = Utc::now().to_rfc3339();
conn.execute(
"INSERT INTO audit_logs (id, timestamp, username, action, object_type, object_id, ip_address, user_agent)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8);",
params![id, timestamp, username, action, object_type, object_id, ip_address, user_agent],
)?;
Ok(AuditLog {
id,
timestamp,
@@ -211,10 +222,14 @@ pub fn write_audit_log(
})
}
pub fn list_audit_logs(conn: &Connection, limit: i64, offset: i64) -> rusqlite::Result<Vec<AuditLog>> {
pub fn list_audit_logs(
conn: &Connection,
limit: i64,
offset: i64,
) -> rusqlite::Result<Vec<AuditLog>> {
let mut stmt = conn.prepare(
"SELECT id, timestamp, username, action, object_type, object_id, ip_address, user_agent
FROM audit_logs ORDER BY timestamp DESC LIMIT ?1 OFFSET ?2;"
FROM audit_logs ORDER BY timestamp DESC LIMIT ?1 OFFSET ?2;",
)?;
let rows = stmt.query_map(params![limit, offset], |row| {
Ok(AuditLog {
@@ -228,7 +243,7 @@ pub fn list_audit_logs(conn: &Connection, limit: i64, offset: i64) -> rusqlite::
user_agent: row.get(7)?,
})
})?;
let mut logs = Vec::new();
for log in rows {
logs.push(log?);
@@ -247,7 +262,7 @@ pub fn set_config(conn: &Connection, key: &str, value: &str) -> rusqlite::Result
pub fn get_config(conn: &Connection, key: &str) -> rusqlite::Result<Option<String>> {
let mut stmt = conn.prepare("SELECT value FROM config WHERE key = ?1;")?;
let mut rows = stmt.query(params![key])?;
if let Some(row) = rows.next()? {
let val: String = row.get(0)?;
Ok(Some(val))
+167 -68
View File
@@ -1,11 +1,11 @@
use rusqlite::{Connection, params};
use std::collections::HashMap;
use crate::models::VisitRecord;
use rusqlite::{params, Connection};
use std::collections::HashMap;
// Custom User-Agent parser to avoid bloated dependencies
pub fn parse_ua(ua: &str) -> (String, String, String) {
let ua_lower = ua.to_lowercase();
let os = if ua_lower.contains("windows") {
"Windows".to_string()
} else if ua_lower.contains("macintosh") || ua_lower.contains("mac os x") {
@@ -18,7 +18,8 @@ pub fn parse_ua(ua: &str) -> (String, String, String) {
"Android".to_string()
} else if ua_lower.contains("linux") {
"Linux".to_string()
} else if ua_lower.contains("iphone") || ua_lower.contains("ipad") || ua_lower.contains("ipod") {
} else if ua_lower.contains("iphone") || ua_lower.contains("ipad") || ua_lower.contains("ipod")
{
"iOS".to_string()
} else {
"Other".to_string()
@@ -38,7 +39,11 @@ pub fn parse_ua(ua: &str) -> (String, String, String) {
"Other".to_string()
};
let device = if ua_lower.contains("mobile") || ua_lower.contains("android") || ua_lower.contains("iphone") || ua_lower.contains("ipod") {
let device = if ua_lower.contains("mobile")
|| ua_lower.contains("android")
|| ua_lower.contains("iphone")
|| ua_lower.contains("ipod")
{
"Mobile".to_string()
} else if ua_lower.contains("ipad") || ua_lower.contains("tablet") {
"Tablet".to_string()
@@ -54,15 +59,17 @@ pub fn clean_referrer(referer: &str) -> String {
if referer.is_empty() || referer == "direct" {
return "Direct".to_string();
}
if let Ok(url) = reqwest::Url::parse(referer) {
if let Some(host) = url.host_str() {
return host.trim_start_matches("www.").to_string();
}
}
// Fallback if not a valid URL
let cleaned = referer.trim_start_matches("https://").trim_start_matches("http://");
let cleaned = referer
.trim_start_matches("https://")
.trim_start_matches("http://");
let cleaned = cleaned.split('/').next().unwrap_or("Direct");
if cleaned.is_empty() {
"Direct".to_string()
@@ -78,7 +85,7 @@ pub fn insert_visits_batch(conn: &mut Connection, records: &[VisitRecord]) -> ru
"INSERT INTO visits (id, target_type, target_id, timestamp, ip_address, user_agent, referer, accept_language, country, status_code)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10);"
)?;
for r in records {
stmt.execute(params![
r.id,
@@ -99,16 +106,25 @@ pub fn insert_visits_batch(conn: &mut Connection, records: &[VisitRecord]) -> ru
}
pub fn get_total_clicks(conn: &Connection) -> rusqlite::Result<i64> {
conn.query_row("SELECT COUNT(*) FROM visits WHERE target_type = 'url';", [], |row| row.get(0))
conn.query_row(
"SELECT COUNT(*) FROM visits WHERE target_type = 'url';",
[],
|row| row.get(0),
)
}
pub fn get_total_page_views(conn: &Connection) -> rusqlite::Result<i64> {
conn.query_row("SELECT COUNT(*) FROM visits WHERE target_type = 'page';", [], |row| row.get(0))
conn.query_row(
"SELECT COUNT(*) FROM visits WHERE target_type = 'page';",
[],
|row| row.get(0),
)
}
// Get the date range of visits in the DB
pub fn get_visits_date_range(conn: &Connection) -> rusqlite::Result<Option<(String, String)>> {
let mut stmt = conn.prepare("SELECT MIN(date(timestamp)), MAX(date(timestamp)) FROM visits;")?;
let mut stmt =
conn.prepare("SELECT MIN(date(timestamp)), MAX(date(timestamp)) FROM visits;")?;
let mut rows = stmt.query([])?;
if let Some(row) = rows.next()? {
let min_date: Option<String> = row.get(0)?;
@@ -128,7 +144,7 @@ pub fn aggregate_day(conn: &mut Connection, date: &str) -> rusqlite::Result<()>
let mut stmt = conn.prepare(
"SELECT target_type, target_id, user_agent, referer, country, status_code FROM visits WHERE date(timestamp) = ?1;"
)?;
struct RawVisit {
target_type: String,
target_id: String,
@@ -136,7 +152,7 @@ pub fn aggregate_day(conn: &mut Connection, date: &str) -> rusqlite::Result<()>
referer: String,
country: String,
}
let rows = stmt.query_map(params![date], |row| {
Ok(RawVisit {
target_type: row.get(0)?,
@@ -146,7 +162,7 @@ pub fn aggregate_day(conn: &mut Connection, date: &str) -> rusqlite::Result<()>
country: row.get(4)?,
})
})?;
for r in rows {
visits.push(r?);
}
@@ -156,7 +172,6 @@ pub fn aggregate_day(conn: &mut Connection, date: &str) -> rusqlite::Result<()>
return Ok(());
}
// 2. Compute metrics in-memory
// Key structure: (target_type, target_id, metric_type, metric_key) -> count
let mut aggregates: HashMap<(String, String, String, String), i64> = HashMap::new();
@@ -165,7 +180,11 @@ pub fn aggregate_day(conn: &mut Connection, date: &str) -> rusqlite::Result<()>
for v in visits {
let (browser, os, device) = parse_ua(&v.user_agent);
let referrer = clean_referrer(&v.referer);
let country = if v.country.is_empty() { "Unknown".to_string() } else { v.country.clone() };
let country = if v.country.is_empty() {
"Unknown".to_string()
} else {
v.country.clone()
};
let targets = vec![
(v.target_type.clone(), v.target_id.clone()),
@@ -174,22 +193,59 @@ pub fn aggregate_day(conn: &mut Connection, date: &str) -> rusqlite::Result<()>
for (t_type, t_id) in targets {
// Clicks
*aggregates.entry((t_type.clone(), t_id.clone(), "clicks".to_string(), "".to_string())).or_insert(0) += 1;
*aggregates
.entry((
t_type.clone(),
t_id.clone(),
"clicks".to_string(),
"".to_string(),
))
.or_insert(0) += 1;
// Country
*aggregates.entry((t_type.clone(), t_id.clone(), "country".to_string(), country.clone())).or_insert(0) += 1;
*aggregates
.entry((
t_type.clone(),
t_id.clone(),
"country".to_string(),
country.clone(),
))
.or_insert(0) += 1;
// Browser
*aggregates.entry((t_type.clone(), t_id.clone(), "browser".to_string(), browser.clone())).or_insert(0) += 1;
*aggregates
.entry((
t_type.clone(),
t_id.clone(),
"browser".to_string(),
browser.clone(),
))
.or_insert(0) += 1;
// OS
*aggregates.entry((t_type.clone(), t_id.clone(), "os".to_string(), os.clone())).or_insert(0) += 1;
*aggregates
.entry((t_type.clone(), t_id.clone(), "os".to_string(), os.clone()))
.or_insert(0) += 1;
// Device
*aggregates.entry((t_type.clone(), t_id.clone(), "device".to_string(), device.clone())).or_insert(0) += 1;
*aggregates
.entry((
t_type.clone(),
t_id.clone(),
"device".to_string(),
device.clone(),
))
.or_insert(0) += 1;
// Referrer
*aggregates.entry((t_type.clone(), t_id.clone(), "referrer".to_string(), referrer.clone())).or_insert(0) += 1;
*aggregates
.entry((
t_type.clone(),
t_id.clone(),
"referrer".to_string(),
referrer.clone(),
))
.or_insert(0) += 1;
}
}
@@ -197,7 +253,10 @@ pub fn aggregate_day(conn: &mut Connection, date: &str) -> rusqlite::Result<()>
let tx = conn.transaction()?;
{
// Delete old aggregates for this day
tx.execute("DELETE FROM daily_summaries WHERE date = ?1;", params![date])?;
tx.execute(
"DELETE FROM daily_summaries WHERE date = ?1;",
params![date],
)?;
let mut insert_stmt = tx.prepare(
"INSERT INTO daily_summaries (date, target_type, target_id, metric_type, metric_key, metric_value)
@@ -205,14 +264,7 @@ pub fn aggregate_day(conn: &mut Connection, date: &str) -> rusqlite::Result<()>
)?;
for ((t_type, t_id, m_type, m_key), value) in aggregates {
insert_stmt.execute(params![
date,
t_type,
t_id,
m_type,
m_key,
value
])?;
insert_stmt.execute(params![date, t_type, t_id, m_type, m_key, value])?;
}
}
tx.commit()?;
@@ -227,7 +279,10 @@ pub fn aggregate_day(conn: &mut Connection, date: &str) -> rusqlite::Result<()>
fn aggregate_month_from_daily(conn: &mut Connection, year_month: &str) -> rusqlite::Result<()> {
let tx = conn.transaction()?;
{
tx.execute("DELETE FROM monthly_summaries WHERE year_month = ?1;", params![year_month])?;
tx.execute(
"DELETE FROM monthly_summaries WHERE year_month = ?1;",
params![year_month],
)?;
tx.execute(
"INSERT INTO monthly_summaries (year_month, target_type, target_id, metric_type, metric_key, metric_value)
SELECT ?1, target_type, target_id, metric_type, metric_key, SUM(metric_value)
@@ -244,7 +299,10 @@ fn aggregate_month_from_daily(conn: &mut Connection, year_month: &str) -> rusqli
fn aggregate_year_from_daily(conn: &mut Connection, year: &str) -> rusqlite::Result<()> {
let tx = conn.transaction()?;
{
tx.execute("DELETE FROM yearly_summaries WHERE year = ?1;", params![year])?;
tx.execute(
"DELETE FROM yearly_summaries WHERE year = ?1;",
params![year],
)?;
tx.execute(
"INSERT INTO yearly_summaries (year, target_type, target_id, metric_type, metric_key, metric_value)
SELECT ?1, target_type, target_id, metric_type, metric_key, SUM(metric_value)
@@ -262,7 +320,10 @@ fn aggregate_year_from_daily(conn: &mut Connection, year: &str) -> rusqlite::Res
pub fn retention_cleanup(conn: &Connection, retention_days: i64) -> rusqlite::Result<usize> {
let limit_date = chrono::Utc::now() - chrono::Duration::days(retention_days);
let limit_str = limit_date.to_rfc3339();
let count = conn.execute("DELETE FROM visits WHERE timestamp < ?1;", params![limit_str])?;
let count = conn.execute(
"DELETE FROM visits WHERE timestamp < ?1;",
params![limit_str],
)?;
Ok(count)
}
@@ -274,18 +335,20 @@ pub fn get_clicks_trend(
target_id: &str,
limit_days: i64,
) -> rusqlite::Result<Vec<(String, i64)>> {
let limit_date = (chrono::Utc::now() - chrono::Duration::days(limit_days)).format("%Y-%m-%d").to_string();
let limit_date = (chrono::Utc::now() - chrono::Duration::days(limit_days))
.format("%Y-%m-%d")
.to_string();
let mut stmt = conn.prepare(
"SELECT date, SUM(metric_value) FROM daily_summaries
WHERE target_type = ?1 AND target_id = ?2 AND metric_type = 'clicks' AND date >= ?3
GROUP BY date ORDER BY date ASC;"
GROUP BY date ORDER BY date ASC;",
)?;
let rows = stmt.query_map(params![target_type, target_id, limit_date], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
})?;
let mut res = Vec::new();
for r in rows {
res.push(r?);
@@ -301,17 +364,17 @@ pub fn get_clicks_trend_raw(
limit_days: i64,
) -> rusqlite::Result<Vec<(String, i64)>> {
let limit_date = (chrono::Utc::now() - chrono::Duration::days(limit_days)).to_rfc3339();
let mut stmt = conn.prepare(
"SELECT date(timestamp) as d, COUNT(*) FROM visits
WHERE target_type = ?1 AND target_id = ?2 AND timestamp >= ?3
GROUP BY d ORDER BY d ASC;"
GROUP BY d ORDER BY d ASC;",
)?;
let rows = stmt.query_map(params![target_type, target_id, limit_date], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
})?;
let mut res = Vec::new();
for r in rows {
res.push(r?);
@@ -326,18 +389,18 @@ pub fn get_hourly_trend_raw(
limit_days: i64,
) -> rusqlite::Result<Vec<(String, i64)>> {
let limit_date = (chrono::Utc::now() - chrono::Duration::days(limit_days)).to_rfc3339();
// SQLite strftime('%H', timestamp) extracts the hour
let mut stmt = conn.prepare(
"SELECT strftime('%H', timestamp) as h, COUNT(*) FROM visits
WHERE target_type = ?1 AND target_id = ?2 AND timestamp >= ?3
GROUP BY h ORDER BY h ASC;"
GROUP BY h ORDER BY h ASC;",
)?;
let rows = stmt.query_map(params![target_type, target_id, limit_date], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
})?;
let mut res = Vec::new();
for r in rows {
res.push(r?);
@@ -355,13 +418,13 @@ pub fn get_metric_rankings(
let mut stmt = conn.prepare(
"SELECT metric_key, SUM(metric_value) as val FROM daily_summaries
WHERE target_type = ?1 AND target_id = ?2 AND metric_type = ?3
GROUP BY metric_key ORDER BY val DESC LIMIT ?4;"
GROUP BY metric_key ORDER BY val DESC LIMIT ?4;",
)?;
let rows = stmt.query_map(params![target_type, target_id, metric_type, limit], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
})?;
let mut res = Vec::new();
for r in rows {
res.push(r?);
@@ -378,30 +441,32 @@ pub fn get_metric_rankings_raw(
) -> rusqlite::Result<Vec<(String, i64)>> {
// Falls back to direct query on visits
let mut res = Vec::new();
match metric_type {
"country" => {
let mut stmt = conn.prepare(
"SELECT country, COUNT(*) as c FROM visits
WHERE target_type = ?1 AND target_id = ?2
GROUP BY country ORDER BY c DESC LIMIT ?3;"
GROUP BY country ORDER BY c DESC LIMIT ?3;",
)?;
let rows = stmt.query_map(params![target_type, target_id, limit], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
})?;
for r in rows { res.push(r?); }
for r in rows {
res.push(r?);
}
}
"referrer" => {
let mut stmt = conn.prepare(
"SELECT referer, COUNT(*) as c FROM visits
WHERE target_type = ?1 AND target_id = ?2
GROUP BY referer ORDER BY c DESC LIMIT ?3;"
GROUP BY referer ORDER BY c DESC LIMIT ?3;",
)?;
let rows = stmt.query_map(params![target_type, target_id, limit], |row| {
let raw_ref: String = row.get(0)?;
Ok((clean_referrer(&raw_ref), row.get::<_, i64>(1)?))
})?;
// Re-aggregate because clean_referrer might group different referrers
let mut grouped: HashMap<String, i64> = HashMap::new();
for r in rows {
@@ -416,12 +481,12 @@ pub fn get_metric_rankings_raw(
let mut stmt = conn.prepare(
"SELECT user_agent, COUNT(*) as c FROM visits
WHERE target_type = ?1 AND target_id = ?2
GROUP BY user_agent;"
GROUP BY user_agent;",
)?;
let rows = stmt.query_map(params![target_type, target_id], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
})?;
let mut grouped: HashMap<String, i64> = HashMap::new();
for r in rows {
let (ua, count) = r?;
@@ -439,7 +504,7 @@ pub fn get_metric_rankings_raw(
}
_ => {}
}
Ok(res)
}
@@ -449,24 +514,58 @@ mod tests {
#[test]
fn test_parse_ua_browsers() {
let firefox_linux = "Mozilla/5.0 (X11; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/119.0";
let firefox_linux =
"Mozilla/5.0 (X11; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/119.0";
let chrome_win = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";
let safari_mac = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1 Safari/605.1.15";
let android_phone = "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Mobile Safari/537.36";
assert_eq!(parse_ua(firefox_linux), ("Firefox".to_string(), "Linux".to_string(), "Desktop".to_string()));
assert_eq!(parse_ua(chrome_win), ("Chrome".to_string(), "Windows".to_string(), "Desktop".to_string()));
assert_eq!(parse_ua(safari_mac), ("Safari".to_string(), "macOS".to_string(), "Desktop".to_string()));
assert_eq!(parse_ua(android_phone), ("Chrome".to_string(), "Android".to_string(), "Mobile".to_string()));
assert_eq!(
parse_ua(firefox_linux),
(
"Firefox".to_string(),
"Linux".to_string(),
"Desktop".to_string()
)
);
assert_eq!(
parse_ua(chrome_win),
(
"Chrome".to_string(),
"Windows".to_string(),
"Desktop".to_string()
)
);
assert_eq!(
parse_ua(safari_mac),
(
"Safari".to_string(),
"macOS".to_string(),
"Desktop".to_string()
)
);
assert_eq!(
parse_ua(android_phone),
(
"Chrome".to_string(),
"Android".to_string(),
"Mobile".to_string()
)
);
}
#[test]
fn test_clean_referrer() {
assert_eq!(clean_referrer("direct"), "Direct");
assert_eq!(clean_referrer(""), "Direct");
assert_eq!(clean_referrer("https://github.com/rust-lang/rust"), "github.com");
assert_eq!(clean_referrer("http://www.google.com/search?q=rust"), "google.com");
assert_eq!(
clean_referrer("https://github.com/rust-lang/rust"),
"github.com"
);
assert_eq!(
clean_referrer("http://www.google.com/search?q=rust"),
"google.com"
);
assert_eq!(clean_referrer("reddit.com/r/rust"), "reddit.com");
}
}
+74
View File
@@ -0,0 +1,74 @@
use crate::models::AuditEvent;
use chrono::Utc;
use rusqlite::{params, Connection};
use uuid::Uuid;
/// Write an audit event to the system.db audit_events table.
pub fn write_audit_event(
conn: &Connection,
actor: &str,
action: &str,
object_type: &str,
object_id: &str,
metadata: Option<&str>,
) -> rusqlite::Result<()> {
let id = Uuid::new_v4().to_string();
let now = Utc::now().to_rfc3339();
conn.execute(
"INSERT INTO audit_events (id, actor, action, object_type, object_id, timestamp, metadata)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7);",
params![id, actor, action, object_type, object_id, now, metadata],
)?;
Ok(())
}
/// List audit events with optional filtering by actor or action.
pub fn list_audit_events(
conn: &Connection,
limit: i64,
offset: i64,
actor_filter: Option<&str>,
action_filter: Option<&str>,
) -> rusqlite::Result<Vec<AuditEvent>> {
let mut events = Vec::new();
let (sql, params_vec): (String, Vec<Box<dyn rusqlite::types::ToSql>>) = match (actor_filter, action_filter) {
(Some(actor), Some(action)) => (
"SELECT id, actor, action, object_type, object_id, timestamp, metadata FROM audit_events WHERE actor = ?1 AND action = ?2 ORDER BY timestamp DESC LIMIT ?3 OFFSET ?4;".to_string(),
vec![Box::new(actor.to_string()), Box::new(action.to_string()), Box::new(limit), Box::new(offset)],
),
(Some(actor), None) => (
"SELECT id, actor, action, object_type, object_id, timestamp, metadata FROM audit_events WHERE actor = ?1 ORDER BY timestamp DESC LIMIT ?2 OFFSET ?3;".to_string(),
vec![Box::new(actor.to_string()), Box::new(limit), Box::new(offset)],
),
(None, Some(action)) => (
"SELECT id, actor, action, object_type, object_id, timestamp, metadata FROM audit_events WHERE action = ?1 ORDER BY timestamp DESC LIMIT ?2 OFFSET ?3;".to_string(),
vec![Box::new(action.to_string()), Box::new(limit), Box::new(offset)],
),
(None, None) => (
"SELECT id, actor, action, object_type, object_id, timestamp, metadata FROM audit_events ORDER BY timestamp DESC LIMIT ?1 OFFSET ?2;".to_string(),
vec![Box::new(limit), Box::new(offset)],
),
};
let params_refs: Vec<&dyn rusqlite::types::ToSql> =
params_vec.iter().map(|p| p.as_ref()).collect();
let mut stmt = conn.prepare(&sql)?;
let rows = stmt.query_map(params_refs.as_slice(), |row| {
Ok(AuditEvent {
id: row.get(0)?,
actor: row.get(1)?,
action: row.get(2)?,
object_type: row.get(3)?,
object_id: row.get(4)?,
timestamp: row.get(5)?,
metadata: row.get(6)?,
})
})?;
for event in rows {
events.push(event?);
}
Ok(events)
}
+248 -94
View File
@@ -1,7 +1,7 @@
use rusqlite::{Connection, params};
use uuid::Uuid;
use crate::models::Url;
use chrono::Utc;
use crate::models::{Url, LandingPage};
use rusqlite::{params, Connection};
use uuid::Uuid;
// Helper: Associate tags with a URL
fn associate_tags(conn: &Connection, url_id: &str, tags: &[String]) -> rusqlite::Result<()> {
@@ -11,20 +11,20 @@ fn associate_tags(conn: &Connection, url_id: &str, tags: &[String]) -> rusqlite:
if tag_name.is_empty() {
continue;
}
// Insert tag if it doesn't exist
conn.execute(
"INSERT OR IGNORE INTO tags (id, name) VALUES (?1, ?2);",
params![Uuid::new_v4().to_string(), tag_name],
)?;
// Get tag id
let tag_id: String = conn.query_row(
"SELECT id FROM tags WHERE name = ?1;",
params![tag_name],
|row| row.get(0),
)?;
// Insert association
conn.execute(
"INSERT OR IGNORE INTO url_tags (url_id, tag_id) VALUES (?1, ?2);",
@@ -47,6 +47,31 @@ pub fn get_tags_for_url(conn: &Connection, url_id: &str) -> rusqlite::Result<Vec
Ok(tags)
}
/// The full column list used in all URL SELECT queries.
const URL_COLUMNS: &str = "id, code, destination, title, description, status, created_at, updated_at, expires_at, expired, password_hash, last_status, last_latency_ms, max_access_count, access_count";
/// Build a Url struct from a row containing URL_COLUMNS in order.
fn url_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<Url> {
Ok(Url {
id: row.get(0)?,
code: row.get(1)?,
destination: row.get(2)?,
title: row.get(3)?,
description: row.get(4)?,
status: row.get(5)?,
created_at: row.get(6)?,
updated_at: row.get(7)?,
expires_at: row.get(8)?,
expired: row.get::<_, i32>(9).unwrap_or(0) != 0,
password_hash: row.get(10)?,
last_status: row.get(11)?,
last_latency_ms: row.get(12)?,
max_access_count: row.get(13)?,
access_count: row.get::<_, i64>(14).unwrap_or(0),
tags: Vec::new(), // filled after query
})
}
pub fn create_url(
conn: &Connection,
code: &str,
@@ -77,54 +102,84 @@ pub fn create_url(
created_at: now.clone(),
updated_at: now,
tags: tags.to_vec(),
expires_at: None,
expired: false,
password_hash: None,
last_status: None,
last_latency_ms: None,
max_access_count: None,
access_count: 0,
})
}
/// Create a URL with all extended options.
#[allow(clippy::too_many_arguments)]
pub fn create_url_extended(
conn: &Connection,
code: &str,
destination: &str,
title: Option<&str>,
description: Option<&str>,
tags: &[String],
expires_at: Option<&str>,
password_hash: Option<&str>,
max_access_count: Option<i64>,
) -> rusqlite::Result<Url> {
let id = Uuid::new_v4().to_string();
let now = Utc::now().to_rfc3339();
let status = "healthy".to_string();
conn.execute(
"INSERT INTO urls (id, code, destination, title, description, status, created_at, updated_at, expires_at, password_hash, max_access_count)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11);",
params![id, code, destination, title, description, status, now, now, expires_at, password_hash, max_access_count],
)?;
associate_tags(conn, &id, tags)?;
Ok(Url {
id,
code: code.to_string(),
destination: destination.to_string(),
title: title.map(|s| s.to_string()),
description: description.map(|s| s.to_string()),
status,
created_at: now.clone(),
updated_at: now,
tags: tags.to_vec(),
expires_at: expires_at.map(|s| s.to_string()),
expired: false,
password_hash: password_hash.map(|s| s.to_string()),
last_status: None,
last_latency_ms: None,
max_access_count,
access_count: 0,
})
}
pub fn get_url_by_id(conn: &Connection, id: &str) -> rusqlite::Result<Option<Url>> {
let mut stmt = conn.prepare(
"SELECT id, code, destination, title, description, status, created_at, updated_at FROM urls WHERE id = ?1;"
)?;
let sql = format!("SELECT {} FROM urls WHERE id = ?1;", URL_COLUMNS);
let mut stmt = conn.prepare(&sql)?;
let mut rows = stmt.query(params![id])?;
if let Some(row) = rows.next()? {
let url_id: String = row.get(0)?;
let tags = get_tags_for_url(conn, &url_id)?;
Ok(Some(Url {
id: url_id,
code: row.get(1)?,
destination: row.get(2)?,
title: row.get(3)?,
description: row.get(4)?,
status: row.get(5)?,
created_at: row.get(6)?,
updated_at: row.get(7)?,
tags,
}))
let mut url = url_from_row(row)?;
url.tags = get_tags_for_url(conn, &url.id)?;
Ok(Some(url))
} else {
Ok(None)
}
}
pub fn get_url_by_code(conn: &Connection, code: &str) -> rusqlite::Result<Option<Url>> {
let mut stmt = conn.prepare(
"SELECT id, code, destination, title, description, status, created_at, updated_at FROM urls WHERE code = ?1;"
)?;
let sql = format!("SELECT {} FROM urls WHERE code = ?1;", URL_COLUMNS);
let mut stmt = conn.prepare(&sql)?;
let mut rows = stmt.query(params![code])?;
if let Some(row) = rows.next()? {
let url_id: String = row.get(0)?;
let tags = get_tags_for_url(conn, &url_id)?;
Ok(Some(Url {
id: url_id,
code: row.get(1)?,
destination: row.get(2)?,
title: row.get(3)?,
description: row.get(4)?,
status: row.get(5)?,
created_at: row.get(6)?,
updated_at: row.get(7)?,
tags,
}))
let mut url = url_from_row(row)?;
url.tags = get_tags_for_url(conn, &url.id)?;
Ok(Some(url))
} else {
Ok(None)
}
@@ -167,69 +222,60 @@ pub fn list_urls(
tag_filter: Option<&str>,
) -> rusqlite::Result<Vec<Url>> {
let mut urls = Vec::new();
if let Some(tag) = tag_filter {
let tag_name = tag.trim().to_lowercase();
let mut stmt = conn.prepare(
"SELECT u.id, u.code, u.destination, u.title, u.description, u.status, u.created_at, u.updated_at
FROM urls u
JOIN url_tags ut ON u.id = ut.url_id
JOIN tags t ON ut.tag_id = t.id
let sql = format!(
"SELECT u.{} FROM urls u
JOIN url_tags ut ON u.id = ut.url_id
JOIN tags t ON ut.tag_id = t.id
WHERE t.name = ?1
ORDER BY u.created_at DESC LIMIT ?2 OFFSET ?3;"
)?;
let rows = stmt.query_map(params![tag_name, limit, offset], |row| {
let url_id: String = row.get(0)?;
Ok((url_id, row.get(1)?, row.get(2)?, row.get(3)?, row.get(4)?, row.get(5)?, row.get(6)?, row.get(7)?))
})?;
ORDER BY u.created_at DESC LIMIT ?2 OFFSET ?3;",
URL_COLUMNS
.replace("id,", "u.id,")
.replace(", code", ", u.code")
.replace(", destination", ", u.destination")
.replace(", title", ", u.title")
.replace(", description", ", u.description")
.replace(", status", ", u.status")
.replace(", created_at", ", u.created_at")
.replace(", updated_at", ", u.updated_at")
.replace(", expires_at", ", u.expires_at")
.replace(", expired", ", u.expired")
.replace(", password_hash", ", u.password_hash")
.replace(", last_status", ", u.last_status")
.replace(", last_latency_ms", ", u.last_latency_ms")
.replace(", max_access_count", ", u.max_access_count")
.replace(", access_count", ", u.access_count")
);
let mut stmt = conn.prepare(&sql)?;
let rows = stmt.query_map(params![tag_name, limit, offset], url_from_row)?;
for r in rows {
let (url_id, code, destination, title, description, status, created_at, updated_at) = r?;
let tags = get_tags_for_url(conn, &url_id)?;
urls.push(Url {
id: url_id,
code,
destination,
title,
description,
status,
created_at,
updated_at,
tags,
});
let mut url = r?;
url.tags = get_tags_for_url(conn, &url.id)?;
urls.push(url);
}
} else {
let mut stmt = conn.prepare(
"SELECT id, code, destination, title, description, status, created_at, updated_at
FROM urls ORDER BY created_at DESC LIMIT ?1 OFFSET ?2;"
)?;
let rows = stmt.query_map(params![limit, offset], |row| {
let url_id: String = row.get(0)?;
Ok((url_id, row.get(1)?, row.get(2)?, row.get(3)?, row.get(4)?, row.get(5)?, row.get(6)?, row.get(7)?))
})?;
let sql = format!(
"SELECT {} FROM urls ORDER BY created_at DESC LIMIT ?1 OFFSET ?2;",
URL_COLUMNS
);
let mut stmt = conn.prepare(&sql)?;
let rows = stmt.query_map(params![limit, offset], url_from_row)?;
for r in rows {
let (url_id, code, destination, title, description, status, created_at, updated_at) = r?;
let tags = get_tags_for_url(conn, &url_id)?;
urls.push(Url {
id: url_id,
code,
destination,
title,
description,
status,
created_at,
updated_at,
tags,
});
let mut url = r?;
url.tags = get_tags_for_url(conn, &url.id)?;
urls.push(url);
}
}
Ok(urls)
}
pub fn list_urls_for_health_check(conn: &Connection) -> rusqlite::Result<Vec<(String, String)>> {
let mut stmt = conn.prepare("SELECT id, destination FROM urls;")?;
let mut stmt = conn.prepare("SELECT id, destination FROM urls WHERE expired = 0;")?;
let rows = stmt.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?;
let mut res = Vec::new();
for r in rows {
@@ -247,13 +293,111 @@ pub fn update_url_health(conn: &Connection, id: &str, status: &str) -> rusqlite:
Ok(())
}
/// Update URL health with extended status and latency information.
pub fn update_url_health_extended(
conn: &Connection,
id: &str,
status: &str,
last_status: &str,
latency_ms: Option<i64>,
) -> rusqlite::Result<()> {
let now = Utc::now().to_rfc3339();
conn.execute(
"UPDATE urls SET status = ?1, last_status = ?2, last_latency_ms = ?3, updated_at = ?4 WHERE id = ?5;",
params![status, last_status, latency_ms, now, id],
)?;
Ok(())
}
pub fn get_url_counts(conn: &Connection) -> rusqlite::Result<(i64, i64, i64)> {
let total: i64 = conn.query_row("SELECT COUNT(*) FROM urls;", [], |row| row.get(0))?;
let active: i64 = conn.query_row("SELECT COUNT(*) FROM urls WHERE status IN ('healthy', 'suspect');", [], |row| row.get(0))?;
let dead: i64 = conn.query_row("SELECT COUNT(*) FROM urls WHERE status = 'dead';", [], |row| row.get(0))?;
let active: i64 = conn.query_row(
"SELECT COUNT(*) FROM urls WHERE status IN ('healthy', 'suspect') AND expired = 0;",
[],
|row| row.get(0),
)?;
let dead: i64 = conn.query_row(
"SELECT COUNT(*) FROM urls WHERE status = 'dead' OR expired = 1;",
[],
|row| row.get(0),
)?;
Ok((total, active, dead))
}
/// Mark all URLs with expires_at < now as expired.
pub fn expire_urls(conn: &Connection) -> rusqlite::Result<usize> {
let now = Utc::now().to_rfc3339();
let count = conn.execute(
"UPDATE urls SET expired = 1, updated_at = ?1 WHERE expires_at IS NOT NULL AND expires_at < ?1 AND expired = 0;",
params![now],
)?;
Ok(count)
}
/// Set a password hash on a URL.
pub fn set_url_password(
conn: &Connection,
id: &str,
password_hash: &str,
) -> rusqlite::Result<bool> {
let now = Utc::now().to_rfc3339();
let count = conn.execute(
"UPDATE urls SET password_hash = ?1, updated_at = ?2 WHERE id = ?3;",
params![password_hash, now, id],
)?;
Ok(count > 0)
}
/// Remove the password from a URL.
pub fn remove_url_password(conn: &Connection, id: &str) -> rusqlite::Result<bool> {
let now = Utc::now().to_rfc3339();
let count = conn.execute(
"UPDATE urls SET password_hash = NULL, updated_at = ?1 WHERE id = ?2;",
params![now, id],
)?;
Ok(count > 0)
}
/// Atomically increment the access count and return the new value.
pub fn increment_access_count(conn: &Connection, id: &str) -> rusqlite::Result<i64> {
conn.execute(
"UPDATE urls SET access_count = access_count + 1 WHERE id = ?1;",
params![id],
)?;
conn.query_row(
"SELECT access_count FROM urls WHERE id = ?1;",
params![id],
|row| row.get(0),
)
}
/// Set expiry on a URL.
pub fn set_url_expiry(conn: &Connection, id: &str, expires_at: &str) -> rusqlite::Result<bool> {
let now = Utc::now().to_rfc3339();
let count = conn.execute(
"UPDATE urls SET expires_at = ?1, updated_at = ?2 WHERE id = ?3;",
params![expires_at, now, id],
)?;
Ok(count > 0)
}
/// Get health status summary across all URLs.
pub fn get_health_summary(conn: &Connection) -> rusqlite::Result<Vec<(String, i64)>> {
let mut stmt = conn.prepare(
"SELECT COALESCE(last_status, status) AS health, COUNT(*) FROM urls GROUP BY health ORDER BY COUNT(*) DESC;"
)?;
let rows = stmt.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?;
let mut res = Vec::new();
for r in rows {
res.push(r?);
}
Ok(res)
}
// --- Landing Page Operations (unchanged) ---
use crate::models::LandingPage;
pub fn create_landing_page(
conn: &Connection,
code: &str,
@@ -283,7 +427,10 @@ pub fn create_landing_page(
})
}
pub fn get_landing_page_by_id(conn: &Connection, id: &str) -> rusqlite::Result<Option<LandingPage>> {
pub fn get_landing_page_by_id(
conn: &Connection,
id: &str,
) -> rusqlite::Result<Option<LandingPage>> {
let mut stmt = conn.prepare(
"SELECT id, code, slug, title, html_content, state, created_at, updated_at FROM landing_pages WHERE id = ?1;"
)?;
@@ -305,7 +452,10 @@ pub fn get_landing_page_by_id(conn: &Connection, id: &str) -> rusqlite::Result<O
}
}
pub fn get_landing_page_by_code(conn: &Connection, code: &str) -> rusqlite::Result<Option<LandingPage>> {
pub fn get_landing_page_by_code(
conn: &Connection,
code: &str,
) -> rusqlite::Result<Option<LandingPage>> {
let mut stmt = conn.prepare(
"SELECT id, code, slug, title, html_content, state, created_at, updated_at FROM landing_pages WHERE code = ?1;"
)?;
@@ -354,10 +504,14 @@ pub fn delete_landing_page(conn: &Connection, id: &str) -> rusqlite::Result<bool
Ok(count > 0)
}
pub fn list_landing_pages(conn: &Connection, limit: i64, offset: i64) -> rusqlite::Result<Vec<LandingPage>> {
pub fn list_landing_pages(
conn: &Connection,
limit: i64,
offset: i64,
) -> rusqlite::Result<Vec<LandingPage>> {
let mut stmt = conn.prepare(
"SELECT id, code, slug, title, html_content, state, created_at, updated_at
FROM landing_pages ORDER BY created_at DESC LIMIT ?1 OFFSET ?2;"
FROM landing_pages ORDER BY created_at DESC LIMIT ?1 OFFSET ?2;",
)?;
let rows = stmt.query_map(params![limit, offset], |row| {
Ok(LandingPage {
+104 -11
View File
@@ -27,7 +27,12 @@ pub fn run_migrations(
if current_version < target_version {
for m in migrations.iter().filter(|m| m.version > current_version) {
info!(database = db_name, version = m.version, name = m.name, "Applying migration");
info!(
database = db_name,
version = m.version,
name = m.name,
"Applying migration"
);
let tx = conn.transaction()?;
tx.execute_batch(m.sql)?;
@@ -35,7 +40,12 @@ pub fn run_migrations(
crate::db::sqlite::set_user_version(conn, m.version as i32)?;
info!(database = db_name, version = m.version, name = m.name, "Migration completed");
info!(
database = db_name,
version = m.version,
name = m.name,
"Migration completed"
);
// Write audit record to system.db.migrations
if let Some(sys_db_mutex) = system_db_opt {
@@ -58,7 +68,11 @@ pub fn run_migrations(
}
}
} else {
info!(database = db_name, version = current_version, "Database up to date");
info!(
database = db_name,
version = current_version,
"Database up to date"
);
}
Ok(())
@@ -77,7 +91,10 @@ pub fn print_migration_plan(
println!(" Current version: {current_version}");
println!(" Target version: {target_version}");
let pending: Vec<&Migration> = migrations.iter().filter(|m| m.version > current_version).collect();
let pending: Vec<&Migration> = migrations
.iter()
.filter(|m| m.version > current_version)
.collect();
if pending.is_empty() {
println!(" Status: up to date");
@@ -94,11 +111,10 @@ pub fn print_migration_plan(
// Migration definitions
// ---------------------------------------------------------------------------
pub const ADMIN_MIGRATIONS: &[Migration] = &[
Migration {
version: 1,
name: "initial_schema",
sql: r#"
pub const ADMIN_MIGRATIONS: &[Migration] = &[Migration {
version: 1,
name: "initial_schema",
sql: r#"
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
@@ -140,8 +156,7 @@ pub const ADMIN_MIGRATIONS: &[Migration] = &[
value TEXT NOT NULL
);
"#,
},
];
}];
pub const CONTENT_MIGRATIONS: &[Migration] = &[
Migration {
@@ -187,6 +202,49 @@ pub const CONTENT_MIGRATIONS: &[Migration] = &[
CREATE INDEX IF NOT EXISTS idx_pages_code ON landing_pages(code);
"#,
},
Migration {
version: 2,
name: "features_expansion",
sql: r#"
-- Expiring Links
ALTER TABLE urls ADD COLUMN expires_at TEXT NULL;
ALTER TABLE urls ADD COLUMN expired INTEGER NOT NULL DEFAULT 0;
-- Password Protected Links
ALTER TABLE urls ADD COLUMN password_hash TEXT NULL;
-- Link Health Dashboard (extended columns)
ALTER TABLE urls ADD COLUMN last_status TEXT;
ALTER TABLE urls ADD COLUMN last_latency_ms INTEGER;
-- One-Time Links
ALTER TABLE urls ADD COLUMN max_access_count INTEGER NULL;
ALTER TABLE urls ADD COLUMN access_count INTEGER NOT NULL DEFAULT 0;
-- Smart Landing Pages / Link Preview
CREATE TABLE IF NOT EXISTS link_preview (
id TEXT PRIMARY KEY,
url_id TEXT NOT NULL UNIQUE,
title TEXT,
description TEXT,
logo_url TEXT,
button_text TEXT DEFAULT 'Continue',
FOREIGN KEY(url_id) REFERENCES urls(id) ON DELETE CASCADE
);
-- QR Code style metadata
CREATE TABLE IF NOT EXISTS qr_codes (
id TEXT PRIMARY KEY,
url_id TEXT NOT NULL,
style TEXT NOT NULL DEFAULT 'default',
created_at TEXT NOT NULL,
FOREIGN KEY(url_id) REFERENCES urls(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_urls_expired ON urls(expired);
CREATE INDEX IF NOT EXISTS idx_urls_expires_at ON urls(expires_at);
"#,
},
];
pub const ANALYTICS_MIGRATIONS: &[Migration] = &[
@@ -241,6 +299,22 @@ pub const ANALYTICS_MIGRATIONS: &[Migration] = &[
CREATE INDEX IF NOT EXISTS idx_visits_target ON visits(target_type, target_id);
"#,
},
Migration {
version: 2,
name: "qr_access_log",
sql: r#"
CREATE TABLE IF NOT EXISTS qr_access_log (
id TEXT PRIMARY KEY,
url_id TEXT NOT NULL,
timestamp TEXT NOT NULL,
ip TEXT,
user_agent TEXT
);
CREATE INDEX IF NOT EXISTS idx_qr_access_url ON qr_access_log(url_id);
CREATE INDEX IF NOT EXISTS idx_qr_access_ts ON qr_access_log(timestamp);
"#,
},
];
pub const SYSTEM_MIGRATIONS: &[Migration] = &[
@@ -291,4 +365,23 @@ pub const SYSTEM_MIGRATIONS: &[Migration] = &[
);
"#,
},
Migration {
version: 2,
name: "audit_events",
sql: r#"
CREATE TABLE IF NOT EXISTS audit_events (
id TEXT PRIMARY KEY,
actor TEXT NOT NULL,
action TEXT NOT NULL,
object_type TEXT NOT NULL,
object_id TEXT NOT NULL,
timestamp TEXT NOT NULL,
metadata TEXT
);
CREATE INDEX IF NOT EXISTS idx_audit_actor ON audit_events(actor);
CREATE INDEX IF NOT EXISTS idx_audit_ts ON audit_events(timestamp);
CREATE INDEX IF NOT EXISTS idx_audit_action ON audit_events(action);
"#,
},
];
+48 -16
View File
@@ -1,15 +1,20 @@
use crate::config::Config;
use crate::db::migrations::{
run_migrations, ADMIN_MIGRATIONS, ANALYTICS_MIGRATIONS, CONTENT_MIGRATIONS, SYSTEM_MIGRATIONS,
};
use crate::db::sqlite::{enable_foreign_keys, enable_wal};
use rusqlite::Connection;
use std::fs;
use std::sync::{Arc, Mutex};
use rusqlite::Connection;
use crate::config::Config;
use crate::db::migrations::{run_migrations, ADMIN_MIGRATIONS, CONTENT_MIGRATIONS, ANALYTICS_MIGRATIONS, SYSTEM_MIGRATIONS};
use crate::db::sqlite::{enable_foreign_keys, enable_wal};
pub mod migrations;
pub mod sqlite;
pub mod admin;
pub mod content;
pub mod analytics;
pub mod audit_events;
pub mod content;
pub mod migrations;
pub mod preview;
pub mod qr;
pub mod sqlite;
#[derive(Clone)]
pub struct Db {
@@ -53,13 +58,25 @@ impl Db {
enable_wal(&system_conn, "system")?;
// Enable foreign key support
info!(database = "admin", "Enabling foreign key enforcement on admin.db");
info!(
database = "admin",
"Enabling foreign key enforcement on admin.db"
);
enable_foreign_keys(&admin_conn, "admin")?;
info!(database = "content", "Enabling foreign key enforcement on content.db");
info!(
database = "content",
"Enabling foreign key enforcement on content.db"
);
enable_foreign_keys(&content_conn, "content")?;
info!(database = "analytics", "Enabling foreign key enforcement on analytics.db");
info!(
database = "analytics",
"Enabling foreign key enforcement on analytics.db"
);
enable_foreign_keys(&analytics_conn, "analytics")?;
info!(database = "system", "Enabling foreign key enforcement on system.db");
info!(
database = "system",
"Enabling foreign key enforcement on system.db"
);
enable_foreign_keys(&system_conn, "system")?;
// 1. Run migrations for system.db first, as it receives secondary audit records
@@ -70,11 +87,26 @@ impl Db {
// 2. Run migrations for other databases with system.db logging
info!("Running admin migrations");
run_migrations(&mut admin_conn, "admin", ADMIN_MIGRATIONS, Some(&system_arc))?;
run_migrations(
&mut admin_conn,
"admin",
ADMIN_MIGRATIONS,
Some(&system_arc),
)?;
info!("Running content migrations");
run_migrations(&mut content_conn, "content", CONTENT_MIGRATIONS, Some(&system_arc))?;
run_migrations(
&mut content_conn,
"content",
CONTENT_MIGRATIONS,
Some(&system_arc),
)?;
info!("Running analytics migrations");
run_migrations(&mut analytics_conn, "analytics", ANALYTICS_MIGRATIONS, Some(&system_arc))?;
run_migrations(
&mut analytics_conn,
"analytics",
ANALYTICS_MIGRATIONS,
Some(&system_arc),
)?;
Ok(Self {
admin: Arc::new(Mutex::new(admin_conn)),
@@ -115,12 +147,12 @@ mod db_init_tests {
let mut config = Config::load();
config.data_dir = temp_dir.clone();
let db = Db::init(&config);
// Cleanup
if temp_dir.exists() {
let _ = std::fs::remove_dir_all(&temp_dir);
}
assert!(db.is_ok(), "Failed to init DB: {:?}", db.err());
}
}
+62
View File
@@ -0,0 +1,62 @@
use crate::models::LinkPreview;
use rusqlite::{params, Connection};
use uuid::Uuid;
/// Insert or update a link preview for a URL.
pub fn upsert_preview(
conn: &Connection,
url_id: &str,
title: Option<&str>,
description: Option<&str>,
logo_url: Option<&str>,
button_text: Option<&str>,
) -> rusqlite::Result<LinkPreview> {
let id = Uuid::new_v4().to_string();
let btn = button_text.unwrap_or("Continue");
conn.execute(
"INSERT INTO link_preview (id, url_id, title, description, logo_url, button_text)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)
ON CONFLICT(url_id) DO UPDATE SET
title = excluded.title,
description = excluded.description,
logo_url = excluded.logo_url,
button_text = excluded.button_text;",
params![id, url_id, title, description, logo_url, btn],
)?;
// Return the current state (may have been an update with a different id)
get_preview(conn, url_id)?.ok_or(rusqlite::Error::QueryReturnedNoRows)
}
/// Get the link preview for a URL.
pub fn get_preview(conn: &Connection, url_id: &str) -> rusqlite::Result<Option<LinkPreview>> {
let mut stmt = conn.prepare(
"SELECT id, url_id, title, description, logo_url, button_text FROM link_preview WHERE url_id = ?1;"
)?;
let mut rows = stmt.query(params![url_id])?;
if let Some(row) = rows.next()? {
Ok(Some(LinkPreview {
id: row.get(0)?,
url_id: row.get(1)?,
title: row.get(2)?,
description: row.get(3)?,
logo_url: row.get(4)?,
button_text: row
.get::<_, Option<String>>(5)?
.unwrap_or_else(|| "Continue".to_string()),
}))
} else {
Ok(None)
}
}
/// Delete the link preview for a URL.
pub fn delete_preview(conn: &Connection, url_id: &str) -> rusqlite::Result<bool> {
let count = conn.execute(
"DELETE FROM link_preview WHERE url_id = ?1;",
params![url_id],
)?;
Ok(count > 0)
}
+90
View File
@@ -0,0 +1,90 @@
use chrono::Utc;
use rusqlite::{params, Connection, OptionalExtension};
use uuid::Uuid;
/// Log a QR code access event to the analytics database.
pub fn log_qr_access(
conn: &Connection,
url_id: &str,
ip: Option<&str>,
user_agent: Option<&str>,
) -> rusqlite::Result<()> {
let id = Uuid::new_v4().to_string();
let now = Utc::now().to_rfc3339();
conn.execute(
"INSERT INTO qr_access_log (id, url_id, timestamp, ip, user_agent)
VALUES (?1, ?2, ?3, ?4, ?5);",
params![id, url_id, now, ip, user_agent],
)?;
Ok(())
}
/// Get QR scan count for a URL.
pub fn get_qr_scan_count(conn: &Connection, url_id: &str) -> rusqlite::Result<i64> {
conn.query_row(
"SELECT COUNT(*) FROM qr_access_log WHERE url_id = ?1;",
params![url_id],
|row| row.get(0),
)
}
/// Get QR scan count for a URL by its code (joins with content.db — must be called on analytics db after lookup).
pub fn get_qr_stats_for_url(
conn: &Connection,
url_id: &str,
) -> rusqlite::Result<Vec<(String, String)>> {
let mut stmt = conn.prepare(
"SELECT timestamp, ip FROM qr_access_log WHERE url_id = ?1 ORDER BY timestamp DESC LIMIT 100;"
)?;
let rows = stmt.query_map(params![url_id], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, Option<String>>(1)?.unwrap_or_default(),
))
})?;
let mut results = Vec::new();
for r in rows {
results.push(r?);
}
Ok(results)
}
/// Create or update a QR code style registration in the content database.
pub fn upsert_qr_code(conn: &Connection, url_id: &str, style: &str) -> rusqlite::Result<()> {
let now = Utc::now().to_rfc3339();
// Check if entry already exists
let existing_id: Option<String> = conn
.query_row(
"SELECT id FROM qr_codes WHERE url_id = ?1;",
params![url_id],
|row| row.get(0),
)
.optional()?;
if let Some(id) = existing_id {
conn.execute(
"UPDATE qr_codes SET style = ?1 WHERE id = ?2;",
params![style, id],
)?;
} else {
let id = Uuid::new_v4().to_string();
conn.execute(
"INSERT INTO qr_codes (id, url_id, style, created_at) VALUES (?1, ?2, ?3, ?4);",
params![id, url_id, style, now],
)?;
}
Ok(())
}
/// Get the style configured for a QR code.
pub fn get_qr_code_style(conn: &Connection, url_id: &str) -> rusqlite::Result<String> {
let style: Option<String> = conn
.query_row(
"SELECT style FROM qr_codes WHERE url_id = ?1;",
params![url_id],
|row| row.get(0),
)
.optional()?;
Ok(style.unwrap_or_else(|| "default".to_string()))
}
+9 -5
View File
@@ -14,8 +14,9 @@ use tracing::info;
/// Uses `query_row` with `PRAGMA journal_mode=WAL` which both sets and returns
/// the actual mode. Returns an error if the database does not confirm WAL mode.
pub fn enable_wal(conn: &Connection, db_name: &str) -> Result<(), rusqlite::Error> {
let actual_mode: String =
conn.query_row("PRAGMA journal_mode=WAL;", [], |row| row.get::<_, String>(0))?;
let actual_mode: String = conn.query_row("PRAGMA journal_mode=WAL;", [], |row| {
row.get::<_, String>(0)
})?;
info!(database = db_name, mode = %actual_mode, "WAL mode configured");
@@ -36,7 +37,11 @@ pub fn enable_foreign_keys(conn: &Connection, db_name: &str) -> Result<(), rusql
let enabled: bool =
conn.pragma_query_value(None, "foreign_keys", |row| row.get::<_, bool>(0))?;
info!(database = db_name, foreign_keys = enabled, "Foreign key enforcement configured");
info!(
database = db_name,
foreign_keys = enabled,
"Foreign key enforcement configured"
);
if !enabled {
return Err(rusqlite::Error::QueryReturnedNoRows);
@@ -183,8 +188,7 @@ mod tests {
#[test]
fn test_collect_health_report() {
let conn = memory_conn();
let report =
collect_health_report(&conn, "test").expect("Failed to collect health report");
let report = collect_health_report(&conn, "test").expect("Failed to collect health report");
assert_eq!(report.database, "test");
assert!(report.integrity_ok);
}
+60 -13
View File
@@ -1,6 +1,6 @@
use axum::{
response::{IntoResponse, Response},
http::StatusCode,
response::{IntoResponse, Response},
};
use std::fmt;
use std::path::PathBuf;
@@ -12,17 +12,36 @@ use std::path::PathBuf;
#[derive(Debug)]
pub enum DatabaseInitError {
/// Data directory could not be created or accessed
DataDirCreate { path: PathBuf, source: std::io::Error },
DataDirCreate {
path: PathBuf,
source: std::io::Error,
},
/// SQLite connection could not be opened
ConnectionOpen { database: String, path: PathBuf, source: rusqlite::Error },
ConnectionOpen {
database: String,
path: PathBuf,
source: rusqlite::Error,
},
/// PRAGMA configuration failed (WAL, foreign_keys, etc.)
PragmaConfig { database: String, pragma: String, source: rusqlite::Error },
PragmaConfig {
database: String,
pragma: String,
source: rusqlite::Error,
},
/// Migration execution failed
MigrationFailed { database: String, version: u32, name: String, source: Box<dyn std::error::Error + Send + Sync> },
MigrationFailed {
database: String,
version: u32,
name: String,
source: Box<dyn std::error::Error + Send + Sync>,
},
/// Database integrity check failed
IntegrityCheckFailed { database: String, message: String },
/// WAL mode could not be enabled (returned unexpected mode)
WalModeFailed { database: String, actual_mode: String },
WalModeFailed {
database: String,
actual_mode: String,
},
}
impl fmt::Display for DatabaseInitError {
@@ -31,20 +50,48 @@ impl fmt::Display for DatabaseInitError {
Self::DataDirCreate { path, source } => {
write!(f, "Failed to create data directory {:?}: {}", path, source)
}
Self::ConnectionOpen { database, path, source } => {
write!(f, "Failed to open {}.db at {:?}: {}", database, path, source)
Self::ConnectionOpen {
database,
path,
source,
} => {
write!(
f,
"Failed to open {}.db at {:?}: {}",
database, path, source
)
}
Self::PragmaConfig { database, pragma, source } => {
Self::PragmaConfig {
database,
pragma,
source,
} => {
write!(f, "PRAGMA {} failed on {}.db: {}", pragma, database, source)
}
Self::MigrationFailed { database, version, name, source } => {
write!(f, "Migration v{} ({}) failed on {}.db: {}", version, name, database, source)
Self::MigrationFailed {
database,
version,
name,
source,
} => {
write!(
f,
"Migration v{} ({}) failed on {}.db: {}",
version, name, database, source
)
}
Self::IntegrityCheckFailed { database, message } => {
write!(f, "Integrity check failed on {}.db: {}", database, message)
}
Self::WalModeFailed { database, actual_mode } => {
write!(f, "WAL mode not enabled on {}.db (got '{}')", database, actual_mode)
Self::WalModeFailed {
database,
actual_mode,
} => {
write!(
f,
"WAL mode not enabled on {}.db (got '{}')",
database, actual_mode
)
}
}
}
+4 -4
View File
@@ -1,15 +1,15 @@
use std::time::Duration;
use tracing::{info, error};
use tracing::{error, info};
use crate::db::Db;
use super::{log_job_end, log_job_start};
use crate::analytics::aggregate_day;
use super::{log_job_start, log_job_end};
use crate::db::Db;
pub async fn run_aggregator(db: Db, interval_mins: u64) {
loop {
tokio::time::sleep(Duration::from_secs(interval_mins * 60)).await;
info!("Running background analytics aggregator...");
let job_id = log_job_start(&db.system, "analytics_aggregator");
match perform_aggregation(&db).await {
Ok(_) => log_job_end(&db.system, &job_id, "success", None),
+17 -11
View File
@@ -1,8 +1,8 @@
use std::time::Duration;
use tracing::{info, error};
use crate::db::Db;
use super::{log_job_end, log_job_start};
use crate::config::Config;
use super::{log_job_start, log_job_end};
use crate::db::Db;
use std::time::Duration;
use tracing::{error, info};
pub async fn run_backup_scheduler(db: Db, config: Config) {
if !config.backup_enabled {
@@ -10,12 +10,15 @@ pub async fn run_backup_scheduler(db: Db, config: Config) {
return;
}
info!("Starting background backup scheduler (interval: {} mins)...", config.backup_interval_mins);
info!(
"Starting background backup scheduler (interval: {} mins)...",
config.backup_interval_mins
);
loop {
// Run backup every configured interval
tokio::time::sleep(Duration::from_secs(config.backup_interval_mins * 60)).await;
info!("Running background database backup...");
let job_id = log_job_start(&db.system, "database_backup");
match perform_backup(&db, &config).await {
Ok(path) => {
@@ -31,13 +34,16 @@ pub async fn run_backup_scheduler(db: Db, config: Config) {
}
}
pub async fn perform_backup(db: &Db, config: &Config) -> Result<String, Box<dyn std::error::Error>> {
use std::fs::File;
pub async fn perform_backup(
db: &Db,
config: &Config,
) -> Result<String, Box<dyn std::error::Error>> {
use chrono::Utc;
use flate2::write::GzEncoder;
use flate2::Compression;
use tar::Builder;
use chrono::Utc;
use rusqlite::params;
use std::fs::File;
use tar::Builder;
use uuid::Uuid;
let out_dir = config.backup_dir.clone();
@@ -60,7 +66,7 @@ pub async fn perform_backup(db: &Db, config: &Config) -> Result<String, Box<dyn
tar.append_path_with_name(&db_file, f)?;
}
}
tar.into_inner()?.finish()?;
let size_bytes = std::fs::metadata(&tar_path)?.len();
let path_str = tar_path.to_string_lossy().to_string();
+22
View File
@@ -0,0 +1,22 @@
use crate::db::Db;
use std::time::Duration;
use tracing::info;
/// Background job that marks expired URLs.
///
/// Runs every 60 seconds. Any URL with `expires_at < NOW()` and `expired = 0`
/// gets flipped to `expired = 1`.
pub async fn run_expiry_checker(db: Db) {
loop {
tokio::time::sleep(Duration::from_secs(60)).await;
let count = {
let conn = db.content.lock().unwrap();
crate::db::content::expire_urls(&conn).unwrap_or(0)
};
if count > 0 {
info!(expired_count = count, "Expired URLs marked");
}
}
}
+80 -23
View File
@@ -1,17 +1,18 @@
use reqwest::Client;
use std::time::Duration;
use tracing::{info, error};
use uuid::Uuid;
use chrono::Utc;
use reqwest::Client;
use rusqlite::params;
use std::time::{Duration, Instant};
use tracing::{error, info};
use uuid::Uuid;
use super::{log_job_end, log_job_start};
use crate::db::Db;
use super::{log_job_start, log_job_end};
pub async fn run_link_checker(db: Db, interval_mins: u64) {
let client = Client::builder()
.timeout(Duration::from_secs(10))
.user_agent("bzod-link-checker/0.1")
.redirect(reqwest::redirect::Policy::limited(10))
.build()
.unwrap_or_default();
@@ -19,7 +20,7 @@ pub async fn run_link_checker(db: Db, interval_mins: u64) {
// Sleep first to give server time to start up
tokio::time::sleep(Duration::from_secs(interval_mins * 60)).await;
info!("Running background link health check...");
let job_id = log_job_start(&db.system, "link_checker");
match perform_link_check(&db, &client).await {
Ok(_) => log_job_end(&db.system, &job_id, "success", None),
@@ -32,19 +33,29 @@ pub async fn run_link_checker(db: Db, interval_mins: u64) {
}
}
pub async fn perform_link_check(db: &Db, client: &Client) -> Result<(), Box<dyn std::error::Error>> {
pub async fn perform_link_check(
db: &Db,
client: &Client,
) -> Result<(), Box<dyn std::error::Error>> {
let urls = {
let conn = db.content.lock().unwrap();
crate::db::content::list_urls_for_health_check(&conn)?
};
for (id, dest) in urls {
let (status, status_code, err_msg) = check_url_health(client, &dest).await;
let (status, detail_status, status_code, latency_ms, err_msg) =
check_url_health(client, &dest).await;
{
let conn = db.content.lock().unwrap();
crate::db::content::update_url_health(&conn, &id, &status)?;
crate::db::content::update_url_health_extended(
&conn,
&id,
&status,
&detail_status,
Some(latency_ms),
)?;
}
// Log to system.db.health_checks
{
let conn = db.system.lock().unwrap();
@@ -64,31 +75,77 @@ pub async fn perform_link_check(db: &Db, client: &Client) -> Result<(), Box<dyn
Ok(())
}
async fn check_url_health(client: &Client, url: &str) -> (String, Option<u16>, Option<String>) {
let res = client.get(url)
.timeout(Duration::from_secs(5))
.send()
.await;
/// Check URL health with detailed classification and latency measurement.
///
/// Returns: (general_status, detail_status, status_code, latency_ms, error_message)
async fn check_url_health(
client: &Client,
url: &str,
) -> (String, String, Option<u16>, i64, Option<String>) {
let start = Instant::now();
let res = client.get(url).timeout(Duration::from_secs(5)).send().await;
let latency_ms = start.elapsed().as_millis() as i64;
match res {
Ok(response) => {
let status = response.status();
let code = status.as_u16();
if status.is_success() || status.is_redirection() {
("healthy".to_string(), Some(code), None)
} else if status == reqwest::StatusCode::NOT_FOUND || status == reqwest::StatusCode::GONE {
("dead".to_string(), Some(code), Some(format!("HTTP {}", code)))
(
"healthy".to_string(),
"healthy".to_string(),
Some(code),
latency_ms,
None,
)
} else if status == reqwest::StatusCode::NOT_FOUND
|| status == reqwest::StatusCode::GONE
{
(
"dead".to_string(),
"dead".to_string(),
Some(code),
latency_ms,
Some(format!("HTTP {}", code)),
)
} else {
("suspect".to_string(), Some(code), Some(format!("HTTP {}", code)))
(
"suspect".to_string(),
format!("http_{}", code),
Some(code),
latency_ms,
Some(format!("HTTP {}", code)),
)
}
}
Err(err) => {
let err_str = err.to_string();
if err.is_timeout() || err.is_connect() {
("suspect".to_string(), None, Some(err_str))
let detail = if err.is_timeout() {
"timeout".to_string()
} else if err.is_connect() {
if err_str.contains("dns") || err_str.contains("resolve") {
"dns_failure".to_string()
} else if err_str.contains("tls")
|| err_str.contains("ssl")
|| err_str.contains("certificate")
{
"tls_error".to_string()
} else {
"connection_refused".to_string()
}
} else if err.is_redirect() {
"redirect_loop".to_string()
} else {
("dead".to_string(), None, Some(err_str))
}
"unknown_error".to_string()
};
let general = if err.is_timeout() || err.is_connect() {
"suspect"
} else {
"dead"
};
(general.to_string(), detail, None, latency_ms, Some(err_str))
}
}
}
+9 -7
View File
@@ -1,16 +1,18 @@
use std::sync::Mutex;
use rusqlite::{Connection, params};
use uuid::Uuid;
use chrono::Utc;
use rusqlite::{params, Connection};
use std::sync::Mutex;
use uuid::Uuid;
pub mod healthcheck;
pub mod retention;
pub mod aggregate;
pub mod backup;
pub mod expiry;
pub mod healthcheck;
pub mod retention;
pub use healthcheck::{run_link_checker, perform_link_check};
pub use aggregate::{perform_aggregation, run_aggregator};
pub use expiry::run_expiry_checker;
pub use healthcheck::{perform_link_check, run_link_checker};
pub use retention::run_retention_cleaner;
pub use aggregate::{run_aggregator, perform_aggregation};
pub fn log_job_start(conn: &Mutex<Connection>, job_name: &str) -> String {
let id = Uuid::new_v4().to_string();
+3 -3
View File
@@ -1,8 +1,8 @@
use std::time::Duration;
use tracing::{info, error};
use tracing::{error, info};
use super::{log_job_end, log_job_start};
use crate::db::Db;
use super::{log_job_start, log_job_end};
pub async fn run_retention_cleaner(db: Db, retention_days_opt: Option<i64>) {
let retention_days = match retention_days_opt {
@@ -14,7 +14,7 @@ pub async fn run_retention_cleaner(db: Db, retention_days_opt: Option<i64>) {
// Check once every 24 hours
tokio::time::sleep(Duration::from_secs(24 * 3600)).await;
info!("Running background data retention cleanup...");
let job_id = log_job_start(&db.system, "retention_cleaner");
let conn = db.analytics.lock().unwrap();
match crate::db::analytics::retention_cleanup(&conn, retention_days) {
+10 -10
View File
@@ -1,14 +1,14 @@
pub mod analytics;
pub mod auth;
pub mod charts;
pub mod cli;
pub mod config;
pub mod db;
pub mod auth;
pub mod analytics;
pub mod jobs;
pub mod services;
pub mod utils;
pub mod models;
pub mod templates;
pub mod charts;
pub mod state;
pub mod error;
pub mod jobs;
pub mod models;
pub mod services;
pub mod state;
pub mod templates;
pub mod utils;
pub mod web;
pub mod cli;
+10 -4
View File
@@ -1,20 +1,26 @@
use bzod::cli::{Cli, Commands};
use bzod::config::Config;
use clap::Parser;
use tracing_subscriber::EnvFilter;
use bzod::config::Config;
use bzod::cli::{Cli, Commands};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Set up tracing subscriber
tracing_subscriber::fmt()
.with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")))
.with_env_filter(
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")),
)
.init();
let cli = Cli::parse();
let config = Config::load();
match cli.command {
Commands::Serve { host, port, data_dir } => {
Commands::Serve {
host,
port,
data_dir,
} => {
bzod::cli::serve::run(host, port, data_dir, config).await?;
}
Commands::Backup { out, data_dir } => {
+1 -1
View File
@@ -1,4 +1,4 @@
use serde::{Serialize, Deserialize};
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct ApiKey {
+1 -1
View File
@@ -1,4 +1,4 @@
use serde::{Serialize, Deserialize};
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct AuditLog {
+8 -8
View File
@@ -1,13 +1,13 @@
pub mod user;
pub mod url;
pub mod page;
pub mod visit;
pub mod api_key;
pub mod audit;
pub mod page;
pub mod url;
pub mod user;
pub mod visit;
pub use user::{User, Session};
pub use url::Url;
pub use page::LandingPage;
pub use visit::{VisitRecord, SummaryEntry};
pub use api_key::ApiKey;
pub use audit::AuditLog;
pub use page::LandingPage;
pub use url::{AuditEvent, LinkPreview, QrCode, Url};
pub use user::{Session, User};
pub use visit::{SummaryEntry, VisitRecord};
+1 -1
View File
@@ -1,4 +1,4 @@
use serde::{Serialize, Deserialize};
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct LandingPage {
+64 -1
View File
@@ -1,4 +1,4 @@
use serde::{Serialize, Deserialize};
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct Url {
@@ -11,4 +11,67 @@ pub struct Url {
pub created_at: String,
pub updated_at: String,
pub tags: Vec<String>,
// --- Feature Expansion Fields ---
#[serde(skip_serializing_if = "Option::is_none")]
pub expires_at: Option<String>,
#[serde(default)]
pub expired: bool,
#[serde(skip_serializing)]
pub password_hash: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_status: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_latency_ms: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_access_count: Option<i64>,
#[serde(default)]
pub access_count: i64,
}
impl Url {
/// Returns true if this URL has a password set.
pub fn is_password_protected(&self) -> bool {
self.password_hash.is_some()
}
/// Returns true if this URL has reached its access limit.
pub fn is_access_exhausted(&self) -> bool {
if let Some(max) = self.max_access_count {
self.access_count >= max
} else {
false
}
}
}
/// Link preview metadata for smart landing pages.
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct LinkPreview {
pub id: String,
pub url_id: String,
pub title: Option<String>,
pub description: Option<String>,
pub logo_url: Option<String>,
pub button_text: String,
}
/// QR code metadata record.
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct QrCode {
pub id: String,
pub url_id: String,
pub style: String,
pub created_at: String,
}
/// Audit event record for the enhanced audit trail.
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct AuditEvent {
pub id: String,
pub actor: String,
pub action: String,
pub object_type: String,
pub object_id: String,
pub timestamp: String,
pub metadata: Option<String>,
}
+1 -1
View File
@@ -1,4 +1,4 @@
use serde::{Serialize, Deserialize};
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct User {
+1 -1
View File
@@ -1,4 +1,4 @@
use serde::{Serialize, Deserialize};
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct VisitRecord {
+1 -1
View File
@@ -1,6 +1,6 @@
use crate::db::Db;
use crate::models::ApiKey;
use crate::error::AppError;
use crate::models::ApiKey;
pub fn create_api_key(
db: &Db,
+1 -1
View File
@@ -1,6 +1,6 @@
use crate::db::Db;
use crate::models::AuditLog;
use crate::error::AppError;
use crate::models::AuditLog;
pub fn log_action(
db: &Db,
+49
View File
@@ -0,0 +1,49 @@
use crate::models::Url;
use crate::services::qr::{generate_qr_png, generate_qr_svg};
use std::io::Write;
use zip::write::FileOptions;
use zip::ZipWriter;
/// Export QR codes for the given URLs as a ZIP file.
/// `format` can be "png" or "svg".
/// `base_url` is used to build the full short URL encoded in the QR.
pub fn export_qr_zip(
urls: &[Url],
format: &str,
base_url: &str,
) -> Result<Vec<u8>, Box<dyn std::error::Error>> {
let mut buf = Vec::new();
{
let mut zip = ZipWriter::new(std::io::Cursor::new(&mut buf));
let options =
FileOptions::<()>::default().compression_method(zip::CompressionMethod::Deflated);
let mut csv_content = String::from("code,destination,qr_filename\n");
for url in urls {
let full_url = format!("{}/{}", base_url.trim_end_matches('/'), url.code);
let ext = if format == "svg" { "svg" } else { "png" };
let filename = format!("{}.{}", url.code, ext);
let qr_data = if format == "svg" {
generate_qr_svg(&full_url)?.into_bytes()
} else {
generate_qr_png(&full_url, 256)?
};
zip.start_file(&filename, options)?;
zip.write_all(&qr_data)?;
// Escape quotes in destination for CSV formatting
let escaped_dest = url.destination.replace('"', "\"\"");
csv_content.push_str(&format!("{},\"{}\",{}\n", url.code, escaped_dest, filename));
}
zip.start_file("manifest.csv", options)?;
zip.write_all(csv_content.as_bytes())?;
zip.finish()?;
}
Ok(buf)
}
+3 -2
View File
@@ -1,6 +1,6 @@
use crate::db::Db;
use crate::models::LandingPage;
use crate::error::AppError;
use crate::models::LandingPage;
pub fn create_landing_page(
db: &Db,
@@ -11,7 +11,8 @@ pub fn create_landing_page(
state: &str,
) -> Result<LandingPage, AppError> {
let conn = db.content.lock().unwrap();
let page = crate::db::content::create_landing_page(&conn, code, slug, title, html_content, state)?;
let page =
crate::db::content::create_landing_page(&conn, code, slug, title, html_content, state)?;
Ok(page)
}
+4 -2
View File
@@ -1,4 +1,6 @@
pub mod shortener;
pub mod landing_pages;
pub mod api_keys;
pub mod audit;
pub mod bulk;
pub mod landing_pages;
pub mod qr;
pub mod shortener;
+58
View File
@@ -0,0 +1,58 @@
//! QR code generation service.
//!
//! Generates QR codes on-demand as PNG or SVG. No files are stored on disk.
use image::Luma;
use qrcode::QrCode;
use std::io::Cursor;
/// Generate a QR code as a PNG byte vector.
///
/// The `url` is encoded into the QR matrix. The `size` parameter controls
/// the pixel dimensions of the output image (default: 256).
pub fn generate_qr_png(url: &str, size: u32) -> Result<Vec<u8>, Box<dyn std::error::Error>> {
let code = QrCode::new(url.as_bytes())?;
let image = code.render::<Luma<u8>>().min_dimensions(size, size).build();
let mut buf = Vec::new();
let mut cursor = Cursor::new(&mut buf);
image.write_to(&mut cursor, image::ImageFormat::Png)?;
Ok(buf)
}
/// Generate a QR code as an SVG string.
pub fn generate_qr_svg(url: &str) -> Result<String, Box<dyn std::error::Error>> {
let code = QrCode::new(url.as_bytes())?;
let svg = code
.render::<qrcode::render::svg::Color>()
.min_dimensions(256, 256)
.build();
Ok(svg)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_qr_png_generation() {
let png = generate_qr_png("https://bzo.in/abc123", 256).unwrap();
assert!(!png.is_empty());
// PNG magic bytes
assert_eq!(&png[..4], &[0x89, b'P', b'N', b'G']);
}
#[test]
fn test_qr_svg_generation() {
let svg = generate_qr_svg("https://bzo.in/abc123").unwrap();
assert!(svg.contains("<svg"));
assert!(svg.contains("</svg>"));
}
#[test]
fn test_qr_long_url() {
let long_url = format!("https://example.com/{}", "a".repeat(500));
let png = generate_qr_png(&long_url, 512).unwrap();
assert!(!png.is_empty());
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
use crate::db::Db;
use crate::models::Url;
use crate::error::AppError;
use crate::models::Url;
pub fn create_url(
db: &Db,
+4 -4
View File
@@ -1,9 +1,9 @@
use crate::analytics::queue::AnalyticsQueue;
use crate::config::Config;
use crate::db::Db;
use rusqlite::Connection;
use std::sync::{Arc, Mutex};
use std::time::Instant;
use rusqlite::Connection;
use crate::config::Config;
use crate::analytics::queue::AnalyticsQueue;
use crate::db::Db;
#[derive(Clone)]
pub struct AppState {
+6 -2
View File
@@ -1,7 +1,7 @@
use askama::Template;
use axum::{
response::{IntoResponse, Response, Html},
http::StatusCode,
response::{Html, IntoResponse, Response},
};
#[derive(Template)]
@@ -23,7 +23,11 @@ impl IntoResponse for DashboardTemplate {
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(),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Render error: {}", e),
)
.into_response(),
}
}
}
+54 -6
View File
@@ -1,19 +1,19 @@
pub mod dashboard;
pub mod urls;
pub mod pages;
pub mod stats;
pub mod settings;
pub mod stats;
pub mod urls;
pub use dashboard::DashboardTemplate;
pub use urls::UrlsTemplate;
pub use pages::PagesTemplate;
pub use stats::{StatusTemplate, AuditTemplate};
pub use settings::SettingsTemplate;
pub use stats::{AuditTemplate, StatusTemplate};
pub use urls::UrlsTemplate;
use askama::Template;
use axum::{
response::{IntoResponse, Response, Html},
http::StatusCode,
response::{Html, IntoResponse, Response},
};
#[derive(Template)]
@@ -27,7 +27,55 @@ impl IntoResponse for LoginTemplate {
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(),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Render error: {}", e),
)
.into_response(),
}
}
}
#[derive(Template)]
#[template(path = "gate.html")]
pub struct GateTemplate {
pub code: String,
pub error: Option<String>,
}
impl IntoResponse for GateTemplate {
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 = "preview.html")]
pub struct PreviewTemplate {
pub code: String,
pub title: Option<String>,
pub description: Option<String>,
pub logo_url: Option<String>,
pub button_text: String,
pub destination: String,
}
impl IntoResponse for PreviewTemplate {
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(),
}
}
}
+7 -3
View File
@@ -1,9 +1,9 @@
use crate::models::LandingPage;
use askama::Template;
use axum::{
response::{IntoResponse, Response, Html},
http::StatusCode,
response::{Html, IntoResponse, Response},
};
use crate::models::LandingPage;
#[derive(Template)]
#[template(path = "pages.html")]
@@ -18,7 +18,11 @@ impl IntoResponse for PagesTemplate {
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(),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Render error: {}", e),
)
.into_response(),
}
}
}
+7 -3
View File
@@ -1,9 +1,9 @@
use crate::models::ApiKey;
use askama::Template;
use axum::{
response::{IntoResponse, Response, Html},
http::StatusCode,
response::{Html, IntoResponse, Response},
};
use crate::models::ApiKey;
#[derive(Template)]
#[template(path = "settings.html")]
@@ -20,7 +20,11 @@ impl IntoResponse for SettingsTemplate {
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(),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Render error: {}", e),
)
.into_response(),
}
}
}
+13 -4
View File
@@ -1,9 +1,9 @@
use crate::models::AuditLog;
use askama::Template;
use axum::{
response::{IntoResponse, Response, Html},
http::StatusCode,
response::{Html, IntoResponse, Response},
};
use crate::models::AuditLog;
#[derive(Template)]
#[template(path = "status_ui.html")]
@@ -16,6 +16,7 @@ pub struct StatusTemplate {
pub uptime: String,
pub version: &'static str,
pub git_commit: &'static str,
pub urls: Vec<crate::models::Url>,
}
#[derive(Template)]
@@ -29,7 +30,11 @@ impl IntoResponse for StatusTemplate {
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(),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Render error: {}", e),
)
.into_response(),
}
}
}
@@ -38,7 +43,11 @@ impl IntoResponse for AuditTemplate {
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(),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Render error: {}", e),
)
.into_response(),
}
}
}
+8 -3
View File
@@ -1,9 +1,9 @@
use crate::models::Url;
use askama::Template;
use axum::{
response::{IntoResponse, Response, Html},
http::StatusCode,
response::{Html, IntoResponse, Response},
};
use crate::models::Url;
#[derive(Template)]
#[template(path = "urls.html")]
@@ -13,13 +13,18 @@ pub struct UrlsTemplate {
pub csrf_token: String,
pub error: Option<String>,
pub tag_filter: Option<String>,
pub base_url: String,
}
impl IntoResponse for UrlsTemplate {
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(),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Render error: {}", e),
)
.into_response(),
}
}
}
+1 -1
View File
@@ -1,4 +1,4 @@
use sha2::{Sha256, Digest};
use sha2::{Digest, Sha256};
// General SHA-256 hash helper
pub fn sha256_hash(data: &str) -> String {
+4 -4
View File
@@ -1,11 +1,11 @@
pub mod time;
pub mod system;
pub mod hashing;
pub mod network;
pub mod random;
pub mod system;
pub mod time;
pub use time::format_duration;
pub use system::{get_memory_usage, get_db_file_info};
pub use hashing::sha256_hash;
pub use network::get_client_ip;
pub use random::generate_token;
pub use system::{get_db_file_info, get_memory_usage};
pub use time::format_duration;
+5 -5
View File
@@ -1,12 +1,12 @@
use axum::{extract::ConnectInfo, http::HeaderMap};
use std::net::SocketAddr;
use axum::{
extract::ConnectInfo,
http::HeaderMap,
};
// Extract client IP address from proxy headers or connection info
pub fn get_client_ip(headers: &HeaderMap, connect_info: Option<ConnectInfo<SocketAddr>>) -> String {
if let Some(ip) = headers.get("cf-connecting-ip").and_then(|h| h.to_str().ok()) {
if let Some(ip) = headers
.get("cf-connecting-ip")
.and_then(|h| h.to_str().ok())
{
return ip.to_string();
}
if let Some(ip) = headers.get("x-real-ip").and_then(|h| h.to_str().ok()) {
+1 -1
View File
@@ -1,4 +1,4 @@
use rand::{RngCore, thread_rng};
use rand::{thread_rng, RngCore};
// Generate a secure random token (hex-encoded)
pub fn generate_token(bytes_len: usize) -> String {
+6 -1
View File
@@ -30,7 +30,12 @@ pub fn get_db_file_info(data_dir: &Path) -> String {
if path.exists() {
if let Ok(metadata) = std::fs::metadata(&path) {
let size = metadata.len();
stats.push_str(&format!("{}: {} bytes ({:.2} MB)\n", name, size, size as f64 / 1_048_576.0));
stats.push_str(&format!(
"{}: {} bytes ({:.2} MB)\n",
name,
size,
size as f64 / 1_048_576.0
));
}
} else {
stats.push_str(&format!("{}: File not created yet\n", name));
+499 -107
View File
@@ -1,38 +1,77 @@
use axum::{
extract::{Path, State, Query, ConnectInfo},
extract::{ConnectInfo, Path, Query, State},
http::{HeaderMap, StatusCode},
response::{Redirect, Response, IntoResponse},
response::{IntoResponse, Redirect, Response},
Form,
};
use rusqlite::params;
use axum_extra::extract::CookieJar;
use axum_extra::extract::cookie::Cookie;
use serde::Deserialize;
use std::net::SocketAddr;
use axum_extra::extract::CookieJar;
use chrono::Utc;
use tar::Builder;
use flate2::write::GzEncoder;
use flate2::Compression;
use rusqlite::params;
use serde::Deserialize;
use std::net::SocketAddr;
use tar::Builder;
use crate::db::admin::{
create_user, get_user_count, get_user_by_username, create_session, delete_session,
write_audit_log, list_audit_logs, list_api_keys, create_api_key, delete_api_key, set_config, get_config
create_api_key, create_session, create_user, delete_api_key, delete_session, get_config,
get_user_by_username, get_user_count, list_api_keys, set_config,
write_audit_log as write_audit_log_legacy,
};
#[allow(clippy::too_many_arguments)]
fn write_audit_log(
conn: &rusqlite::Connection,
state: &AppState,
username: &str,
action: &str,
object_type: Option<&str>,
object_id: Option<&str>,
ip_address: Option<&str>,
user_agent: Option<&str>,
) -> rusqlite::Result<crate::models::AuditLog> {
let res = write_audit_log_legacy(
conn,
username,
action,
object_type,
object_id,
ip_address,
user_agent,
);
// Also write to unified audit events in system.db
let system_conn = state.system_db.lock().unwrap();
let metadata = format!("IP: {:?}, UA: {:?}", ip_address, user_agent);
let _ = crate::db::audit_events::write_audit_event(
&system_conn,
username,
action,
object_type.unwrap_or(""),
object_id.unwrap_or(""),
Some(&metadata),
);
res
}
use crate::auth::{
authenticate_session, generate_csrf_token, generate_token, hash_password, verify_csrf,
verify_password, verify_sha256,
};
use crate::charts::{generate_bar_chart, generate_line_chart};
use crate::db::analytics::{
get_clicks_trend, get_clicks_trend_raw, get_metric_rankings, get_metric_rankings_raw,
get_total_clicks,
};
use crate::db::content::{
list_urls, create_url, delete_url, get_url_counts, get_landing_page_count,
list_landing_pages, create_landing_page, delete_landing_page
create_landing_page, delete_landing_page, delete_url, get_landing_page_count, get_url_counts,
list_landing_pages, list_urls,
};
use crate::db::analytics::{
get_total_clicks, get_clicks_trend, get_clicks_trend_raw, get_metric_rankings, get_metric_rankings_raw
};
use crate::auth::{
authenticate_session, verify_password, verify_sha256, hash_password, generate_token,
generate_csrf_token, verify_csrf
};
use crate::charts::{generate_line_chart, generate_bar_chart};
use crate::state::AppState;
use crate::models::User;
use crate::utils::{get_client_ip, get_memory_usage, get_db_file_info};
use crate::state::AppState;
use crate::utils::{get_client_ip, get_db_file_info, get_memory_usage};
// Helper: Verify session and return user or redirect to login
async fn require_auth(state: &AppState, jar: &CookieJar) -> Result<(User, String), Redirect> {
@@ -44,10 +83,7 @@ async fn require_auth(state: &AppState, jar: &CookieJar) -> Result<(User, String
}
// GET /admin
pub async fn admin_index(
State(state): State<AppState>,
jar: CookieJar,
) -> Response {
pub async fn admin_index(State(state): State<AppState>, jar: CookieJar) -> Response {
match require_auth(&state, &jar).await {
Ok(_) => Redirect::to("/admin/dashboard").into_response(),
Err(redir) => redir.into_response(),
@@ -62,7 +98,7 @@ pub async fn login_get(
) -> Response {
let error = params.get("error").cloned();
let csrf_token = generate_token(16);
let mut new_jar = jar.clone();
new_jar = new_jar.add(
Cookie::build(("bzod_temp_csrf", csrf_token.clone()))
@@ -70,7 +106,7 @@ pub async fn login_get(
.secure(state.config.cookie_secure)
.http_only(true)
.same_site(axum_extra::extract::cookie::SameSite::Strict)
.build()
.build(),
);
let template = crate::templates::LoginTemplate { error, csrf_token };
@@ -92,7 +128,10 @@ pub async fn login_post(
connect_info: Option<ConnectInfo<SocketAddr>>,
Form(form): Form<LoginForm>,
) -> Response {
let temp_csrf = jar.get("bzod_temp_csrf").map(|c| c.value().to_string()).unwrap_or_default();
let temp_csrf = jar
.get("bzod_temp_csrf")
.map(|c| c.value().to_string())
.unwrap_or_default();
if temp_csrf.is_empty() || temp_csrf != form.csrf_token {
return Redirect::to("/admin/login?error=Invalid CSRF token").into_response();
}
@@ -106,16 +145,30 @@ pub async fn login_post(
let user_opt = if user_count == 0 {
// Bootstrap Phase using BOOTSTRAP_PASSWORD_SHA256
if form.username == state.config.admin_username && verify_sha256(&form.password, &state.config.bootstrap_password_sha256) {
if form.username == state.config.admin_username
&& verify_sha256(&form.password, &state.config.bootstrap_password_sha256)
{
let hash = match hash_password(&form.password) {
Ok(h) => h,
Err(_) => return Redirect::to("/admin/login?error=Internal hashing error").into_response(),
Err(_) => {
return Redirect::to("/admin/login?error=Internal hashing error")
.into_response()
}
};
let conn = state.admin_db.lock().unwrap();
match create_user(&conn, &form.username, &hash) {
Ok(u) => {
let _ = write_audit_log(&conn, &u.username, "BOOTSTRAP_USER_PROVISIONED", Some("user"), Some(&u.id), Some(&ip), headers.get("user-agent").and_then(|h| h.to_str().ok()));
let _ = write_audit_log(
&conn,
&state,
&u.username,
"BOOTSTRAP_USER_PROVISIONED",
Some("user"),
Some(&u.id),
Some(&ip),
headers.get("user-agent").and_then(|h| h.to_str().ok()),
);
Some(u)
}
Err(_) => None,
@@ -142,11 +195,20 @@ pub async fn login_post(
Some(user) => {
let session_token = generate_token(32);
let expires = (Utc::now() + chrono::Duration::days(30)).to_rfc3339();
{
let conn = state.admin_db.lock().unwrap();
let _ = create_session(&conn, &session_token, &user.id, &expires);
let _ = write_audit_log(&conn, &user.username, "USER_LOGIN", Some("session"), Some(&session_token), Some(&ip), headers.get("user-agent").and_then(|h| h.to_str().ok()));
let _ = write_audit_log(
&conn,
&state,
&user.username,
"USER_LOGIN",
Some("session"),
Some(&session_token),
Some(&ip),
headers.get("user-agent").and_then(|h| h.to_str().ok()),
);
}
let cookie = Cookie::build(("bzod_session", session_token))
@@ -170,7 +232,16 @@ pub async fn login_post(
None => {
{
let conn = state.admin_db.lock().unwrap();
let _ = write_audit_log(&conn, "anonymous", "LOGIN_FAILED", None, None, Some(&ip), headers.get("user-agent").and_then(|h| h.to_str().ok()));
let _ = write_audit_log(
&conn,
&state,
"anonymous",
"LOGIN_FAILED",
None,
None,
Some(&ip),
headers.get("user-agent").and_then(|h| h.to_str().ok()),
);
}
Redirect::to("/admin/login?error=Invalid username or password").into_response()
}
@@ -178,10 +249,7 @@ pub async fn login_post(
}
// GET /admin/logout
pub async fn logout(
State(state): State<AppState>,
jar: CookieJar,
) -> Response {
pub async fn logout(State(state): State<AppState>, jar: CookieJar) -> Response {
if let Ok((_, session_id)) = require_auth(&state, &jar).await {
let conn = state.admin_db.lock().unwrap();
let _ = delete_session(&conn, &session_id);
@@ -199,10 +267,7 @@ pub async fn logout(
}
// GET /admin/dashboard
pub async fn dashboard_get(
State(state): State<AppState>,
jar: CookieJar,
) -> Response {
pub async fn dashboard_get(State(state): State<AppState>, jar: CookieJar) -> Response {
let (user, _) = match require_auth(&state, &jar).await {
Ok(u) => u,
Err(redir) => return redir.into_response(),
@@ -229,10 +294,12 @@ pub async fn dashboard_get(
.or_else(|_| get_clicks_trend_raw(&conn, "url", "all", 30))
.unwrap_or_default()
};
let mut trend_map = std::collections::BTreeMap::new();
for i in (0..30).rev() {
let date_str = (Utc::now() - chrono::Duration::days(i)).format("%Y-%m-%d").to_string();
let date_str = (Utc::now() - chrono::Duration::days(i))
.format("%Y-%m-%d")
.to_string();
trend_map.insert(date_str, 0i64);
}
for (d, c) in clicks_data {
@@ -277,7 +344,7 @@ pub async fn dashboard_get(
browsers_chart,
referrers_chart,
};
template.into_response()
}
@@ -305,12 +372,24 @@ pub async fn urls_get(
let csrf_token = generate_csrf_token(&session_id);
let proto = if state.config.cookie_secure {
"https"
} else {
"http"
};
let base_url = state
.config
.base_url
.clone()
.unwrap_or_else(|| format!("{}://localhost:{}", proto, state.config.port));
let template = crate::templates::UrlsTemplate {
admin_username: user.username,
urls,
csrf_token,
error: query.error,
tag_filter: query.tag,
base_url,
};
template.into_response()
@@ -324,6 +403,9 @@ pub struct CreateUrlForm {
pub description: String,
pub tags: String,
pub csrf_token: String,
pub expires_at: String,
pub password: String,
pub max_access_count: String,
}
// POST /admin/urls/create
@@ -349,38 +431,97 @@ pub async fn urls_create(
code = generate_token(3);
} else {
if code.len() != 6 || !code.chars().all(|c| c.is_ascii_hexdigit()) {
return Redirect::to("/admin/urls?error=Custom code must be exactly 6 hex characters").into_response();
return Redirect::to("/admin/urls?error=Custom code must be exactly 6 hex characters")
.into_response();
}
}
let tags_list: Vec<String> = form.tags
let expires_at_opt = if form.expires_at.trim().is_empty() {
None
} else {
let mut rfc = form.expires_at.trim().to_string();
if rfc.len() == 16 {
rfc.push_str(":00Z"); // convert HTML datetime-local to standard UTC RFC3339
}
Some(rfc)
};
let password_hash_opt = if form.password.trim().is_empty() {
None
} else {
match hash_password(&form.password) {
Ok(h) => Some(h),
Err(_) => return Redirect::to("/admin/urls?error=Hashing error").into_response(),
}
};
let max_access_count_opt = if form.max_access_count.trim().is_empty() {
None
} else {
match form.max_access_count.trim().parse::<i64>() {
Ok(c) => Some(c),
Err(_) => {
return Redirect::to("/admin/urls?error=Invalid max access count").into_response()
}
}
};
let tags_list: Vec<String> = form
.tags
.split(',')
.map(|t| t.trim().to_string())
.filter(|t| !t.is_empty())
.collect();
let title_opt = if form.title.trim().is_empty() { None } else { Some(form.title.trim()) };
let desc_opt = if form.description.trim().is_empty() { None } else { Some(form.description.trim()) };
let title_opt = if form.title.trim().is_empty() {
None
} else {
Some(form.title.trim())
};
let desc_opt = if form.description.trim().is_empty() {
None
} else {
Some(form.description.trim())
};
let res = {
let conn = state.content_db.lock().unwrap();
create_url(&conn, &code, &form.destination, title_opt, desc_opt, &tags_list)
crate::db::content::create_url_extended(
&conn,
&code,
&form.destination,
title_opt,
desc_opt,
&tags_list,
expires_at_opt.as_deref(),
password_hash_opt.as_deref(),
max_access_count_opt,
)
};
match res {
Ok(url) => {
{
let conn = state.admin_db.lock().unwrap();
let _ = write_audit_log(&conn, &user.username, "URL_CREATION", Some("url"), Some(&url.id), Some(&ip), headers.get("user-agent").and_then(|h| h.to_str().ok()));
let _ = write_audit_log(
&conn,
&state,
&user.username,
"URL_CREATION",
Some("url"),
Some(&url.id),
Some(&ip),
headers.get("user-agent").and_then(|h| h.to_str().ok()),
);
}
Redirect::to("/admin/urls").into_response()
}
Err(rusqlite::Error::SqliteFailure(err, _)) if err.code == rusqlite::ErrorCode::ConstraintViolation => {
Err(rusqlite::Error::SqliteFailure(err, _))
if err.code == rusqlite::ErrorCode::ConstraintViolation =>
{
Redirect::to("/admin/urls?error=Short code already exists").into_response()
}
Err(e) => {
Redirect::to(&format!("/admin/urls?error=Database error: {}", e)).into_response()
}
Err(e) => Redirect::to(&format!("/admin/urls?error=Database error: {}", e)).into_response(),
}
}
@@ -410,11 +551,22 @@ pub async fn urls_delete(
Ok(_) => {
{
let conn_admin = state.admin_db.lock().unwrap();
let _ = write_audit_log(&conn_admin, &user.username, "URL_DELETION", Some("url"), Some(&id), Some(&ip), headers.get("user-agent").and_then(|h| h.to_str().ok()));
let _ = write_audit_log(
&conn_admin,
&state,
&user.username,
"URL_DELETION",
Some("url"),
Some(&id),
Some(&ip),
headers.get("user-agent").and_then(|h| h.to_str().ok()),
);
}
Redirect::to("/admin/urls").into_response()
}
Err(e) => Redirect::to(&format!("/admin/urls?error=Failed to delete link: {}", e)).into_response(),
Err(e) => {
Redirect::to(&format!("/admin/urls?error=Failed to delete link: {}", e)).into_response()
}
}
}
@@ -484,7 +636,8 @@ pub async fn pages_create(
code = generate_token(2);
} else {
if code.len() != 4 || !code.chars().all(|c| c.is_ascii_hexdigit()) {
return Redirect::to("/admin/pages?error=Custom code must be exactly 4 hex characters").into_response();
return Redirect::to("/admin/pages?error=Custom code must be exactly 4 hex characters")
.into_response();
}
}
@@ -495,18 +648,36 @@ pub async fn pages_create(
let res = {
let conn = state.content_db.lock().unwrap();
create_landing_page(&conn, &code, &clean_slug, &form.title, &form.html_content, &form.state)
create_landing_page(
&conn,
&code,
&clean_slug,
&form.title,
&form.html_content,
&form.state,
)
};
match res {
Ok(page) => {
{
let conn_admin = state.admin_db.lock().unwrap();
let _ = write_audit_log(&conn_admin, &user.username, "PAGE_CREATION", Some("page"), Some(&page.id), Some(&ip), headers.get("user-agent").and_then(|h| h.to_str().ok()));
let _ = write_audit_log(
&conn_admin,
&state,
&user.username,
"PAGE_CREATION",
Some("page"),
Some(&page.id),
Some(&ip),
headers.get("user-agent").and_then(|h| h.to_str().ok()),
);
}
Redirect::to("/admin/pages").into_response()
}
Err(rusqlite::Error::SqliteFailure(err, _)) if err.code == rusqlite::ErrorCode::ConstraintViolation => {
Err(rusqlite::Error::SqliteFailure(err, _))
if err.code == rusqlite::ErrorCode::ConstraintViolation =>
{
Redirect::to("/admin/pages?error=Short code already exists").into_response()
}
Err(e) => {
@@ -541,11 +712,21 @@ pub async fn pages_delete(
Ok(_) => {
{
let conn_admin = state.admin_db.lock().unwrap();
let _ = write_audit_log(&conn_admin, &user.username, "PAGE_DELETION", Some("page"), Some(&id), Some(&ip), headers.get("user-agent").and_then(|h| h.to_str().ok()));
let _ = write_audit_log(
&conn_admin,
&state,
&user.username,
"PAGE_DELETION",
Some("page"),
Some(&id),
Some(&ip),
headers.get("user-agent").and_then(|h| h.to_str().ok()),
);
}
Redirect::to("/admin/pages").into_response()
}
Err(e) => Redirect::to(&format!("/admin/pages?error=Failed to delete page: {}", e)).into_response(),
Err(e) => Redirect::to(&format!("/admin/pages?error=Failed to delete page: {}", e))
.into_response(),
}
}
@@ -575,7 +756,13 @@ pub async fn settings_get(
let conn = state.admin_db.lock().unwrap();
get_config(&conn, "retention_days")
.unwrap_or(None)
.unwrap_or_else(|| state.config.data_retention_days.map(|d| d.to_string()).unwrap_or_else(|| "unlimited".to_string()))
.unwrap_or_else(|| {
state
.config
.data_retention_days
.map(|d| d.to_string())
.unwrap_or_else(|| "unlimited".to_string())
})
};
let csrf_token = generate_csrf_token(&session_id);
@@ -620,7 +807,16 @@ pub async fn change_password_post(
let conn = state.admin_db.lock().unwrap();
if !verify_password(&form.current_password, &user.password_hash) {
let _ = write_audit_log(&conn, &user.username, "PASSWORD_CHANGE_FAIL", Some("user"), Some(&user.id), Some(&ip), headers.get("user-agent").and_then(|h| h.to_str().ok()));
let _ = write_audit_log(
&conn,
&state,
&user.username,
"PASSWORD_CHANGE_FAIL",
Some("user"),
Some(&user.id),
Some(&ip),
headers.get("user-agent").and_then(|h| h.to_str().ok()),
);
return Redirect::to("/admin/settings?error=Incorrect current password").into_response();
}
@@ -629,15 +825,29 @@ pub async fn change_password_post(
Err(_) => return Redirect::to("/admin/settings?error=Hashing error").into_response(),
};
let res = conn.execute("UPDATE users SET password_hash = ?1 WHERE id = ?2;", params![new_hash, user.id]);
let res = conn.execute(
"UPDATE users SET password_hash = ?1 WHERE id = ?2;",
params![new_hash, user.id],
);
match res {
Ok(_) => {
let _ = write_audit_log(&conn, &user.username, "PASSWORD_CHANGE_SUCCESS", Some("user"), Some(&user.id), Some(&ip), headers.get("user-agent").and_then(|h| h.to_str().ok()));
let _ = write_audit_log(
&conn,
&state,
&user.username,
"PASSWORD_CHANGE_SUCCESS",
Some("user"),
Some(&user.id),
Some(&ip),
headers.get("user-agent").and_then(|h| h.to_str().ok()),
);
Redirect::to("/admin/settings?success=Password updated successfully").into_response()
}
Err(e) => {
Redirect::to(&format!("/admin/settings?error=Failed to update password: {}", e)).into_response()
}
Err(e) => Redirect::to(&format!(
"/admin/settings?error=Failed to update password: {}",
e
))
.into_response(),
}
}
@@ -669,10 +879,21 @@ pub async fn change_retention_post(
let conn = state.admin_db.lock().unwrap();
match set_config(&conn, "retention_days", &form.retention) {
Ok(_) => {
let _ = write_audit_log(&conn, &user.username, "RETENTION_POLICY_CHANGED", Some("config"), Some("retention_days"), Some(&ip), headers.get("user-agent").and_then(|h| h.to_str().ok()));
let _ = write_audit_log(
&conn,
&state,
&user.username,
"RETENTION_POLICY_CHANGED",
Some("config"),
Some("retention_days"),
Some(&ip),
headers.get("user-agent").and_then(|h| h.to_str().ok()),
);
Redirect::to("/admin/settings?success=Retention policy saved").into_response()
}
Err(e) => Redirect::to(&format!("/admin/settings?error=Database error: {}", e)).into_response(),
Err(e) => {
Redirect::to(&format!("/admin/settings?error=Database error: {}", e)).into_response()
}
}
}
@@ -693,10 +914,22 @@ pub async fn compact_db_post(
match state.db_compact() {
Ok(_) => {
let conn = state.admin_db.lock().unwrap();
let _ = write_audit_log(&conn, &user.username, "DATABASE_COMPACTION", Some("system"), Some("all_dbs"), Some(&ip), headers.get("user-agent").and_then(|h| h.to_str().ok()));
Redirect::to("/admin/settings?success=Database files compacted successfully").into_response()
let _ = write_audit_log(
&conn,
&state,
&user.username,
"DATABASE_COMPACTION",
Some("system"),
Some("all_dbs"),
Some(&ip),
headers.get("user-agent").and_then(|h| h.to_str().ok()),
);
Redirect::to("/admin/settings?success=Database files compacted successfully")
.into_response()
}
Err(e) => {
Redirect::to(&format!("/admin/settings?error=Failed to compact: {}", e)).into_response()
}
Err(e) => Redirect::to(&format!("/admin/settings?error=Failed to compact: {}", e)).into_response(),
}
}
@@ -719,7 +952,7 @@ pub async fn download_backup(
let res = {
let enc = GzEncoder::new(&mut buffer, Compression::default());
let mut tar = Builder::new(enc);
let files = vec!["admin.db", "content.db", "analytics.db", "system.db"];
let mut add_err = None;
for f in files {
@@ -731,15 +964,13 @@ pub async fn download_backup(
}
}
}
match add_err {
Some(e) => Err(e),
None => {
match tar.into_inner().and_then(|encoder| encoder.finish()) {
Ok(_) => Ok(()),
Err(e) => Err(e),
}
}
None => match tar.into_inner().and_then(|encoder| encoder.finish()) {
Ok(_) => Ok(()),
Err(e) => Err(e),
},
}
};
@@ -747,7 +978,16 @@ pub async fn download_backup(
Ok(_) => {
{
let conn = state.admin_db.lock().unwrap();
let _ = write_audit_log(&conn, &user.username, "DATABASE_BACKUP", Some("system"), Some("tarball"), Some(&ip), headers.get("user-agent").and_then(|h| h.to_str().ok()));
let _ = write_audit_log(
&conn,
&state,
&user.username,
"DATABASE_BACKUP",
Some("system"),
Some("tarball"),
Some(&ip),
headers.get("user-agent").and_then(|h| h.to_str().ok()),
);
}
let date_str = Utc::now().format("%Y-%m-%d").to_string();
@@ -757,10 +997,14 @@ pub async fn download_backup(
StatusCode::OK,
[
("Content-Type", "application/gzip"),
("Content-Disposition", &format!("attachment; filename=\"{}\"", filename)),
(
"Content-Disposition",
&format!("attachment; filename=\"{}\"", filename),
),
],
buffer,
).into_response()
)
.into_response()
}
Err(e) => {
Redirect::to(&format!("/admin/settings?error=Backup failed: {}", e)).into_response()
@@ -793,8 +1037,8 @@ pub async fn create_api_key_post(
let ip = get_client_ip(&headers, connect_info);
let key_secret = format!("bzo_{}", generate_token(16));
use sha2::{Sha256, Digest};
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(key_secret.as_bytes());
let hashed_key = hex::encode(hasher.finalize());
@@ -802,13 +1046,24 @@ pub async fn create_api_key_post(
let conn = state.admin_db.lock().unwrap();
match create_api_key(&conn, &user.id, &form.key_name, &hashed_key) {
Ok(api_key) => {
let _ = write_audit_log(&conn, &user.username, "API_KEY_CREATED", Some("api_key"), Some(&api_key.id), Some(&ip), headers.get("user-agent").and_then(|h| h.to_str().ok()));
let _ = write_audit_log(
&conn,
&state,
&user.username,
"API_KEY_CREATED",
Some("api_key"),
Some(&api_key.id),
Some(&ip),
headers.get("user-agent").and_then(|h| h.to_str().ok()),
);
Redirect::to(&format!(
"/admin/settings?success=Token generated successfully. **IMPORTANT: Copy your token now, it will never be shown again!** Token value: {}",
key_secret
)).into_response()
}
Err(e) => Redirect::to(&format!("/admin/settings?error=Database error: {}", e)).into_response(),
Err(e) => {
Redirect::to(&format!("/admin/settings?error=Database error: {}", e)).into_response()
}
}
}
@@ -836,26 +1091,157 @@ pub async fn revoke_api_key_post(
let conn = state.admin_db.lock().unwrap();
match delete_api_key(&conn, &id) {
Ok(_) => {
let _ = write_audit_log(&conn, &user.username, "API_KEY_REVOKED", Some("api_key"), Some(&id), Some(&ip), headers.get("user-agent").and_then(|h| h.to_str().ok()));
let _ = write_audit_log(
&conn,
&state,
&user.username,
"API_KEY_REVOKED",
Some("api_key"),
Some(&id),
Some(&ip),
headers.get("user-agent").and_then(|h| h.to_str().ok()),
);
Redirect::to("/admin/settings?success=API Token revoked").into_response()
}
Err(e) => Redirect::to(&format!("/admin/settings?error=Failed to revoke key: {}", e)).into_response(),
Err(e) => Redirect::to(&format!(
"/admin/settings?error=Failed to revoke key: {}",
e
))
.into_response(),
}
}
#[derive(Deserialize)]
pub struct BulkQrExportForm {
pub format: String,
pub csrf_token: String,
}
// POST /admin/settings/bulk-qr
pub async fn bulk_qr_export_post(
State(state): State<AppState>,
jar: CookieJar,
headers: HeaderMap,
connect_info: Option<ConnectInfo<SocketAddr>>,
Form(form): Form<BulkQrExportForm>,
) -> Response {
let (user, session_id) = match require_auth(&state, &jar).await {
Ok(u) => u,
Err(redir) => return redir.into_response(),
};
if !verify_csrf(&session_id, &form.csrf_token) {
return Redirect::to("/admin/settings?error=Invalid CSRF token").into_response();
}
let ip = get_client_ip(&headers, connect_info);
let urls = {
let conn = state.content_db.lock().unwrap();
crate::db::content::list_urls(&conn, 500, 0, None).unwrap_or_default()
};
if urls.is_empty() {
return Redirect::to("/admin/settings?error=No shortened URLs found to export")
.into_response();
}
let proto = if state.config.cookie_secure {
"https"
} else {
"http"
};
let host_header = headers
.get("host")
.and_then(|h| h.to_str().ok())
.unwrap_or("localhost:8654");
let base_url = state
.config
.base_url
.clone()
.unwrap_or_else(|| format!("{}://{}", proto, host_header));
match crate::services::bulk::export_qr_zip(&urls, &form.format, &base_url) {
Ok(zip_data) => {
// Write Audit Log
let _ = write_audit_log(
&state.admin_db.lock().unwrap(),
&state,
&user.username,
"BULK_QR_EXPORT",
Some("bulk"),
Some("qr"),
Some(&ip),
headers.get("user-agent").and_then(|h| h.to_str().ok()),
);
Response::builder()
.header("content-type", "application/zip")
.header(
"content-disposition",
"attachment; filename=\"qr_codes.zip\"",
)
.body(axum::body::Body::from(zip_data))
.unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response())
}
Err(e) => {
Redirect::to(&format!("/admin/settings?error=Export failed: {}", e)).into_response()
}
}
}
// GET /admin/audit
pub async fn audit_get(
State(state): State<AppState>,
jar: CookieJar,
) -> Response {
pub async fn audit_get(State(state): State<AppState>, jar: CookieJar) -> Response {
let (user, _) = match require_auth(&state, &jar).await {
Ok(u) => u,
Err(redir) => return redir.into_response(),
};
let logs = {
let conn = state.admin_db.lock().unwrap();
list_audit_logs(&conn, 100, 0).unwrap_or_default()
let conn = state.system_db.lock().unwrap();
let events = crate::db::audit_events::list_audit_events(&conn, 100, 0, None, None)
.unwrap_or_default();
events
.into_iter()
.map(|e| {
let (ip, ua) = if let Some(ref m) = e.metadata {
if m.starts_with("IP: ") {
let parts: Vec<&str> = m.split(", UA: ").collect();
let ip = parts[0]
.trim_start_matches("IP: ")
.trim_matches('"')
.trim_matches('\'')
.replace("Some(", "")
.replace(")", "");
let ua = if parts.len() > 1 {
parts[1]
.trim_matches('"')
.trim_matches('\'')
.replace("Some(", "")
.replace(")", "")
} else {
"Unknown".to_string()
};
(Some(ip), Some(ua))
} else {
(None, None)
}
} else {
(None, None)
};
crate::models::AuditLog {
id: e.id,
timestamp: e.timestamp,
username: e.actor,
action: e.action,
object_type: Some(e.object_type),
object_id: Some(e.object_id),
ip_address: ip,
user_agent: ua,
}
})
.collect()
};
let template = crate::templates::AuditTemplate {
@@ -867,35 +1253,40 @@ pub async fn audit_get(
}
// GET /admin/status
pub async fn status_get(
State(state): State<AppState>,
jar: CookieJar,
) -> Response {
pub async fn status_get(State(state): State<AppState>, jar: CookieJar) -> Response {
let (user, _) = match require_auth(&state, &jar).await {
Ok(u) => u,
Err(redir) => return redir.into_response(),
};
let app_status = "Healthy";
let db_status = {
let conn_ok = {
let conn = state.admin_db.lock().unwrap();
get_user_count(&conn).is_ok()
};
if conn_ok {
format!("Operational\n\nDatabase Files:\n{}", get_db_file_info(&state.config.data_dir))
format!(
"Operational\n\nDatabase Files:\n{}",
get_db_file_info(&state.config.data_dir)
)
} else {
"Degraded (Database connections failed)".to_string()
}
};
let queue_size = 0;
let queue_size = 0;
let memory_usage = get_memory_usage();
let uptime_duration = state.start_time.elapsed();
let uptime = crate::utils::format_duration(uptime_duration);
let urls = {
let conn = state.content_db.lock().unwrap();
crate::db::content::list_urls(&conn, 50, 0, None).unwrap_or_default()
};
let template = crate::templates::StatusTemplate {
admin_username: user.username,
app_status,
@@ -905,6 +1296,7 @@ pub async fn status_get(
uptime,
version: "0.1.0",
git_commit: "unknown",
urls,
};
template.into_response()
+808 -57
View File
File diff suppressed because it is too large. Load diff
+288
View File
@@ -0,0 +1,288 @@
use crate::auth::generate_token;
use crate::auth::password::hash_password;
use crate::auth::ApiUser;
use crate::state::AppState;
use crate::utils::get_client_ip;
use axum::{
extract::{ConnectInfo, State},
http::{HeaderMap, StatusCode},
response::{IntoResponse, Json, Response},
};
use serde::{Deserialize, Serialize};
use std::net::SocketAddr;
#[derive(Deserialize)]
pub struct BulkQrRequest {
pub ids: Vec<String>,
pub format: Option<String>,
}
#[derive(Deserialize)]
pub struct BulkUrlItem {
pub destination: String,
pub code: Option<String>,
pub title: Option<String>,
pub description: Option<String>,
pub tags: Option<Vec<String>>,
pub expires_at: Option<String>,
pub password: Option<String>,
pub max_access_count: Option<i64>,
}
#[derive(Serialize)]
pub struct BulkErrorResponse {
pub error: String,
}
// POST /api/v1/bulk/qr
pub async fn api_bulk_qr(
State(state): State<AppState>,
headers: HeaderMap,
user: ApiUser,
Json(payload): Json<BulkQrRequest>,
) -> Response {
if payload.ids.len() > 500 {
return (
StatusCode::BAD_REQUEST,
Json(BulkErrorResponse {
error: "Maximum 500 QR codes allowed per bulk request".to_string(),
}),
)
.into_response();
}
let format = payload
.format
.unwrap_or_else(|| "png".to_string())
.to_lowercase();
if format != "png" && format != "svg" {
return (
StatusCode::BAD_REQUEST,
Json(BulkErrorResponse {
error: "Invalid format. Supported: png, svg".to_string(),
}),
)
.into_response();
}
// Retrieve URLs from database
let mut urls = Vec::new();
{
let conn = state.content_db.lock().unwrap();
for id in &payload.ids {
match crate::db::content::get_url_by_id(&conn, id) {
Ok(Some(url)) => urls.push(url),
Ok(None) => {}
Err(e) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(BulkErrorResponse {
error: format!("Database error fetching URL {}: {}", id, e),
}),
)
.into_response();
}
}
}
}
if urls.is_empty() {
return (
StatusCode::BAD_REQUEST,
Json(BulkErrorResponse {
error: "No valid URLs found for the provided IDs".to_string(),
}),
)
.into_response();
}
// Base URL configuration check
let proto = if state.config.cookie_secure {
"https"
} else {
"http"
};
let host_header = headers
.get("host")
.and_then(|h| h.to_str().ok())
.unwrap_or("localhost:8654");
let base_url = state
.config
.base_url
.clone()
.unwrap_or_else(|| format!("{}://{}", proto, host_header));
// Generate ZIP
match crate::services::bulk::export_qr_zip(&urls, &format, &base_url) {
Ok(zip_data) => {
// Write Audit Log
{
let system_conn = state.db.system.lock().unwrap();
let _ = crate::db::audit_events::write_audit_event(
&system_conn,
&user.0.username,
"BULK_QR_EXPORT",
"bulk",
"qr",
Some(&format!("Count: {}, Format: {}", urls.len(), format)),
);
}
Response::builder()
.header("content-type", "application/zip")
.header(
"content-disposition",
"attachment; filename=\"qr_codes.zip\"",
)
.body(axum::body::Body::from(zip_data))
.unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response())
}
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(BulkErrorResponse {
error: format!("Error generating ZIP: {}", e),
}),
)
.into_response(),
}
}
// POST /api/v1/bulk/url
pub async fn api_bulk_url(
State(state): State<AppState>,
headers: HeaderMap,
connect_info: Option<ConnectInfo<SocketAddr>>,
user: ApiUser,
Json(payload): Json<Vec<BulkUrlItem>>,
) -> Response {
if payload.len() > 500 {
return (
StatusCode::BAD_REQUEST,
Json(BulkErrorResponse {
error: "Maximum 500 URLs allowed per bulk creation".to_string(),
}),
)
.into_response();
}
let mut conn = state.content_db.lock().unwrap();
let tx = match conn.transaction() {
Ok(t) => t,
Err(e) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(BulkErrorResponse {
error: format!("Failed to start database transaction: {}", e),
}),
)
.into_response()
}
};
let mut created_urls = Vec::new();
for item in payload {
let mut code = item.code.unwrap_or_default().trim().to_lowercase();
if code.is_empty() {
code = generate_token(3); // 6 hex
} else {
if code.len() != 6 || !code.chars().all(|c| c.is_ascii_hexdigit()) {
let _ = tx.rollback();
return (
StatusCode::BAD_REQUEST,
Json(BulkErrorResponse {
error: format!("Short code '{}' must be 6 hex characters", code),
}),
)
.into_response();
}
}
let password_hash = if let Some(ref pwd) = item.password {
match hash_password(pwd) {
Ok(h) => Some(h),
Err(e) => {
let _ = tx.rollback();
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(BulkErrorResponse {
error: format!("Password hashing error: {}", e),
}),
)
.into_response();
}
}
} else {
None
};
let tags = item.tags.unwrap_or_default();
match crate::db::content::create_url_extended(
&tx,
&code,
&item.destination,
item.title.as_deref(),
item.description.as_deref(),
&tags,
item.expires_at.as_deref(),
password_hash.as_deref(),
item.max_access_count,
) {
Ok(url) => created_urls.push(url),
Err(rusqlite::Error::SqliteFailure(err, _))
if err.code == rusqlite::ErrorCode::ConstraintViolation =>
{
let _ = tx.rollback();
return (
StatusCode::CONFLICT,
Json(BulkErrorResponse {
error: format!("Short code '{}' already exists", code),
}),
)
.into_response();
}
Err(e) => {
let _ = tx.rollback();
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(BulkErrorResponse {
error: format!("Database insert error: {}", e),
}),
)
.into_response();
}
}
}
if let Err(e) = tx.commit() {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(BulkErrorResponse {
error: format!("Failed to commit transaction: {}", e),
}),
)
.into_response();
}
// Write Audit Log for the entire batch
let ip = get_client_ip(&headers, connect_info);
let user_agent = headers.get("user-agent").and_then(|h| h.to_str().ok());
{
let system_conn = state.db.system.lock().unwrap();
let _ = crate::db::audit_events::write_audit_event(
&system_conn,
&user.0.username,
"BULK_URL_CREATION",
"bulk",
"url",
Some(&format!(
"Count: {}, IP: {:?}, User-Agent: {:?}",
created_urls.len(),
ip,
user_agent
)),
);
}
(StatusCode::CREATED, Json(created_urls)).into_response()
}
+7 -4
View File
@@ -1,9 +1,12 @@
pub mod routes;
pub mod middleware;
pub mod redirect;
pub mod pages;
pub mod admin;
pub mod api;
pub mod bulk;
pub mod middleware;
pub mod pages;
pub mod password_gate;
pub mod qr;
pub mod redirect;
pub mod routes;
pub mod system;
pub use routes::create_router;
+12 -9
View File
@@ -1,17 +1,17 @@
use axum::{
extract::{Path, State, ConnectInfo},
extract::{ConnectInfo, Path, State},
http::{HeaderMap, StatusCode},
response::{Response, Html, IntoResponse},
response::{Html, IntoResponse, Response},
};
use chrono::Utc;
use std::net::SocketAddr;
use uuid::Uuid;
use chrono::Utc;
use crate::state::AppState;
use crate::models::VisitRecord;
use crate::utils::get_client_ip;
use crate::analytics::get_client_country;
use crate::models::VisitRecord;
use crate::services::landing_pages::get_landing_page_by_code;
use crate::state::AppState;
use crate::utils::get_client_ip;
// GET /p/:code and GET /p/:code/*slug
// Resolve and render landing page
@@ -40,15 +40,18 @@ pub async fn resolve_page(
// Record view analytics
let ip = get_client_ip(&headers, connect_info);
let country = get_client_country(&headers);
let user_agent = headers.get("user-agent")
let user_agent = headers
.get("user-agent")
.and_then(|h| h.to_str().ok())
.unwrap_or("Unknown")
.to_string();
let referer = headers.get("referer")
let referer = headers
.get("referer")
.and_then(|h| h.to_str().ok())
.unwrap_or("Direct")
.to_string();
let accept_language = headers.get("accept-language")
let accept_language = headers
.get("accept-language")
.and_then(|h| h.to_str().ok())
.unwrap_or("Unknown")
.to_string();
+75
View File
@@ -0,0 +1,75 @@
use axum::{
extract::{Path, State},
response::{IntoResponse, Redirect, Response},
Form,
};
use axum_extra::extract::{cookie::Cookie, CookieJar};
use serde::Deserialize;
use crate::auth::password::verify_password;
use crate::services::shortener::get_url_by_code;
use crate::state::AppState;
use crate::templates::GateTemplate;
#[derive(Deserialize)]
pub struct PasswordGateForm {
pub password: String,
}
// GET /gate/:code
pub async fn gate_get(Path(code): Path<String>) -> impl IntoResponse {
GateTemplate { code, error: None }
}
// POST /gate/:code
pub async fn gate_post(
State(state): State<AppState>,
Path(code): Path<String>,
jar: CookieJar,
Form(form): Form<PasswordGateForm>,
) -> Response {
let url_opt = match get_url_by_code(&state.db, &code) {
Ok(url) => url,
Err(_) => {
return (
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
"Database error",
)
.into_response()
}
};
let url = match url_opt {
Some(u) => u,
None => return (axum::http::StatusCode::NOT_FOUND, "Url not found").into_response(),
};
let password_hash = match url.password_hash {
Some(ref h) => h,
None => {
// Not password protected, redirect to resolution
return Redirect::temporary(&format!("/{}", code)).into_response();
}
};
if verify_password(&form.password, password_hash) {
// Correct password - set 15 min temporary cookie
let cookie_name = format!("bzod_gate_{}", code);
let cookie = Cookie::build((cookie_name, "authorized"))
.secure(state.config.cookie_secure)
.same_site(axum_extra::extract::cookie::SameSite::Strict)
.http_only(true)
.path("/")
.max_age(time::Duration::minutes(15));
let updated_jar = jar.add(cookie);
(updated_jar, Redirect::temporary(&format!("/{}", code))).into_response()
} else {
// Invalid password
GateTemplate {
code,
error: Some("Invalid password".to_string()),
}
.into_response()
}
}
+161
View File
@@ -0,0 +1,161 @@
use crate::services::qr::{generate_qr_png, generate_qr_svg};
use crate::services::shortener::get_url_by_code;
use crate::state::AppState;
use crate::utils::get_client_ip;
use axum::{
extract::{ConnectInfo, Path, State},
http::{HeaderMap, StatusCode},
response::{IntoResponse, Response},
};
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)
pub async fn qr_handler(
State(state): State<AppState>,
headers: HeaderMap,
connect_info: Option<ConnectInfo<SocketAddr>>,
Path(file): Path<String>,
) -> Response {
let parts: Vec<&str> = file.split('.').collect();
if parts.len() != 2 {
// No extension: this is a JSON stats request!
let auth_header = headers.get("Authorization").and_then(|h| h.to_str().ok());
let authenticated = if let Some(auth) = auth_header {
let conn = state.admin_db.lock().unwrap();
matches!(
crate::auth::session::authenticate_api_key(&conn, auth),
Ok(Some(_user))
)
} else {
false
};
if !authenticated {
return (StatusCode::UNAUTHORIZED, "Unauthorized").into_response();
}
let url_opt = match get_url_by_code(&state.db, &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 = state.analytics_db.lock().unwrap();
crate::db::qr::get_qr_scan_count(&conn, &url.id).unwrap_or(0)
};
let direct_clicks = {
let conn = state.analytics_db.lock().unwrap();
conn.query_row(
"SELECT COUNT(*) FROM visits WHERE target_type = 'url' AND target_id = ?1;",
rusqlite::params![url.id],
|row| row.get(0),
)
.unwrap_or(0)
};
return axum::response::Json(json!({
"direct_clicks": direct_clicks,
"qr_scans": qr_scans
}))
.into_response();
}
let code = parts[0];
let ext = parts[1].to_lowercase();
if code.len() != 6 || !code.chars().all(|c| c.is_ascii_hexdigit()) {
return (StatusCode::NOT_FOUND, "Not Found").into_response();
}
let url_opt = match get_url_by_code(&state.db, 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"
} else {
"http"
};
let host_header = headers
.get("host")
.and_then(|h| h.to_str().ok())
.unwrap_or("localhost:8654");
let base_url = state
.config
.base_url
.clone()
.unwrap_or_else(|| format!("{}://{}", proto, host_header));
let full_url = format!("{}/{}", base_url.trim_end_matches('/'), code);
// Generate QR code based on format
let (body, content_type) = if ext == "svg" {
match generate_qr_svg(&full_url) {
Ok(svg) => (svg.into_bytes(), "image/svg+xml"),
Err(e) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
format!("QR generation error: {}", e),
)
.into_response()
}
}
} else if ext == "png" {
match generate_qr_png(&full_url, 256) {
Ok(png) => (png, "image/png"),
Err(e) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
format!("QR generation error: {}", e),
)
.into_response()
}
}
} else {
return (
StatusCode::BAD_REQUEST,
"Unsupported format. Use .png or .svg",
)
.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());
{
let analytics_conn = state.db.analytics.lock().unwrap();
let _ = crate::db::qr::log_qr_access(
&analytics_conn,
&url.id,
Some(ip.as_str()),
user_agent.as_deref(),
);
}
Response::builder()
.header("content-type", content_type)
.header("cache-control", "public, max-age=86400") // cache for 1 day
.body(axum::body::Body::from(body))
.unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response())
}
+113 -41
View File
@@ -1,22 +1,25 @@
use axum::{
extract::{Path, State, ConnectInfo},
extract::{ConnectInfo, Path, State},
http::{HeaderMap, StatusCode},
response::{Redirect, Response, IntoResponse},
response::{IntoResponse, Redirect, Response},
};
use axum_extra::extract::CookieJar;
use chrono::Utc;
use std::net::SocketAddr;
use uuid::Uuid;
use chrono::Utc;
use crate::state::AppState;
use crate::models::VisitRecord;
use crate::utils::get_client_ip;
use crate::analytics::get_client_country;
use crate::models::VisitRecord;
use crate::services::shortener::get_url_by_code;
use crate::state::AppState;
use crate::templates::PreviewTemplate;
use crate::utils::get_client_ip;
// GET /:code
// Resolve and redirect
pub async fn resolve_redirect(
State(state): State<AppState>,
jar: CookieJar,
Path(code): Path<String>,
headers: HeaderMap,
connect_info: Option<ConnectInfo<SocketAddr>>,
@@ -31,43 +34,112 @@ pub async fn resolve_redirect(
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
};
match url_opt {
Some(url) => {
// Asynchronously record analytics
let ip = get_client_ip(&headers, connect_info);
let country = get_client_country(&headers);
let user_agent = headers.get("user-agent")
.and_then(|h| h.to_str().ok())
.unwrap_or("Unknown")
.to_string();
let referer = headers.get("referer")
.and_then(|h| h.to_str().ok())
.unwrap_or("Direct")
.to_string();
let accept_language = headers.get("accept-language")
.and_then(|h| h.to_str().ok())
.unwrap_or("Unknown")
.to_string();
let url = match url_opt {
Some(u) => u,
None => return (StatusCode::NOT_FOUND, "Short code not found").into_response(),
};
let record = VisitRecord {
id: Uuid::new_v4().to_string(),
target_type: "url".to_string(),
target_id: url.id.clone(),
timestamp: Utc::now().to_rfc3339(),
ip_address: ip,
user_agent,
referer,
accept_language,
country,
status_code: 302,
};
// 1. Expiration check
if url.expired {
return (StatusCode::GONE, "This link has expired").into_response();
}
// Push to memory queue (non-blocking)
state.analytics_queue.push(record);
// Perform redirect
Redirect::temporary(&url.destination).into_response()
if let Some(ref expires_at_str) = url.expires_at {
if let Ok(expires_at) = chrono::DateTime::parse_from_rfc3339(expires_at_str) {
if expires_at.with_timezone(&Utc) < Utc::now() {
// Mark as expired in DB asynchronously/immediately
{
let conn = state.db.content.lock().unwrap();
let _ = conn.execute(
"UPDATE urls SET expired = 1 WHERE id = ?1;",
[url.id.clone()],
);
}
return (StatusCode::GONE, "This link has expired").into_response();
}
}
None => (StatusCode::NOT_FOUND, "Short code not found").into_response(),
}
// 2. Access limit check
if url.is_access_exhausted() {
return (
StatusCode::GONE,
"This link has reached its maximum access limit",
)
.into_response();
}
// 3. Password protection check
if url.is_password_protected() {
let cookie_name = format!("bzod_gate_{}", code);
let authorized = jar
.get(&cookie_name)
.map(|c| c.value() == "authorized")
.unwrap_or(false);
if !authorized {
return Redirect::temporary(&format!("/gate/{}", code)).into_response();
}
}
// 4. Increment access count & retrieve preview config
let _new_access_count = {
let conn = state.db.content.lock().unwrap();
crate::db::content::increment_access_count(&conn, &url.id).unwrap_or(url.access_count + 1)
};
let preview_opt = {
let conn = state.db.content.lock().unwrap();
crate::db::preview::get_preview(&conn, &url.id).unwrap_or(None)
};
// Asynchronously record analytics
let ip = get_client_ip(&headers, connect_info);
let country = get_client_country(&headers);
let user_agent = headers
.get("user-agent")
.and_then(|h| h.to_str().ok())
.unwrap_or("Unknown")
.to_string();
let referer = headers
.get("referer")
.and_then(|h| h.to_str().ok())
.unwrap_or("Direct")
.to_string();
let accept_language = headers
.get("accept-language")
.and_then(|h| h.to_str().ok())
.unwrap_or("Unknown")
.to_string();
let record = VisitRecord {
id: Uuid::new_v4().to_string(),
target_type: "url".to_string(),
target_id: url.id.clone(),
timestamp: Utc::now().to_rfc3339(),
ip_address: ip,
user_agent,
referer,
accept_language,
country,
status_code: if preview_opt.is_some() { 200 } else { 302 },
};
// Push to memory queue (non-blocking)
state.analytics_queue.push(record);
// 5. Render Preview or Redirect
if let Some(preview) = preview_opt {
PreviewTemplate {
code,
title: preview.title,
description: preview.description,
logo_url: preview.logo_url,
button_text: preview.button_text,
destination: url.destination,
}
.into_response()
} else {
Redirect::temporary(&url.destination).into_response()
}
}
+77 -21
View File
@@ -1,9 +1,9 @@
use axum::{
Router,
routing::{get, post},
};
use crate::state::AppState;
use crate::web::{redirect, pages, admin, api, system};
use crate::web::{admin, api, bulk, pages, password_gate, qr, redirect, system};
use axum::{
routing::{get, post},
Router,
};
pub fn create_router(state: AppState) -> Router {
Router::new()
@@ -11,16 +11,22 @@ pub fn create_router(state: AppState) -> Router {
.route("/:code", get(redirect::resolve_redirect))
.route("/p/:code", get(pages::resolve_page))
.route("/p/:code/*slug", get(pages::resolve_page))
.route(
"/gate/:code",
get(password_gate::gate_get).post(password_gate::gate_post),
)
// --- QR Code Serving ---
.route("/api/qr/:file", get(qr::qr_handler))
// --- System Health & Diagnostics ---
.route("/status", get(system::status_endpoint))
.route("/metrics", get(system::metrics_endpoint))
// --- Admin UI Login/Logout ---
.route("/admin", get(admin::admin_index))
.route("/admin/login", get(admin::login_get).post(admin::login_post))
.route(
"/admin/login",
get(admin::login_get).post(admin::login_post),
)
.route("/admin/logout", get(admin::logout))
// --- Admin UI Pages ---
.route("/admin/dashboard", get(admin::dashboard_get))
.route("/admin/urls", get(admin::urls_get))
@@ -30,26 +36,76 @@ pub fn create_router(state: AppState) -> Router {
.route("/admin/pages/create", post(admin::pages_create))
.route("/admin/pages/delete/:id", post(admin::pages_delete))
.route("/admin/settings", get(admin::settings_get))
.route("/admin/settings/password", post(admin::change_password_post))
.route("/admin/settings/retention", post(admin::change_retention_post))
.route(
"/admin/settings/password",
post(admin::change_password_post),
)
.route(
"/admin/settings/retention",
post(admin::change_retention_post),
)
.route("/admin/settings/compact", post(admin::compact_db_post))
.route("/admin/settings/backup", get(admin::download_backup))
.route("/admin/settings/api-keys/create", post(admin::create_api_key_post))
.route("/admin/settings/api-keys/revoke/:id", post(admin::revoke_api_key_post))
.route("/admin/settings/bulk-qr", post(admin::bulk_qr_export_post))
.route(
"/admin/settings/api-keys/create",
post(admin::create_api_key_post),
)
.route(
"/admin/settings/api-keys/revoke/:id",
post(admin::revoke_api_key_post),
)
.route("/admin/audit", get(admin::audit_get))
.route("/admin/status", get(admin::status_get))
// --- REST API v1 JSON Endpoints ---
.route("/api/v1/urls", post(api::api_create_url).get(api::api_list_urls))
.route("/api/v1/urls/:uuid", get(api::api_get_url).put(api::api_update_url).delete(api::api_delete_url))
.route("/api/v1/pages", post(api::api_create_page).get(api::api_list_pages))
.route("/api/v1/pages/:uuid", get(api::api_get_page).put(api::api_update_page).delete(api::api_delete_page))
.route(
"/api/v1/urls",
post(api::api_create_url).get(api::api_list_urls),
)
.route(
"/api/v1/urls/:uuid",
get(api::api_get_url)
.put(api::api_update_url)
.delete(api::api_delete_url),
)
.route(
"/api/v1/pages",
post(api::api_create_page).get(api::api_list_pages),
)
.route(
"/api/v1/pages/:uuid",
get(api::api_get_page)
.put(api::api_update_page)
.delete(api::api_delete_page),
)
.route("/api/v1/stats", get(api::api_overall_stats))
.route("/api/v1/stats/url/:uuid", get(api::api_url_stats))
.route("/api/v1/stats/page/:uuid", get(api::api_page_stats))
// --- Feature Expansion REST API Endpoints ---
.route("/api/v1/qr/:code", get(api::api_get_qr_stats))
.route("/api/v1/bulk/qr", post(bulk::api_bulk_qr))
.route("/api/v1/bulk/url", post(bulk::api_bulk_url))
.route("/api/v1/audit", get(api::api_list_audit))
// --- Feature 10 Non-Versioned API Extensions ---
.route("/api/qr", post(api::api_create_qr))
.route("/api/bulk/qr", post(bulk::api_bulk_qr))
.route("/api/bulk/url", post(bulk::api_bulk_url))
.route("/api/stats", get(api::api_overall_stats))
.route("/api/audit", get(api::api_list_audit))
.route(
"/api/v1/urls/:uuid/preview",
post(api::api_set_preview)
.get(api::api_get_preview)
.delete(api::api_delete_preview),
)
.route(
"/api/v1/urls/:uuid/password",
post(api::api_set_password).delete(api::api_remove_password),
)
// --- Static Asset Stub ---
.route("/static/style.css", get(|| async { ([(axum::http::header::CONTENT_TYPE, "text/css")], "") }))
.route(
"/static/style.css",
get(|| async { ([(axum::http::header::CONTENT_TYPE, "text/css")], "") }),
)
.with_state(state)
}
+16 -10
View File
@@ -1,15 +1,15 @@
use axum::{
extract::State,
http::{HeaderMap, StatusCode},
response::{IntoResponse, Response, Json},
response::{IntoResponse, Json, Response},
};
use axum_extra::extract::CookieJar;
use serde::Serialize;
use crate::auth::{authenticate_api_key, authenticate_session};
use crate::db::admin::get_user_count;
use crate::state::AppState;
use crate::utils::{get_memory_usage, get_db_file_info};
use crate::auth::{authenticate_session, authenticate_api_key};
use crate::utils::{get_db_file_info, get_memory_usage};
// Helper: authenticate system request via header or session cookie
fn authenticate_request(state: &AppState, jar: &CookieJar, headers: &HeaderMap) -> bool {
@@ -51,8 +51,9 @@ pub async fn status_endpoint(
// Return public basic status for container/load-balancer health checks
return (
StatusCode::OK,
Json(serde_json::json!({ "application": "Healthy" }))
).into_response();
Json(serde_json::json!({ "application": "Healthy" })),
)
.into_response();
}
let is_db_ok = {
@@ -61,7 +62,10 @@ pub async fn status_endpoint(
};
let db_status = if is_db_ok {
format!("Connected (WAL Mode enabled). Files Info:\n{}", get_db_file_info(&state.config.data_dir))
format!(
"Connected (WAL Mode enabled). Files Info:\n{}",
get_db_file_info(&state.config.data_dir)
)
} else {
"Disconnected".to_string()
};
@@ -71,12 +75,13 @@ pub async fn status_endpoint(
Json(StatusResponse {
application: "Healthy",
database: db_status,
queue_size: 0,
queue_size: 0,
memory_usage: get_memory_usage(),
uptime_seconds: uptime,
version: "0.1.0",
git_commit: "unknown",
}).into_response()
})
.into_response()
}
// GET /metrics
@@ -104,7 +109,7 @@ pub async fn metrics_endpoint(
let conn = state.analytics_db.lock().unwrap();
(
crate::db::analytics::get_total_clicks(&conn).unwrap_or(0),
crate::db::analytics::get_total_page_views(&conn).unwrap_or(0)
crate::db::analytics::get_total_page_views(&conn).unwrap_or(0),
)
};
@@ -168,5 +173,6 @@ bzod_uptime_seconds {uptime}
StatusCode::OK,
[("Content-Type", "text/plain; version=0.0.4; charset=utf-8")],
metrics_text,
).into_response()
)
.into_response()
}
+177
View File
@@ -0,0 +1,177 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Password Protected Link - BZOD</title>
<style>
:root {
--bg-base: #070a13;
--bg-card: rgba(17, 24, 39, 0.7);
--border-color: rgba(255, 255, 255, 0.08);
--text-primary: #f8fafc;
--text-secondary: #94a3b8;
--primary-grad: linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%);
--accent-color: #6366f1;
--danger-color: #ef4444;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
background-color: var(--bg-base);
color: var(--text-primary);
font-family: 'Outfit', 'Inter', system-ui, -apple-system, sans-serif;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 1.5rem;
}
.gate-card {
background-color: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 16px;
padding: 2.5rem;
width: 100%;
max-width: 420px;
backdrop-filter: blur(12px);
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.4);
text-align: center;
}
.icon-container {
margin-bottom: 1.5rem;
display: inline-flex;
justify-content: center;
align-items: center;
width: 64px;
height: 64px;
border-radius: 50%;
background: rgba(99, 102, 241, 0.1);
border: 1px solid rgba(99, 102, 241, 0.2);
color: #818cf8;
}
.lock-icon {
width: 32px;
height: 32px;
fill: currentColor;
}
.title {
font-size: 1.5rem;
font-weight: 700;
margin-bottom: 0.5rem;
background: var(--primary-grad);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.subtitle {
color: var(--text-secondary);
font-size: 0.9rem;
margin-bottom: 2rem;
}
.form-group {
margin-bottom: 1.25rem;
text-align: left;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.form-group label {
font-size: 0.85rem;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.form-input {
background-color: rgba(15, 23, 42, 0.6);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 0.75rem 1rem;
color: var(--text-primary);
font-family: inherit;
font-size: 0.95rem;
width: 100%;
transition: all 0.2s ease;
}
.form-input:focus {
outline: none;
border-color: var(--accent-color);
box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.2);
}
.btn {
background: var(--primary-grad);
color: #fff;
border: none;
padding: 0.75rem 1.5rem;
border-radius: 8px;
font-weight: 600;
font-size: 0.95rem;
cursor: pointer;
width: 100%;
margin-top: 1rem;
transition: all 0.2s ease;
}
.btn:hover {
opacity: 0.9;
transform: translateY(-1px);
box-shadow: 0 4px 12px 0 rgba(99, 102, 241, 0.35);
}
.alert-error {
background-color: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.2);
color: #fca5a5;
padding: 0.75rem 1rem;
border-radius: 8px;
font-size: 0.85rem;
margin-bottom: 1.5rem;
text-align: left;
font-weight: 500;
}
</style>
</head>
<body>
<div class="gate-card">
<div class="icon-container">
<svg class="lock-icon" viewBox="0 0 24 24">
<path d="M18 8h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zm-6 9c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2zm3.1-9H8.9V6c0-1.71 1.39-3.1 3.1-3.1 1.71 0 3.1 1.39 3.1 3.1v2z"/>
</svg>
</div>
<h1 class="title">Secure Link</h1>
<p class="subtitle">This link is password-protected. Please enter the password to proceed.</p>
{% if let Some(err) = error %}
<div class="alert-error">
{{ err }}
</div>
{% endif %}
<form action="/gate/{{ code }}" method="POST">
<div class="form-group">
<label for="password">Password</label>
<input type="password" id="password" name="password" class="form-input" required autofocus autocomplete="current-password" placeholder="Enter password">
</div>
<button type="submit" class="btn">Unlock Link</button>
</form>
</div>
</body>
</html>
+184
View File
@@ -0,0 +1,184 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
{% if let Some(t) = title %}
<title>{{ t }}</title>
<meta property="og:title" content="{{ t }}">
<meta name="twitter:title" content="{{ t }}">
{% else %}
<title>Link Preview - BZOD</title>
<meta property="og:title" content="Link Preview">
<meta name="twitter:title" content="Link Preview">
{% endif %}
{% if let Some(d) = description %}
<meta name="description" content="{{ d }}">
<meta property="og:description" content="{{ d }}">
<meta name="twitter:description" content="{{ d }}">
{% endif %}
{% if let Some(l) = logo_url %}
<meta property="og:image" content="{{ l }}">
<meta name="twitter:image" content="{{ l }}">
{% endif %}
<meta property="og:type" content="website">
<meta name="twitter:card" content="summary_large_image">
<style>
:root {
--bg-base: #070a13;
--bg-card: rgba(17, 24, 39, 0.7);
--border-color: rgba(255, 255, 255, 0.08);
--text-primary: #f8fafc;
--text-secondary: #94a3b8;
--primary-grad: linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%);
--accent-color: #6366f1;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
background-color: var(--bg-base);
color: var(--text-primary);
font-family: 'Outfit', 'Inter', system-ui, -apple-system, sans-serif;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 1.5rem;
}
.preview-card {
background-color: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 20px;
padding: 3rem 2.5rem;
width: 100%;
max-width: 500px;
backdrop-filter: blur(12px);
box-shadow: 0 12px 40px 0 rgba(0, 0, 0, 0.5);
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
}
.logo-container {
margin-bottom: 2rem;
display: flex;
justify-content: center;
align-items: center;
width: 96px;
height: 96px;
border-radius: 24px;
background: rgba(255, 255, 255, 0.03);
border: 1px solid var(--border-color);
overflow: hidden;
}
.logo-img {
width: 100%;
height: 100%;
object-fit: cover;
}
.logo-placeholder {
width: 48px;
height: 48px;
color: #818cf8;
}
.title {
font-size: 1.75rem;
font-weight: 700;
margin-bottom: 1rem;
line-height: 1.3;
background: linear-gradient(135deg, #fff 0%, #cbd5e1 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.description {
color: var(--text-secondary);
font-size: 1rem;
line-height: 1.6;
margin-bottom: 2.5rem;
max-width: 100%;
word-wrap: break-word;
}
.btn {
background: var(--primary-grad);
color: #fff;
border: none;
padding: 1rem 2rem;
border-radius: 12px;
font-weight: 600;
font-size: 1.1rem;
text-decoration: none;
cursor: pointer;
width: 100%;
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
display: inline-block;
box-shadow: 0 4px 14px 0 rgba(99, 102, 241, 0.3);
}
.btn:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px 0 rgba(99, 102, 241, 0.45);
opacity: 0.95;
}
.btn:active {
transform: translateY(0);
}
.footer-text {
margin-top: 2rem;
font-size: 0.8rem;
color: var(--text-secondary);
opacity: 0.6;
}
</style>
</head>
<body>
<div class="preview-card">
<div class="logo-container">
{% if let Some(l) = logo_url %}
<img class="logo-img" src="{{ l }}" alt="Logo">
{% else %}
<svg class="logo-placeholder" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"></circle>
<line x1="2" y1="12" x2="22" y2="12"></line>
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"></path>
</svg>
{% endif %}
</div>
{% if let Some(t) = title %}
<h1 class="title">{{ t }}</h1>
{% else %}
<h1 class="title">You are being redirected</h1>
{% endif %}
{% if let Some(d) = description %}
<p class="description">{{ d }}</p>
{% else %}
<p class="description">Click the button below to proceed to the destination URL.</p>
{% endif %}
<a href="{{ destination }}" class="btn">{{ button_text }}</a>
<div class="footer-text">Powered by BZOD</div>
</div>
</body>
</html>
+26
View File
@@ -108,6 +108,32 @@
</div>
</div>
<!-- Bulk QR Code Export -->
<div class="card">
<h3 style="font-size: 1.1rem; margin-bottom: 1.25rem; display: flex; align-items: center; gap: 0.5rem;">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect><rect x="7" y="7" width="3" height="3"></rect><rect x="14" y="7" width="3" height="3"></rect><rect x="7" y="14" width="3" height="3"></rect><rect x="14" y="14" width="3" height="3"></rect></svg>
Bulk QR Code Export
</h3>
<form action="/admin/settings/bulk-qr" method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<div class="form-group">
<label for="format">Export Format</label>
<select id="format" name="format">
<option value="png" selected>PNG Images (256x256)</option>
<option value="svg">SVG Vector Graphics</option>
</select>
</div>
<p style="font-size: 0.8rem; color: var(--text-secondary); margin-bottom: 1rem; line-height: 1.4;">
Generates a ZIP archive containing QR codes for all shortened links in your registry, alongside a <code>manifest.csv</code> file mapping codes to their destinations.
</p>
<button type="submit" class="btn" style="width: 100%; background: var(--primary-grad);">Download ZIP Archive</button>
</form>
</div>
</div>
<!-- Right Column: API Keys -->
+93
View File
@@ -66,4 +66,97 @@
</div>
</div>
<!-- Full Width: Link Health Dashboard Section -->
<div class="card" style="margin-top: 1.5rem; padding: 0; overflow: hidden;">
<div style="padding: 1.25rem 1.5rem; border-bottom: 1px solid var(--border-color); display: flex; justify-content: space-between; align-items: center;">
<h3 style="font-size: 1.1rem; display: flex; align-items: center; gap: 0.5rem;">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg>
Link Health Registry
</h3>
</div>
<div class="table-container">
<table>
<thead>
<tr>
<th>Short Link</th>
<th>Destination URL</th>
<th>General Status</th>
<th>Detailed Diagnostic</th>
<th>Latency</th>
<th>Last Checked</th>
</tr>
</thead>
<tbody>
{% if urls.is_empty() %}
<tr>
<td colspan="6" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
No shortened links registered to monitor.
</td>
</tr>
{% else %}
{% for url in urls %}
<tr>
<td>
<a href="/{{ url.code }}" target="_blank" style="color: var(--accent-color); font-weight: 700; text-decoration: none; font-family: monospace; font-size: 0.95rem;">
/{{ url.code }}
</a>
<div style="font-size: 0.75rem; color: var(--text-secondary); margin-top: 0.1rem;">
{{ url.title.as_deref().unwrap_or("") }}
</div>
</td>
<td style="max-width: 250px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 0.85rem; color: var(--text-secondary);">
{{ url.destination }}
</td>
<td>
{% if url.status == "healthy" %}
<span class="badge badge-healthy">Healthy</span>
{% else if url.status == "suspect" %}
<span class="badge badge-suspect">Suspect</span>
{% else %}
<span class="badge badge-dead">Dead</span>
{% endif %}
</td>
<td>
{% if let Some(last_status) = url.last_status %}
{% if last_status == "healthy" %}
<span class="badge" style="background-color: rgba(16, 185, 129, 0.15); color: #10b981; border: 1px solid rgba(16, 185, 129, 0.2);">HTTP OK</span>
{% else if last_status == "timeout" %}
<span class="badge" style="background-color: rgba(245, 158, 11, 0.15); color: #f59e0b; border: 1px solid rgba(245, 158, 11, 0.2);">Timeout</span>
{% else if last_status == "dns_failure" %}
<span class="badge" style="background-color: rgba(59, 130, 246, 0.15); color: #3b82f6; border: 1px solid rgba(59, 130, 246, 0.2);">DNS Error</span>
{% else if last_status == "tls_error" %}
<span class="badge" style="background-color: rgba(139, 92, 246, 0.15); color: #8b5cf6; border: 1px solid rgba(139, 92, 246, 0.2);">TLS Error</span>
{% else if last_status == "connection_refused" %}
<span class="badge" style="background-color: rgba(239, 68, 68, 0.15); color: #ef4444; border: 1px solid rgba(239, 68, 68, 0.2);">Refused</span>
{% else if last_status == "redirect_loop" %}
<span class="badge" style="background-color: rgba(236, 72, 153, 0.15); color: #ec4899; border: 1px solid rgba(236, 72, 153, 0.2);">Redirect Loop</span>
{% else if last_status.starts_with("http_") %}
<span class="badge" style="background-color: rgba(245, 158, 11, 0.15); color: #f59e0b; border: 1px solid rgba(245, 158, 11, 0.2);">{{ last_status.replace("http_", "HTTP ") }}</span>
{% else %}
<span class="badge" style="background-color: rgba(107, 114, 128, 0.15); color: #9ca3af; border: 1px solid rgba(107, 114, 128, 0.2);">{{ last_status }}</span>
{% endif %}
{% else %}
<span style="color: var(--text-muted); font-size: 0.85rem;">Pending</span>
{% endif %}
</td>
<td style="font-family: monospace; font-size: 0.85rem; color: var(--text-secondary);">
{% if let Some(latency) = url.last_latency_ms %}
{{ latency }} ms
{% else %}
-
{% endif %}
</td>
<td style="font-size: 0.8rem; color: var(--text-secondary);">
{{ url.updated_at[0..10] }} {{ url.updated_at[11..16] }}
</td>
</tr>
{% endfor %}
{% endif %}
</tbody>
</table>
</div>
</div>
{% endblock %}
+64 -4
View File
@@ -49,6 +49,21 @@
<input type="text" id="tags" name="tags" class="form-input" placeholder="e.g. blog, tech, personal">
</div>
<div class="form-group">
<label for="expires_at">Expiration Date & Time (optional)</label>
<input type="datetime-local" id="expires_at" name="expires_at" class="form-input">
</div>
<div class="form-group">
<label for="password">Password Protection (optional)</label>
<input type="password" id="password" name="password" class="form-input" placeholder="Leave blank for public access">
</div>
<div class="form-group">
<label for="max_access_count">Access Limit / One-Time (optional)</label>
<input type="number" id="max_access_count" name="max_access_count" class="form-input" placeholder="e.g. 10 (auto-expires after N clicks)" min="1">
</div>
<button type="submit" class="btn" style="width: 100%; margin-top: 0.5rem;">Create Link</button>
</form>
</div>
@@ -74,6 +89,7 @@
<tr>
<th>Short Link</th>
<th>Destination</th>
<th>QR Code</th>
<th>Health</th>
<th>Tags</th>
<th>Created</th>
@@ -83,7 +99,7 @@
<tbody>
{% if urls.is_empty() %}
<tr>
<td colspan="6" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
<td colspan="7" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
No shortened URLs registered. Create one to get started!
</td>
</tr>
@@ -91,9 +107,16 @@
{% for url in urls %}
<tr>
<td>
<a href="/{{ url.code }}" target="_blank" style="color: var(--accent-color); font-weight: 700; text-decoration: none; font-family: monospace; font-size: 1rem;">
bzo.in/{{ url.code }}
</a>
<div style="display: flex; align-items: center; gap: 0.25rem;">
<a href="/{{ url.code }}" target="_blank" style="color: var(--accent-color); font-weight: 700; text-decoration: none; font-family: monospace; font-size: 1rem;">
{{ base_url.replace("https://", "").replace("http://", "") }}/{{ url.code }}
</a>
{% if url.is_password_protected() %}
<span title="Password Protected" style="color: #fbbf24; display: inline-flex; align-items: center;">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect><path d="M7 11V7a5 5 0 0 1 10 0v4"></path></svg>
</span>
{% endif %}
</div>
<div style="font-size: 0.8rem; color: var(--text-secondary); margin-top: 0.25rem; font-weight: 500;">
{{ url.title.as_deref().unwrap_or("") }}
</div>
@@ -109,6 +132,43 @@
</div>
{% endif %}
{% endif %}
{% if let Some(expires_at) = url.expires_at.as_deref() %}
{% if !expires_at.is_empty() %}
<div style="font-size: 0.75rem; color: #fca5a5; margin-top: 0.25rem; display: flex; align-items: center; gap: 0.25rem;">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect><line x1="16" y1="2" x2="16" y2="6"></line><line x1="8" y1="2" x2="8" y2="6"></line><line x1="3" y1="10" x2="21" y2="10"></line></svg>
Expires: {{ expires_at[0..10] }} {{ expires_at[11..16] }}
{% if url.expired %}
<span class="badge badge-dead" style="font-size: 0.6rem; padding: 0.1rem 0.25rem; margin-left: 0.25rem;">Expired</span>
{% endif %}
</div>
{% endif %}
{% endif %}
{% if let Some(max) = url.max_access_count %}
<div style="font-size: 0.75rem; color: #38bdf8; margin-top: 0.25rem; display: flex; align-items: center; gap: 0.25rem;">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"></circle><polyline points="12 6 12 12 16 14"></polyline></svg>
Clicks: {{ url.access_count }} / {{ max }}
{% if url.is_access_exhausted() %}
<span class="badge badge-dead" style="font-size: 0.6rem; padding: 0.1rem 0.25rem; margin-left: 0.25rem;">Limit Reached</span>
{% endif %}
</div>
{% else %}
{% if url.access_count > 0 %}
<div style="font-size: 0.75rem; color: var(--text-secondary); margin-top: 0.25rem;">
Clicks: {{ url.access_count }}
</div>
{% 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>
<td>
<span class="badge badge-{{ url.status }}">
+226
View File
@@ -0,0 +1,226 @@
use bzod::db::audit_events::{list_audit_events, write_audit_event};
use bzod::db::content::{
create_url_extended, expire_urls, get_url_by_id, increment_access_count, remove_url_password,
set_url_password,
};
use bzod::db::migrations::{run_migrations, CONTENT_MIGRATIONS, SYSTEM_MIGRATIONS};
use bzod::db::preview::{delete_preview, get_preview, upsert_preview};
use chrono::Utc;
use rusqlite::Connection;
fn setup_content_db() -> Connection {
let mut conn = Connection::open_in_memory().unwrap();
run_migrations(&mut conn, "content", CONTENT_MIGRATIONS, None).unwrap();
conn
}
fn setup_system_db() -> Connection {
let mut conn = Connection::open_in_memory().unwrap();
run_migrations(&mut conn, "system", SYSTEM_MIGRATIONS, None).unwrap();
conn
}
#[test]
fn test_expiring_links() {
let conn = setup_content_db();
// Create an expired URL
let past = (Utc::now() - chrono::Duration::hours(1)).to_rfc3339();
let url_expired = create_url_extended(
&conn,
"exp001",
"https://expired.com",
Some("Expired Link"),
None,
&[],
Some(&past),
None,
None,
)
.unwrap();
// Create a future URL (not expired)
let future = (Utc::now() + chrono::Duration::hours(1)).to_rfc3339();
let url_active = create_url_extended(
&conn,
"act001",
"https://active.com",
Some("Active Link"),
None,
&[],
Some(&future),
None,
None,
)
.unwrap();
// Verify initial state
assert!(!url_expired.expired);
assert!(!url_active.expired);
// Run expiration logic
let expired_count = expire_urls(&conn).unwrap();
assert_eq!(expired_count, 1);
// Verify after expiration
let url_expired_after = get_url_by_id(&conn, &url_expired.id).unwrap().unwrap();
let url_active_after = get_url_by_id(&conn, &url_active.id).unwrap().unwrap();
assert!(url_expired_after.expired);
assert!(!url_active_after.expired);
}
#[test]
fn test_password_protected_links() {
let conn = setup_content_db();
let url = create_url_extended(
&conn,
"pwd001",
"https://protected.com",
None,
None,
&[],
None,
None,
None,
)
.unwrap();
assert!(!url.is_password_protected());
// Set password hash
let hash = "fake_argon_hash";
let set_ok = set_url_password(&conn, &url.id, hash).unwrap();
assert!(set_ok);
let url_updated = get_url_by_id(&conn, &url.id).unwrap().unwrap();
assert!(url_updated.is_password_protected());
assert_eq!(url_updated.password_hash.as_deref(), Some(hash));
// Remove password
let remove_ok = remove_url_password(&conn, &url.id).unwrap();
assert!(remove_ok);
let url_removed = get_url_by_id(&conn, &url.id).unwrap().unwrap();
assert!(!url_removed.is_password_protected());
}
#[test]
fn test_one_time_links() {
let conn = setup_content_db();
let url = create_url_extended(
&conn,
"one001",
"https://onetime.com",
None,
None,
&[],
None,
None,
Some(2), // Max access count = 2
)
.unwrap();
assert!(!url.is_access_exhausted());
// 1st click
let clicks = increment_access_count(&conn, &url.id).unwrap();
assert_eq!(clicks, 1);
let url_refetched = get_url_by_id(&conn, &url.id).unwrap().unwrap();
assert!(!url_refetched.is_access_exhausted());
// 2nd click
let clicks2 = increment_access_count(&conn, &url.id).unwrap();
assert_eq!(clicks2, 2);
let url_refetched2 = get_url_by_id(&conn, &url.id).unwrap().unwrap();
assert!(url_refetched2.is_access_exhausted());
}
#[test]
fn test_link_previews() {
let conn = setup_content_db();
let url = create_url_extended(
&conn,
"prv001",
"https://previewed.com",
None,
None,
&[],
None,
None,
None,
)
.unwrap();
// Initial check: no preview
let prev_init = get_preview(&conn, &url.id).unwrap();
assert!(prev_init.is_none());
// Create preview
let prev = upsert_preview(
&conn,
&url.id,
Some("Sample Page"),
Some("Sample Description"),
Some("https://logo.png"),
Some("Proceed"),
)
.unwrap();
assert_eq!(prev.title.as_deref(), Some("Sample Page"));
assert_eq!(prev.button_text, "Proceed");
// Get preview
let prev_get = get_preview(&conn, &url.id).unwrap().unwrap();
assert_eq!(prev_get.description.as_deref(), Some("Sample Description"));
// Update preview
let prev_updated =
upsert_preview(&conn, &url.id, Some("Updated Page"), None, None, None).unwrap();
assert_eq!(prev_updated.title.as_deref(), Some("Updated Page"));
assert_eq!(prev_updated.button_text, "Continue"); // defaults
// Delete preview
let del_ok = delete_preview(&conn, &url.id).unwrap();
assert!(del_ok);
let prev_final = get_preview(&conn, &url.id).unwrap();
assert!(prev_final.is_none());
}
#[test]
fn test_system_audit_events() {
let conn = setup_system_db();
// Initial check
let events_init = list_audit_events(&conn, 100, 0, None, None).unwrap();
assert!(events_init.is_empty());
// Write events
write_audit_event(
&conn,
"admin",
"URL_CREATION",
"url",
"url-uuid-1",
Some("metadata-1"),
)
.unwrap();
write_audit_event(&conn, "api-user", "URL_UPDATE", "url", "url-uuid-2", None).unwrap();
// List events
let events = list_audit_events(&conn, 100, 0, None, None).unwrap();
assert_eq!(events.len(), 2);
assert_eq!(events[0].actor, "api-user"); // ordered desc by timestamp
assert_eq!(events[1].actor, "admin");
// Filter by actor
let events_filtered = list_audit_events(&conn, 100, 0, Some("admin"), None).unwrap();
assert_eq!(events_filtered.len(), 1);
assert_eq!(events_filtered[0].action, "URL_CREATION");
}
+76
View File
@@ -0,0 +1,76 @@
use bzod::db::migrations::{run_migrations, ANALYTICS_MIGRATIONS};
use bzod::db::qr::{
get_qr_code_style, get_qr_scan_count, get_qr_stats_for_url, log_qr_access, upsert_qr_code,
};
use rusqlite::Connection;
#[test]
fn test_qr_service_png_and_svg() {
let url = "https://example.com/some/path";
let png = bzod::services::qr::generate_qr_png(url, 256).unwrap();
assert!(!png.is_empty());
assert_eq!(&png[..4], &[0x89, b'P', b'N', b'G']); // PNG magic bytes
let svg = bzod::services::qr::generate_qr_svg(url).unwrap();
assert!(svg.contains("<svg"));
assert!(svg.contains("</svg>"));
}
#[test]
fn test_qr_access_logging() {
let mut conn = Connection::open_in_memory().unwrap();
run_migrations(&mut conn, "analytics", ANALYTICS_MIGRATIONS, None).unwrap();
let url_id = "test-url-uuid-123";
log_qr_access(&conn, url_id, Some("127.0.0.1"), Some("TestBrowser/1.0")).unwrap();
log_qr_access(&conn, url_id, None, None).unwrap();
let count = get_qr_scan_count(&conn, url_id).unwrap();
assert_eq!(count, 2);
let stats = get_qr_stats_for_url(&conn, url_id).unwrap();
assert_eq!(stats.len(), 2);
assert_eq!(stats[0].1, "");
assert_eq!(stats[1].1, "127.0.0.1");
}
#[test]
fn test_qr_code_styling() {
let mut conn = Connection::open_in_memory().unwrap();
// Run content migrations because qr_codes table is in content.db
run_migrations(
&mut conn,
"content",
bzod::db::migrations::CONTENT_MIGRATIONS,
None,
)
.unwrap();
// Create a dummy URL first to avoid FOREIGN KEY failure
let url = bzod::db::content::create_url_extended(
&conn,
"qrstyle",
"https://example.com",
None,
None,
&[],
None,
None,
None,
)
.unwrap();
// Default style when not configured
let style = get_qr_code_style(&conn, &url.id).unwrap();
assert_eq!(style, "default");
// Upsert a style
upsert_qr_code(&conn, &url.id, "fancy-blue").unwrap();
let style = get_qr_code_style(&conn, &url.id).unwrap();
assert_eq!(style, "fancy-blue");
// Update the style
upsert_qr_code(&conn, &url.id, "sleek-dark").unwrap();
let style = get_qr_code_style(&conn, &url.id).unwrap();
assert_eq!(style, "sleek-dark");
}
+45 -36
View File
@@ -1,15 +1,13 @@
use rusqlite::Connection;
use chrono::Utc;
use axum_extra::extract::CookieJar;
use axum_extra::extract::cookie::Cookie;
use axum_extra::extract::CookieJar;
use chrono::Utc;
use rusqlite::Connection;
use bzod::auth::{
verify_csrf, generate_csrf_token, authenticate_session, authenticate_api_key,
hash_password, verify_sha256, verify_password
};
use bzod::db::admin::{
create_user, create_session, get_user_count, create_api_key
authenticate_api_key, authenticate_session, generate_csrf_token, hash_password, verify_csrf,
verify_password, verify_sha256,
};
use bzod::db::admin::{create_api_key, create_session, create_user, get_user_count};
use bzod::db::migrations::{run_migrations, ADMIN_MIGRATIONS};
// Helper to set up an in-memory admin.db connection with migrations applied
@@ -23,13 +21,13 @@ fn setup_test_db() -> Connection {
fn test_csrf_tampering_prevention() {
let session_id = "secret_session_id_123456";
let valid_token = generate_csrf_token(session_id);
// Mismatched token must fail
assert!(!verify_csrf(session_id, "different_token_value"));
// Valid token must pass
assert!(verify_csrf(session_id, &valid_token));
// Mismatched session id must fail even if token matches the original session id
assert!(!verify_csrf("different_session_id_789", &valid_token));
}
@@ -37,39 +35,39 @@ fn test_csrf_tampering_prevention() {
#[test]
fn test_api_key_sql_injection_resistance() {
let conn = setup_test_db();
// Create an API key
let user_hash = hash_password("admin_pass").unwrap();
let user = create_user(&conn, "admin", &user_hash).unwrap();
// Generate valid API key
let key_secret = "bzo_validkey1234567890abcdef";
use sha2::{Sha256, Digest};
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(key_secret.as_bytes());
let hashed_key = hex::encode(hasher.finalize());
create_api_key(&conn, &user.id, "my-key", &hashed_key).unwrap();
// 1. Test valid key passes
let valid_auth = format!("Bearer {}", key_secret);
let auth_res = authenticate_api_key(&conn, &valid_auth).unwrap();
assert!(auth_res.is_some());
assert_eq!(auth_res.unwrap().username, "admin");
// 2. Test SQL Injection attempt in the header does not succeed or crash
let sql_inj_auth1 = "Bearer ' OR 1=1 --";
let res = authenticate_api_key(&conn, sql_inj_auth1).unwrap();
assert!(res.is_none());
let sql_inj_auth2 = "Bearer ' UNION SELECT id, username FROM users --";
let res = authenticate_api_key(&conn, sql_inj_auth2).unwrap();
assert!(res.is_none());
// 3. Test malformed header
let malformed_auth = "Bearer";
let res = authenticate_api_key(&conn, malformed_auth).unwrap();
assert!(res.is_none());
let wrong_scheme = "Basic admin:pass";
let res = authenticate_api_key(&conn, wrong_scheme).unwrap();
assert!(res.is_none());
@@ -78,25 +76,25 @@ fn test_api_key_sql_injection_resistance() {
#[test]
fn test_expired_session_invalidation() {
let conn = setup_test_db();
let user_hash = hash_password("pass").unwrap();
let user = create_user(&conn, "admin", &user_hash).unwrap();
// 1. Session in the future must be valid
let future_expiry = (Utc::now() + chrono::Duration::hours(1)).to_rfc3339();
let session_id_future = "future_session_token";
create_session(&conn, session_id_future, &user.id, &future_expiry).unwrap();
let jar_future = CookieJar::new().add(Cookie::new("bzod_session", session_id_future));
let auth_future = authenticate_session(&conn, &jar_future).unwrap();
assert!(auth_future.is_some());
assert_eq!(auth_future.unwrap().0.id, user.id);
// 2. Session in the past must be rejected
let past_expiry = (Utc::now() - chrono::Duration::hours(1)).to_rfc3339();
let session_id_past = "expired_session_token";
create_session(&conn, session_id_past, &user.id, &past_expiry).unwrap();
let jar_past = CookieJar::new().add(Cookie::new("bzod_session", session_id_past));
let auth_past = authenticate_session(&conn, &jar_past).unwrap();
assert!(auth_past.is_none());
@@ -105,26 +103,29 @@ fn test_expired_session_invalidation() {
#[test]
fn test_bootstrap_credentials_deactivation() {
let conn = setup_test_db();
let bootstrap_sha = "8c6976e5b5410415bde908bd4dee15dfb167a9c873fc4bb8a81f6f2ab448a918"; // SHA-256 of "admin"
// 1. Initially, no users exist in database
assert_eq!(get_user_count(&conn).unwrap(), 0);
// Bootstrap validation is allowed
assert!(verify_sha256("admin", bootstrap_sha));
// 2. Provision a user in database (either via bootstrap login or CLI)
let user_hash = hash_password("new_secure_admin_password").unwrap();
create_user(&conn, "admin", &user_hash).unwrap();
// Check that database now has users
assert_eq!(get_user_count(&conn).unwrap(), 1);
// Standard credential validation must pass
let user_opt = bzod::db::admin::get_user_by_username(&conn, "admin").unwrap();
assert!(user_opt.is_some());
assert!(verify_password("new_secure_admin_password", &user_opt.unwrap().password_hash));
assert!(verify_password(
"new_secure_admin_password",
&user_opt.unwrap().password_hash
));
// The bootstrap credentials MUST be ignored now (the application logic checks users count,
// which is 1, so it bypasses the bootstrap check and verifies ONLY against the database).
}
@@ -135,16 +136,24 @@ fn test_path_traversal_rejection() {
// If a request has /../admin, standard HTTP parsers and Axum router resolve it as /admin
// (which checks session cookies) or return 404 for unresolved paths.
// Here we verify that code inputs containing traversal strings are parsed as invalid codes.
let invalid_codes = vec!["../foo", "..%2ff", "/admin", "a/b/c", "1234567"];
for code in invalid_codes {
// Validate redirect code must be exactly 6 hex digits
let is_valid_redirect_code = code.len() == 6 && code.chars().all(|c| c.is_ascii_hexdigit());
assert!(!is_valid_redirect_code, "Code '{}' should be rejected as a valid redirect shortcode", code);
assert!(
!is_valid_redirect_code,
"Code '{}' should be rejected as a valid redirect shortcode",
code
);
// Validate landing page code must be exactly 4 hex digits
let is_valid_page_code = code.len() == 4 && code.chars().all(|c| c.is_ascii_hexdigit());
assert!(!is_valid_page_code, "Code '{}' should be rejected as a valid landing page shortcode", code);
assert!(
!is_valid_page_code,
"Code '{}' should be rejected as a valid landing page shortcode",
code
);
}
}
+182
View File
@@ -0,0 +1,182 @@
<!DOCTYPE html>
<html lang="en" data-lt-installed="true"><head>
<meta http-equiv="content-type" content="text/html; charset=UTF-8">
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>bzo.in - Simple &amp; Private URL Shortener</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap');
:root {
--bg: #0f1321;
--card: #1a2337;
--accent: #8b5cf6;
--text: #e0e7ff;
}
* { margin:0; padding:0; box-sizing:border-box; }
body {
font-family: 'Inter', system-ui, sans-serif;
background: var(--bg);
color: var(--text);
line-height: 1.6;
min-height: 100vh;
}
header {
background: rgba(26, 35, 55, 0.95);
backdrop-filter: blur(10px);
border-bottom: 1px solid #2a3a5a;
padding: 1.25rem 0;
}
.nav {
max-width: 1200px;
margin: 0 auto;
padding: 0 2rem;
display: flex;
justify-content: space-between;
align-items: center;
}
.logo { font-size: 1.75rem; font-weight: 700; color: var(--accent); }
.main { padding: 6rem 2rem 4rem; text-align: center; }
.beta {
display: inline-block;
background: #eab308;
color: #1e2937;
font-size: 0.85rem;
padding: 5px 14px;
border-radius: 9999px;
font-weight: 600;
margin-bottom: 1rem;
}
h1 {
font-size: 3.2rem;
line-height: 1.1;
margin-bottom: 1rem;
background: linear-gradient(90deg, #c4b5fd, #a78bfa);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.tagline {
font-size: 1.35rem;
color: #94a3c0;
max-width: 700px;
margin: 0 auto 3rem;
}
.shorten-box {
background: var(--card);
border-radius: 16px;
padding: 1.25rem;
max-width: 620px;
margin: 0 auto 4rem;
border: 1px solid #3b4a6b;
display: flex;
gap: 12px;
}
input {
flex: 1;
padding: 16px 20px;
font-size: 1.1rem;
background: #111827;
border: 1px solid #475569;
border-radius: 12px;
color: white;
}
button {
padding: 16px 36px;
background: var(--accent);
color: white;
border: none;
border-radius: 12px;
font-weight: 600;
cursor: pointer;
}
button:hover { background: #a78bfa; }
.features {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 1.5rem;
max-width: 1100px;
margin: 0 auto;
}
.feature-card {
background: var(--card);
padding: 2rem 1.75rem;
border-radius: 16px;
border: 1px solid #3b4a6b;
text-align: left;
}
footer {
text-align: center;
padding: 3rem 1rem 2rem;
color: #64748b;
font-size: 0.95rem;
}
</style>
</head>
<body>
<header>
<div class="nav">
<div class="logo">BZOD • bzo.in</div>
<a href="https://bzo.in/admin/dashboard" style="color:#a5b4fc; text-decoration:none;">Admin</a>
</div>
</header>
<div class="main">
<div class="beta">BETA</div>
<h1>Short. Clean.<br>Privacy-First.</h1>
<p class="tagline">Lightning-fast URL shortener with built-in analytics, custom landing pages, and full self-hosting control.</p>
<div class="shorten-box">
<input type="url" placeholder="Paste your long URL here..." id="urlInput">
<button onclick="shorten()">Shorten Now</button>
</div>
<div class="features">
<div class="feature-card">
<h3>🔗 Compact Hex Codes</h3>
<p>Short, memorable 4-6 character links that look professional.</p>
</div>
<div class="feature-card">
<h3>📊 Real-time Analytics</h3>
<p>Track clicks, countries, referrers with beautiful charts.</p>
</div>
<div class="feature-card">
<h3>🎨 Custom Landing Pages</h3>
<p>Beautiful branded pages before redirect (like this one!).</p>
</div>
<div class="feature-card">
<h3>🔒 Self-Hosted &amp; Private</h3>
<p>Your data. Your server. No third-party tracking.</p>
</div>
</div>
</div>
<footer>
<p>Made with ❤️ using <strong>nx9-url-shortener</strong> •
<a href="https://github.com/thakares/nx9-url-shortener" style="color:#a78bfa">Star on GitHub</a></p>
</footer>
<script>
function shorten() {
const url = document.getElementById('urlInput').value.trim();
if (url) {
window.location.href = `/?url=${encodeURIComponent(url)}`;
}
}
</script>
</body></html>