Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bcbcc90d98 | ||
|
|
157aa81252 | ||
|
|
a55f40dc29 | ||
|
|
a516ecedb0 | ||
|
|
50718e57f0 |
No files matched your search
Generated
+695
-1
@@ -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
@@ -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
@@ -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"]
|
||||
@@ -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
|
||||
|
||||

|
||||
|
||||
### Short URL Management
|
||||
|
||||

|
||||
|
||||
### Landing Pages
|
||||
|
||||

|
||||
|
||||
### Settings
|
||||
|
||||

|
||||
|
||||
### Server Status
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
## 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
@@ -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
@@ -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,4 +1,4 @@
|
||||
use serde::{Serialize, Deserialize};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub enum AnalyticsEvent {
|
||||
|
||||
@@ -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;
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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};
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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
@@ -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
@@ -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();
|
||||
|
||||
@@ -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
@@ -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
@@ -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,4 +1,4 @@
|
||||
use serde::{Serialize, Deserialize};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct ApiKey {
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
use serde::{Serialize, Deserialize};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct AuditLog {
|
||||
|
||||
+8
-8
@@ -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
@@ -1,4 +1,4 @@
|
||||
use serde::{Serialize, Deserialize};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct LandingPage {
|
||||
|
||||
+64
-1
@@ -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
@@ -1,4 +1,4 @@
|
||||
use serde::{Serialize, Deserialize};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct User {
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
use serde::{Serialize, Deserialize};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct VisitRecord {
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
@@ -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,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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,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
@@ -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;
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
File diff suppressed because it is too large.
Load diff
+288
@@ -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
@@ -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
@@ -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();
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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()
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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
@@ -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 }}">
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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 & 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 & 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>
|
||||
Reference in new issue
Block a user