diff --git a/Cargo.lock b/Cargo.lock index 7460876..bc07339 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", +] diff --git a/Cargo.toml b/Cargo.toml index 6a2ae2f..3475e9c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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"] } diff --git a/Dockerfile b/Dockerfile index 959da41..c84912d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,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 @@ -47,8 +48,8 @@ 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 diff --git a/src/analytics/aggregate.rs b/src/analytics/aggregate.rs index b6c1f7f..be9a62f 100644 --- a/src/analytics/aggregate.rs +++ b/src/analytics/aggregate.rs @@ -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) diff --git a/src/analytics/events.rs b/src/analytics/events.rs index 15d7d32..321469c 100644 --- a/src/analytics/events.rs +++ b/src/analytics/events.rs @@ -1,4 +1,4 @@ -use serde::{Serialize, Deserialize}; +use serde::{Deserialize, Serialize}; #[derive(Serialize, Deserialize, Clone, Debug)] pub enum AnalyticsEvent { diff --git a/src/analytics/mod.rs b/src/analytics/mod.rs index 5838fde..477a775 100644 --- a/src/analytics/mod.rs +++ b/src/analytics/mod.rs @@ -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; diff --git a/src/analytics/queue.rs b/src/analytics/queue.rs index fcc391e..17ef067 100644 --- a/src/analytics/queue.rs +++ b/src/analytics/queue.rs @@ -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 { diff --git a/src/analytics/worker.rs b/src/analytics/worker.rs index 3c826d9..392d625 100644 --- a/src/analytics/worker.rs +++ b/src/analytics/worker.rs @@ -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) { let mut batch = Vec::new(); diff --git a/src/auth/csrf.rs b/src/auth/csrf.rs index 59670d9..edcc76a 100644 --- a/src/auth/csrf.rs +++ b/src/auth/csrf.rs @@ -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 { diff --git a/src/auth/middleware.rs b/src/auth/middleware.rs index 2ba4a12..f21eb80 100644 --- a/src/auth/middleware.rs +++ b/src/auth/middleware.rs @@ -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 { 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"))?; diff --git a/src/auth/mod.rs b/src/auth/mod.rs index 942eb4b..17867a8 100644 --- a/src/auth/mod.rs +++ b/src/auth/mod.rs @@ -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}; diff --git a/src/auth/password.rs b/src/auth/password.rs index c14f3b9..f42cc2e 100644 --- a/src/auth/password.rs +++ b/src/auth/password.rs @@ -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 { 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 } diff --git a/src/auth/session.rs b/src/auth/session.rs index 75050eb..2f74a30 100644 --- a/src/auth/session.rs +++ b/src/auth/session.rs @@ -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) } diff --git a/src/charts/bar.rs b/src/charts/bar.rs index 87ca834..006b40e 100644 --- a/src/charts/bar.rs +++ b/src/charts/bar.rs @@ -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 { diff --git a/src/charts/line.rs b/src/charts/line.rs index 5003b48..fa9860a 100644 --- a/src/charts/line.rs +++ b/src/charts/line.rs @@ -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##"{}"##, x, height - 15.0, DEFAULT_TEXT_COLOR, short_label )); - + x_labels.push_str(&format!( r##""##, - 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 )); } } diff --git a/src/charts/mod.rs b/src/charts/mod.rs index 5a3ebdb..75f01bf 100644 --- a/src/charts/mod.rs +++ b/src/charts/mod.rs @@ -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; diff --git a/src/cli/backup.rs b/src/cli/backup.rs index baf29c0..634e111 100644 --- a/src/cli/backup.rs +++ b/src/cli/backup.rs @@ -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, data_dir: Option, mut config: Config, ) -> Result<(), Box> { - 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); diff --git a/src/cli/create_admin.rs b/src/cli/create_admin.rs index 34863ec..f24a4b9 100644 --- a/src/cli/create_admin.rs +++ b/src/cli/create_admin.rs @@ -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, data_dir: Option, mut config: Config, ) -> Result<(), Box> { - 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(()) } diff --git a/src/cli/doctor.rs b/src/cli/doctor.rs index ef64bc0..2ea598a 100644 --- a/src/cli/doctor.rs +++ b/src/cli/doctor.rs @@ -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, mut config: Config, ) -> Result<(), Box> { - 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); diff --git a/src/cli/migrate.rs b/src/cli/migrate.rs index eea6819..67dfc1f 100644 --- a/src/cli/migrate.rs +++ b/src/cli/migrate.rs @@ -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, dry_run: bool, mut config: Config, ) -> Result<(), Box> { - 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."); diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 8558ab2..18a59b3 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -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")] diff --git a/src/cli/restore.rs b/src/cli/restore.rs index d5f015e..268493c 100644 --- a/src/cli/restore.rs +++ b/src/cli/restore.rs @@ -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, mut config: Config, ) -> Result<(), Box> { - 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(()); diff --git a/src/cli/serve.rs b/src/cli/serve.rs index 3aadbc9..74af7b9 100644 --- a/src/cli/serve.rs +++ b/src/cli/serve.rs @@ -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, @@ -13,16 +13,22 @@ pub async fn run( data_dir: Option, mut config: Config, ) -> Result<(), Box> { - 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?; diff --git a/src/cli/stats.rs b/src/cli/stats.rs index 435ebd8..f6a6e5f 100644 --- a/src/cli/stats.rs +++ b/src/cli/stats.rs @@ -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, mut config: Config, ) -> Result<(), Box> { - 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(); diff --git a/src/cli/validate.rs b/src/cli/validate.rs index 648a4cd..dd23dac 100644 --- a/src/cli/validate.rs +++ b/src/cli/validate.rs @@ -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, mut config: Config, ) -> Result<(), Box> { - 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."); diff --git a/src/config.rs b/src/config.rs index a801b70..59eb14e 100644 --- a/src/config.rs +++ b/src/config.rs @@ -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, } #[derive(Deserialize, Default)] @@ -33,6 +34,7 @@ struct TomlConfig { link_check_interval_mins: Option, aggregation_interval_mins: Option, backup: Option, + base_url: Option, } #[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::(&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::() { port = p; } + if let Ok(p) = p_str.parse::() { + 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::() { cookie_secure = c; } + if let Ok(c) = c_str.parse::() { + 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::() { link_check_interval_mins = lc; } + if let Ok(lc) = lc_str.parse::() { + link_check_interval_mins = lc; + } } if let Ok(ag_str) = env::var("AGGREGATION_INTERVAL_MINS") { - if let Ok(ag) = ag_str.parse::() { aggregation_interval_mins = ag; } + if let Ok(ag) = ag_str.parse::() { + aggregation_interval_mins = ag; + } } if let Ok(be_str) = env::var("BACKUP_ENABLED") { - if let Ok(be) = be_str.parse::() { backup_enabled = be; } + if let Ok(be) = be_str.parse::() { + backup_enabled = be; + } } if let Ok(bi_str) = env::var("BACKUP_INTERVAL_MINS") { - if let Ok(bi) = bi_str.parse::() { backup_interval_mins = bi; } + if let Ok(bi) = bi_str.parse::() { + 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, } } } diff --git a/src/db/admin.rs b/src/db/admin.rs index 4126932..ba03eea 100644 --- a/src/db/admin.rs +++ b/src/db/admin.rs @@ -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 { +pub fn create_user( + conn: &Connection, + username: &str, + password_hash: &str, +) -> rusqlite::Result { 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> { - 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> { - 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 { 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> { - 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 { 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 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 { 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> { +pub fn list_audit_logs( + conn: &Connection, + limit: i64, + offset: i64, +) -> rusqlite::Result> { 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> { 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)) diff --git a/src/db/analytics.rs b/src/db/analytics.rs index aa2b302..c119bf3 100644 --- a/src/db/analytics.rs +++ b/src/db/analytics.rs @@ -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 { - 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 { - 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> { - 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 = 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 { 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> { - 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> { 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> { 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> { // 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 = 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 = 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"); } } - diff --git a/src/db/audit_events.rs b/src/db/audit_events.rs new file mode 100644 index 0000000..9ab34bb --- /dev/null +++ b/src/db/audit_events.rs @@ -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> { + let mut events = Vec::new(); + + let (sql, params_vec): (String, Vec>) = 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) +} diff --git a/src/db/content.rs b/src/db/content.rs index 8ee926d..747f237 100644 --- a/src/db/content.rs +++ b/src/db/content.rs @@ -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) -> rusqlite::Result { + 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, +) -> rusqlite::Result { + 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> { - 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> { - 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> { 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> { - 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, +) -> 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 { + 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 { + 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 { + 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 { + 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 { + 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> { + 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> { +pub fn get_landing_page_by_id( + conn: &Connection, + id: &str, +) -> rusqlite::Result> { 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 rusqlite::Result> { +pub fn get_landing_page_by_code( + conn: &Connection, + code: &str, +) -> rusqlite::Result> { 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 0) } -pub fn list_landing_pages(conn: &Connection, limit: i64, offset: i64) -> rusqlite::Result> { +pub fn list_landing_pages( + conn: &Connection, + limit: i64, + offset: i64, +) -> rusqlite::Result> { 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 { diff --git a/src/db/migrations.rs b/src/db/migrations.rs index c36730a..5f8294c 100644 --- a/src/db/migrations.rs +++ b/src/db/migrations.rs @@ -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); + "#, + }, ]; diff --git a/src/db/mod.rs b/src/db/mod.rs index 7c394c9..3abed56 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -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()); } } diff --git a/src/db/preview.rs b/src/db/preview.rs new file mode 100644 index 0000000..b8d3d65 --- /dev/null +++ b/src/db/preview.rs @@ -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 { + 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> { + 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>(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 { + let count = conn.execute( + "DELETE FROM link_preview WHERE url_id = ?1;", + params![url_id], + )?; + Ok(count > 0) +} diff --git a/src/db/qr.rs b/src/db/qr.rs new file mode 100644 index 0000000..edab7cb --- /dev/null +++ b/src/db/qr.rs @@ -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 { + 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> { + 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>(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 = 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 { + let style: Option = 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())) +} diff --git a/src/db/sqlite.rs b/src/db/sqlite.rs index f0eb4e6..0cb2973 100644 --- a/src/db/sqlite.rs +++ b/src/db/sqlite.rs @@ -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); } diff --git a/src/error.rs b/src/error.rs index 73dbc35..a1366e2 100644 --- a/src/error.rs +++ b/src/error.rs @@ -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 }, + MigrationFailed { + database: String, + version: u32, + name: String, + source: Box, + }, /// 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 + ) } } } diff --git a/src/jobs/aggregate.rs b/src/jobs/aggregate.rs index 9c9236f..9286533 100644 --- a/src/jobs/aggregate.rs +++ b/src/jobs/aggregate.rs @@ -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), diff --git a/src/jobs/backup.rs b/src/jobs/backup.rs index c65ae08..19d7947 100644 --- a/src/jobs/backup.rs +++ b/src/jobs/backup.rs @@ -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> { - use std::fs::File; +pub async fn perform_backup( + db: &Db, + config: &Config, +) -> Result> { + 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 0 { + info!(expired_count = count, "Expired URLs marked"); + } + } +} diff --git a/src/jobs/healthcheck.rs b/src/jobs/healthcheck.rs index fd6ff47..6549c7b 100644 --- a/src/jobs/healthcheck.rs +++ b/src/jobs/healthcheck.rs @@ -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> { +pub async fn perform_link_check( + db: &Db, + client: &Client, +) -> Result<(), Box> { 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 (String, Option, Option) { - 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, i64, Option) { + 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)) } } } diff --git a/src/jobs/mod.rs b/src/jobs/mod.rs index f55bbd8..045c277 100644 --- a/src/jobs/mod.rs +++ b/src/jobs/mod.rs @@ -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, job_name: &str) -> String { let id = Uuid::new_v4().to_string(); diff --git a/src/jobs/retention.rs b/src/jobs/retention.rs index 4dfe48b..6f6d2cd 100644 --- a/src/jobs/retention.rs +++ b/src/jobs/retention.rs @@ -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) { let retention_days = match retention_days_opt { @@ -14,7 +14,7 @@ pub async fn run_retention_cleaner(db: Db, retention_days_opt: Option) { // 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) { diff --git a/src/lib.rs b/src/lib.rs index 47adf8c..973aaa6 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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; diff --git a/src/main.rs b/src/main.rs index 84f4970..d10c394 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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> { // 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 } => { diff --git a/src/models/api_key.rs b/src/models/api_key.rs index 755f99f..c8a9f48 100644 --- a/src/models/api_key.rs +++ b/src/models/api_key.rs @@ -1,4 +1,4 @@ -use serde::{Serialize, Deserialize}; +use serde::{Deserialize, Serialize}; #[derive(Serialize, Deserialize, Clone, Debug)] pub struct ApiKey { diff --git a/src/models/audit.rs b/src/models/audit.rs index 4e07008..c6feec6 100644 --- a/src/models/audit.rs +++ b/src/models/audit.rs @@ -1,4 +1,4 @@ -use serde::{Serialize, Deserialize}; +use serde::{Deserialize, Serialize}; #[derive(Serialize, Deserialize, Clone, Debug)] pub struct AuditLog { diff --git a/src/models/mod.rs b/src/models/mod.rs index b818df9..b54b302 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -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}; diff --git a/src/models/page.rs b/src/models/page.rs index b2c9b70..e517206 100644 --- a/src/models/page.rs +++ b/src/models/page.rs @@ -1,4 +1,4 @@ -use serde::{Serialize, Deserialize}; +use serde::{Deserialize, Serialize}; #[derive(Serialize, Deserialize, Clone, Debug)] pub struct LandingPage { diff --git a/src/models/url.rs b/src/models/url.rs index fa8062a..2c11d87 100644 --- a/src/models/url.rs +++ b/src/models/url.rs @@ -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, + // --- Feature Expansion Fields --- + #[serde(skip_serializing_if = "Option::is_none")] + pub expires_at: Option, + #[serde(default)] + pub expired: bool, + #[serde(skip_serializing)] + pub password_hash: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub last_status: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub last_latency_ms: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub max_access_count: Option, + #[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, + pub description: Option, + pub logo_url: Option, + 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, } diff --git a/src/models/user.rs b/src/models/user.rs index 2535449..464f212 100644 --- a/src/models/user.rs +++ b/src/models/user.rs @@ -1,4 +1,4 @@ -use serde::{Serialize, Deserialize}; +use serde::{Deserialize, Serialize}; #[derive(Serialize, Deserialize, Clone, Debug)] pub struct User { diff --git a/src/models/visit.rs b/src/models/visit.rs index dd9c5e7..a76d1a9 100644 --- a/src/models/visit.rs +++ b/src/models/visit.rs @@ -1,4 +1,4 @@ -use serde::{Serialize, Deserialize}; +use serde::{Deserialize, Serialize}; #[derive(Serialize, Deserialize, Clone, Debug)] pub struct VisitRecord { diff --git a/src/services/api_keys.rs b/src/services/api_keys.rs index 7e69f7b..7e0312a 100644 --- a/src/services/api_keys.rs +++ b/src/services/api_keys.rs @@ -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, diff --git a/src/services/audit.rs b/src/services/audit.rs index 4b9fd24..8d5cc3d 100644 --- a/src/services/audit.rs +++ b/src/services/audit.rs @@ -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, diff --git a/src/services/bulk.rs b/src/services/bulk.rs new file mode 100644 index 0000000..bf727d9 --- /dev/null +++ b/src/services/bulk.rs @@ -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, Box> { + 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) +} diff --git a/src/services/landing_pages.rs b/src/services/landing_pages.rs index fdf7075..13f9788 100644 --- a/src/services/landing_pages.rs +++ b/src/services/landing_pages.rs @@ -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 { 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) } diff --git a/src/services/mod.rs b/src/services/mod.rs index f0f2654..e4c3ffa 100644 --- a/src/services/mod.rs +++ b/src/services/mod.rs @@ -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; diff --git a/src/services/qr.rs b/src/services/qr.rs new file mode 100644 index 0000000..7ec2df4 --- /dev/null +++ b/src/services/qr.rs @@ -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, Box> { + let code = QrCode::new(url.as_bytes())?; + let image = code.render::>().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> { + let code = QrCode::new(url.as_bytes())?; + let svg = code + .render::() + .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("")); + } + + #[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()); + } +} diff --git a/src/services/shortener.rs b/src/services/shortener.rs index 579484a..7ce4ad4 100644 --- a/src/services/shortener.rs +++ b/src/services/shortener.rs @@ -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, diff --git a/src/state.rs b/src/state.rs index 4ad115d..b82e43e 100644 --- a/src/state.rs +++ b/src/state.rs @@ -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 { diff --git a/src/templates/dashboard.rs b/src/templates/dashboard.rs index ee04d80..6f7d7f7 100644 --- a/src/templates/dashboard.rs +++ b/src/templates/dashboard.rs @@ -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(), } } } diff --git a/src/templates/mod.rs b/src/templates/mod.rs index 78d3f68..050cc2c 100644 --- a/src/templates/mod.rs +++ b/src/templates/mod.rs @@ -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, +} + +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, + pub description: Option, + pub logo_url: Option, + 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(), } } } diff --git a/src/templates/pages.rs b/src/templates/pages.rs index 750aa65..8d297fe 100644 --- a/src/templates/pages.rs +++ b/src/templates/pages.rs @@ -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(), } } } diff --git a/src/templates/settings.rs b/src/templates/settings.rs index cd9e1e1..f9390fb 100644 --- a/src/templates/settings.rs +++ b/src/templates/settings.rs @@ -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(), } } } diff --git a/src/templates/stats.rs b/src/templates/stats.rs index 69f16ed..4fbe95b 100644 --- a/src/templates/stats.rs +++ b/src/templates/stats.rs @@ -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, } #[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(), } } } diff --git a/src/templates/urls.rs b/src/templates/urls.rs index c0daa00..be4445a 100644 --- a/src/templates/urls.rs +++ b/src/templates/urls.rs @@ -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, pub tag_filter: Option, + 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(), } } } diff --git a/src/utils/hashing.rs b/src/utils/hashing.rs index 5a16b1a..405ec71 100644 --- a/src/utils/hashing.rs +++ b/src/utils/hashing.rs @@ -1,4 +1,4 @@ -use sha2::{Sha256, Digest}; +use sha2::{Digest, Sha256}; // General SHA-256 hash helper pub fn sha256_hash(data: &str) -> String { diff --git a/src/utils/mod.rs b/src/utils/mod.rs index 2f2e009..cb089d7 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -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; diff --git a/src/utils/network.rs b/src/utils/network.rs index 97ae35c..c10d4a1 100644 --- a/src/utils/network.rs +++ b/src/utils/network.rs @@ -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>) -> 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()) { diff --git a/src/utils/random.rs b/src/utils/random.rs index c1408a1..a31f174 100644 --- a/src/utils/random.rs +++ b/src/utils/random.rs @@ -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 { diff --git a/src/utils/system.rs b/src/utils/system.rs index 34c773f..416a042 100644 --- a/src/utils/system.rs +++ b/src/utils/system.rs @@ -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)); diff --git a/src/web/admin.rs b/src/web/admin.rs index ba45c5b..994b43c 100644 --- a/src/web/admin.rs +++ b/src/web/admin.rs @@ -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 { + 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, - jar: CookieJar, -) -> Response { +pub async fn admin_index(State(state): State, 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>, Form(form): Form, ) -> 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, - jar: CookieJar, -) -> Response { +pub async fn logout(State(state): State, 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, - jar: CookieJar, -) -> Response { +pub async fn dashboard_get(State(state): State, 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 = 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::() { + Ok(c) => Some(c), + Err(_) => { + return Redirect::to("/admin/urls?error=Invalid max access count").into_response() + } + } + }; + + let tags_list: Vec = 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, + jar: CookieJar, + headers: HeaderMap, + connect_info: Option>, + Form(form): Form, +) -> 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, - jar: CookieJar, -) -> Response { +pub async fn audit_get(State(state): State, 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, - jar: CookieJar, -) -> Response { +pub async fn status_get(State(state): State, 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() diff --git a/src/web/api.rs b/src/web/api.rs index 256e12c..ce35c58 100644 --- a/src/web/api.rs +++ b/src/web/api.rs @@ -1,25 +1,26 @@ use axum::{ - extract::{Path, State, Query, ConnectInfo}, - http::{StatusCode, HeaderMap}, + extract::{ConnectInfo, Path, Query, State}, + http::{HeaderMap, StatusCode}, response::{IntoResponse, Json, Response}, }; use serde::{Deserialize, Serialize}; use std::net::SocketAddr; -use crate::db::content::{ - list_urls, get_url_by_id, create_url, update_url, delete_url, - list_landing_pages, get_landing_page_by_id, create_landing_page, update_landing_page, delete_landing_page, - get_url_counts, get_landing_page_count -}; -use crate::db::analytics::{ - get_total_clicks, get_total_page_views, get_clicks_trend, get_clicks_trend_raw, - get_metric_rankings, get_metric_rankings_raw -}; -use crate::db::admin::write_audit_log; -use crate::utils::get_client_ip; use crate::auth::generate_token; +use crate::auth::password::hash_password; use crate::auth::ApiUser; +use crate::db::admin::write_audit_log; +use crate::db::analytics::{ + get_clicks_trend, get_clicks_trend_raw, get_metric_rankings, get_metric_rankings_raw, + get_total_clicks, get_total_page_views, +}; +use crate::db::content::{ + create_landing_page, delete_landing_page, delete_url, get_landing_page_by_id, + get_landing_page_count, get_url_by_id, get_url_counts, list_landing_pages, list_urls, + update_landing_page, update_url, +}; use crate::state::AppState; +use crate::utils::get_client_ip; // JSON Payload Structs #[derive(Deserialize)] @@ -29,6 +30,9 @@ pub struct CreateUrlRequest { pub title: Option, pub description: Option, pub tags: Option>, + pub expires_at: Option, + pub password: Option, + pub max_access_count: Option, } #[derive(Deserialize)] @@ -38,6 +42,9 @@ pub struct UpdateUrlRequest { pub description: Option, pub status: String, // 'healthy', 'suspect', 'dead' pub tags: Option>, + pub expires_at: Option, + pub password: Option, + pub max_access_count: Option, } #[derive(Deserialize)] @@ -78,23 +85,95 @@ pub async fn api_create_url( code = generate_token(3); // 6 hex } else { if code.len() != 6 || !code.chars().all(|c| c.is_ascii_hexdigit()) { - return (StatusCode::BAD_REQUEST, Json(ApiError { error: "Short code must be 6 hex characters".to_string() })).into_response(); + return ( + StatusCode::BAD_REQUEST, + Json(ApiError { + error: "Short code must be 6 hex characters".to_string(), + }), + ) + .into_response(); } } + let password_hash = if let Some(ref pwd) = payload.password { + if pwd.is_empty() { + None + } else { + match hash_password(pwd) { + Ok(h) => Some(h), + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError { + error: format!("Password hashing error: {}", e), + }), + ) + .into_response() + } + } + } + } else { + None + }; + let tags = payload.tags.unwrap_or_default(); let conn = state.content_db.lock().unwrap(); - match create_url(&conn, &code, &payload.destination, payload.title.as_deref(), payload.description.as_deref(), &tags) { + match crate::db::content::create_url_extended( + &conn, + &code, + &payload.destination, + payload.title.as_deref(), + payload.description.as_deref(), + &tags, + payload.expires_at.as_deref(), + password_hash.as_deref(), + payload.max_access_count, + ) { Ok(url) => { let ip = get_client_ip(&headers, connect_info); let user_agent = headers.get("user-agent").and_then(|h| h.to_str().ok()); - let _ = write_audit_log(&state.admin_db.lock().unwrap(), &user.0.username, "URL_CREATION", Some("url"), Some(&url.id), Some(&ip), user_agent); + let _ = write_audit_log( + &state.admin_db.lock().unwrap(), + &user.0.username, + "URL_CREATION", + Some("url"), + Some(&url.id), + Some(&ip), + user_agent, + ); + + // Log to system.db + { + let system_conn = state.system_db.lock().unwrap(); + let _ = crate::db::audit_events::write_audit_event( + &system_conn, + &user.0.username, + "URL_CREATION", + "url", + &url.id, + Some(&format!("IP: {:?}, User-Agent: {:?}", ip, user_agent)), + ); + } (StatusCode::CREATED, Json(url)).into_response() } - Err(rusqlite::Error::SqliteFailure(err, _)) if err.code == rusqlite::ErrorCode::ConstraintViolation => { - (StatusCode::CONFLICT, Json(ApiError { error: "Short code already exists".to_string() })).into_response() + Err(rusqlite::Error::SqliteFailure(err, _)) + if err.code == rusqlite::ErrorCode::ConstraintViolation => + { + ( + StatusCode::CONFLICT, + Json(ApiError { + error: "Short code already exists".to_string(), + }), + ) + .into_response() } - Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError { error: e.to_string() })).into_response(), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError { + error: e.to_string(), + }), + ) + .into_response(), } } @@ -117,7 +196,13 @@ pub async fn api_list_urls( let conn = state.content_db.lock().unwrap(); match list_urls(&conn, limit, offset, query.tag.as_deref()) { Ok(urls) => Json(urls).into_response(), - Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError { error: e.to_string() })).into_response(), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError { + error: e.to_string(), + }), + ) + .into_response(), } } @@ -130,8 +215,20 @@ pub async fn api_get_url( let conn = state.content_db.lock().unwrap(); match get_url_by_id(&conn, &uuid) { Ok(Some(url)) => Json(url).into_response(), - Ok(None) => (StatusCode::NOT_FOUND, Json(ApiError { error: "URL not found".to_string() })).into_response(), - Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError { error: e.to_string() })).into_response(), + Ok(None) => ( + StatusCode::NOT_FOUND, + Json(ApiError { + error: "URL not found".to_string(), + }), + ) + .into_response(), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError { + error: e.to_string(), + }), + ) + .into_response(), } } @@ -146,15 +243,81 @@ pub async fn api_update_url( ) -> Response { let tags = payload.tags.unwrap_or_default(); let conn = state.content_db.lock().unwrap(); - match update_url(&conn, &uuid, &payload.destination, payload.title.as_deref(), payload.description.as_deref(), &payload.status, &tags) { + match update_url( + &conn, + &uuid, + &payload.destination, + payload.title.as_deref(), + payload.description.as_deref(), + &payload.status, + &tags, + ) { Ok(Some(url)) => { + // Update password + if let Some(ref pwd) = payload.password { + if pwd.is_empty() { + let _ = crate::db::content::remove_url_password(&conn, &uuid); + } else { + if let Ok(hash) = hash_password(pwd) { + let _ = crate::db::content::set_url_password(&conn, &uuid, &hash); + } + } + } + + // Update expires_at and max_access_count + let _ = conn.execute( + "UPDATE urls SET expires_at = ?1, max_access_count = ?2 WHERE id = ?3;", + rusqlite::params![ + payload.expires_at.as_deref(), + payload.max_access_count, + uuid + ], + ); + + // Fetch the fully updated URL + let updated_url = get_url_by_id(&conn, &uuid).unwrap_or(Some(url)).unwrap(); + let ip = get_client_ip(&headers, connect_info); let user_agent = headers.get("user-agent").and_then(|h| h.to_str().ok()); - let _ = write_audit_log(&state.admin_db.lock().unwrap(), &user.0.username, "URL_UPDATE", Some("url"), Some(&uuid), Some(&ip), user_agent); - Json(url).into_response() + let _ = write_audit_log( + &state.admin_db.lock().unwrap(), + &user.0.username, + "URL_UPDATE", + Some("url"), + Some(&uuid), + Some(&ip), + user_agent, + ); + + // Log to system.db + { + let system_conn = state.system_db.lock().unwrap(); + let _ = crate::db::audit_events::write_audit_event( + &system_conn, + &user.0.username, + "URL_UPDATE", + "url", + &uuid, + Some(&format!("IP: {:?}, User-Agent: {:?}", ip, user_agent)), + ); + } + + Json(updated_url).into_response() } - Ok(None) => (StatusCode::NOT_FOUND, Json(ApiError { error: "URL not found".to_string() })).into_response(), - Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError { error: e.to_string() })).into_response(), + Ok(None) => ( + StatusCode::NOT_FOUND, + Json(ApiError { + error: "URL not found".to_string(), + }), + ) + .into_response(), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError { + error: e.to_string(), + }), + ) + .into_response(), } } @@ -171,11 +334,45 @@ pub async fn api_delete_url( Ok(true) => { let ip = get_client_ip(&headers, connect_info); let user_agent = headers.get("user-agent").and_then(|h| h.to_str().ok()); - let _ = write_audit_log(&state.admin_db.lock().unwrap(), &user.0.username, "URL_DELETION", Some("url"), Some(&uuid), Some(&ip), user_agent); + let _ = write_audit_log( + &state.admin_db.lock().unwrap(), + &user.0.username, + "URL_DELETION", + Some("url"), + Some(&uuid), + Some(&ip), + user_agent, + ); + + // Log to system.db + { + let system_conn = state.system_db.lock().unwrap(); + let _ = crate::db::audit_events::write_audit_event( + &system_conn, + &user.0.username, + "URL_DELETION", + "url", + &uuid, + Some(&format!("IP: {:?}, User-Agent: {:?}", ip, user_agent)), + ); + } + StatusCode::NO_CONTENT.into_response() } - Ok(false) => (StatusCode::NOT_FOUND, Json(ApiError { error: "URL not found".to_string() })).into_response(), - Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError { error: e.to_string() })).into_response(), + Ok(false) => ( + StatusCode::NOT_FOUND, + Json(ApiError { + error: "URL not found".to_string(), + }), + ) + .into_response(), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError { + error: e.to_string(), + }), + ) + .into_response(), } } @@ -194,22 +391,57 @@ pub async fn api_create_page( code = generate_token(2); // 4 hex } else { if code.len() != 4 || !code.chars().all(|c| c.is_ascii_hexdigit()) { - return (StatusCode::BAD_REQUEST, Json(ApiError { error: "Short code must be 4 hex characters".to_string() })).into_response(); + return ( + StatusCode::BAD_REQUEST, + Json(ApiError { + error: "Short code must be 4 hex characters".to_string(), + }), + ) + .into_response(); } } let conn = state.content_db.lock().unwrap(); - match create_landing_page(&conn, &code, &payload.slug, &payload.title, &payload.html_content, &payload.state) { + match create_landing_page( + &conn, + &code, + &payload.slug, + &payload.title, + &payload.html_content, + &payload.state, + ) { Ok(page) => { let ip = get_client_ip(&headers, connect_info); let user_agent = headers.get("user-agent").and_then(|h| h.to_str().ok()); - let _ = write_audit_log(&state.admin_db.lock().unwrap(), &user.0.username, "PAGE_CREATION", Some("page"), Some(&page.id), Some(&ip), user_agent); + let _ = write_audit_log( + &state.admin_db.lock().unwrap(), + &user.0.username, + "PAGE_CREATION", + Some("page"), + Some(&page.id), + Some(&ip), + user_agent, + ); (StatusCode::CREATED, Json(page)).into_response() } - Err(rusqlite::Error::SqliteFailure(err, _)) if err.code == rusqlite::ErrorCode::ConstraintViolation => { - (StatusCode::CONFLICT, Json(ApiError { error: "Short code already exists".to_string() })).into_response() + Err(rusqlite::Error::SqliteFailure(err, _)) + if err.code == rusqlite::ErrorCode::ConstraintViolation => + { + ( + StatusCode::CONFLICT, + Json(ApiError { + error: "Short code already exists".to_string(), + }), + ) + .into_response() } - Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError { error: e.to_string() })).into_response(), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError { + error: e.to_string(), + }), + ) + .into_response(), } } @@ -225,7 +457,13 @@ pub async fn api_list_pages( let conn = state.content_db.lock().unwrap(); match list_landing_pages(&conn, limit, offset) { Ok(pages) => Json(pages).into_response(), - Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError { error: e.to_string() })).into_response(), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError { + error: e.to_string(), + }), + ) + .into_response(), } } @@ -238,8 +476,20 @@ pub async fn api_get_page( let conn = state.content_db.lock().unwrap(); match get_landing_page_by_id(&conn, &uuid) { Ok(Some(page)) => Json(page).into_response(), - Ok(None) => (StatusCode::NOT_FOUND, Json(ApiError { error: "Page not found".to_string() })).into_response(), - Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError { error: e.to_string() })).into_response(), + Ok(None) => ( + StatusCode::NOT_FOUND, + Json(ApiError { + error: "Page not found".to_string(), + }), + ) + .into_response(), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError { + error: e.to_string(), + }), + ) + .into_response(), } } @@ -253,15 +503,42 @@ pub async fn api_update_page( Json(payload): Json, ) -> Response { let conn = state.content_db.lock().unwrap(); - match update_landing_page(&conn, &uuid, &payload.slug, &payload.title, &payload.html_content, &payload.state) { + match update_landing_page( + &conn, + &uuid, + &payload.slug, + &payload.title, + &payload.html_content, + &payload.state, + ) { Ok(Some(page)) => { let ip = get_client_ip(&headers, connect_info); let user_agent = headers.get("user-agent").and_then(|h| h.to_str().ok()); - let _ = write_audit_log(&state.admin_db.lock().unwrap(), &user.0.username, "PAGE_UPDATE", Some("page"), Some(&uuid), Some(&ip), user_agent); + let _ = write_audit_log( + &state.admin_db.lock().unwrap(), + &user.0.username, + "PAGE_UPDATE", + Some("page"), + Some(&uuid), + Some(&ip), + user_agent, + ); Json(page).into_response() } - Ok(None) => (StatusCode::NOT_FOUND, Json(ApiError { error: "Page not found".to_string() })).into_response(), - Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError { error: e.to_string() })).into_response(), + Ok(None) => ( + StatusCode::NOT_FOUND, + Json(ApiError { + error: "Page not found".to_string(), + }), + ) + .into_response(), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError { + error: e.to_string(), + }), + ) + .into_response(), } } @@ -278,11 +555,31 @@ pub async fn api_delete_page( Ok(true) => { let ip = get_client_ip(&headers, connect_info); let user_agent = headers.get("user-agent").and_then(|h| h.to_str().ok()); - let _ = write_audit_log(&state.admin_db.lock().unwrap(), &user.0.username, "PAGE_DELETION", Some("page"), Some(&uuid), Some(&ip), user_agent); + let _ = write_audit_log( + &state.admin_db.lock().unwrap(), + &user.0.username, + "PAGE_DELETION", + Some("page"), + Some(&uuid), + Some(&ip), + user_agent, + ); StatusCode::NO_CONTENT.into_response() } - Ok(false) => (StatusCode::NOT_FOUND, Json(ApiError { error: "Page not found".to_string() })).into_response(), - Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError { error: e.to_string() })).into_response(), + Ok(false) => ( + StatusCode::NOT_FOUND, + Json(ApiError { + error: "Page not found".to_string(), + }), + ) + .into_response(), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError { + error: e.to_string(), + }), + ) + .into_response(), } } @@ -299,10 +596,7 @@ pub struct OverallStatsResponse { } // GET /api/v1/stats -pub async fn api_overall_stats( - State(state): State, - _user: ApiUser, -) -> Response { +pub async fn api_overall_stats(State(state): State, _user: ApiUser) -> Response { let (total_urls, active_links, dead_links) = { let conn = state.content_db.lock().unwrap(); get_url_counts(&conn).unwrap_or((0, 0, 0)) @@ -315,7 +609,7 @@ pub async fn api_overall_stats( let conn = state.analytics_db.lock().unwrap(); ( get_total_clicks(&conn).unwrap_or(0), - get_total_page_views(&conn).unwrap_or(0) + get_total_page_views(&conn).unwrap_or(0), ) }; @@ -326,7 +620,8 @@ pub async fn api_overall_stats( total_page_views, active_links, dead_links, - }).into_response() + }) + .into_response() } #[derive(Serialize)] @@ -348,7 +643,13 @@ pub async fn api_url_stats( { let conn = state.content_db.lock().unwrap(); if get_url_by_id(&conn, &uuid).unwrap_or(None).is_none() { - return (StatusCode::NOT_FOUND, Json(ApiError { error: "URL not found".to_string() })).into_response(); + return ( + StatusCode::NOT_FOUND, + Json(ApiError { + error: "URL not found".to_string(), + }), + ) + .into_response(); } } @@ -372,7 +673,8 @@ pub async fn api_url_stats( top_countries, top_referrers, top_browsers, - }).into_response() + }) + .into_response() } // GET /api/v1/stats/page/:uuid @@ -384,8 +686,17 @@ pub async fn api_page_stats( // Verify Page exists { let conn = state.content_db.lock().unwrap(); - if get_landing_page_by_id(&conn, &uuid).unwrap_or(None).is_none() { - return (StatusCode::NOT_FOUND, Json(ApiError { error: "Page not found".to_string() })).into_response(); + if get_landing_page_by_id(&conn, &uuid) + .unwrap_or(None) + .is_none() + { + return ( + StatusCode::NOT_FOUND, + Json(ApiError { + error: "Page not found".to_string(), + }), + ) + .into_response(); } } @@ -409,5 +720,445 @@ pub async fn api_page_stats( top_countries, top_referrers, top_browsers, - }).into_response() + }) + .into_response() +} + +// --- QR Stats Endpoints --- + +#[derive(Serialize)] +pub struct QrStatsResponse { + pub code: String, + pub scan_count: i64, + pub scans: Vec<(String, String)>, +} + +// GET /api/v1/qr/:code +pub async fn api_get_qr_stats( + State(state): State, + _user: ApiUser, + Path(code): Path, +) -> Response { + let url_opt = { + let conn = state.content_db.lock().unwrap(); + crate::db::content::get_url_by_code(&conn, &code).unwrap_or(None) + }; + + let url = match url_opt { + Some(u) => u, + None => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError { + error: "URL not found".to_string(), + }), + ) + .into_response() + } + }; + + let (scan_count, scans) = { + let conn = state.analytics_db.lock().unwrap(); + let count = crate::db::qr::get_qr_scan_count(&conn, &url.id).unwrap_or(0); + let scans_list = crate::db::qr::get_qr_stats_for_url(&conn, &url.id).unwrap_or_default(); + (count, scans_list) + }; + + Json(QrStatsResponse { + code, + scan_count, + scans, + }) + .into_response() +} + +// --- Audit Trail Endpoints --- + +#[derive(Deserialize)] +pub struct AuditQuery { + pub limit: Option, + pub offset: Option, + pub actor: Option, + pub action: Option, +} + +// GET /api/v1/audit +pub async fn api_list_audit( + State(state): State, + _user: ApiUser, + Query(query): Query, +) -> Response { + let limit = query.limit.unwrap_or(50); + let offset = query.offset.unwrap_or(0); + + let conn = state.system_db.lock().unwrap(); + match crate::db::audit_events::list_audit_events( + &conn, + limit, + offset, + query.actor.as_deref(), + query.action.as_deref(), + ) { + Ok(events) => Json(events).into_response(), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError { + error: e.to_string(), + }), + ) + .into_response(), + } +} + +// --- Preview Endpoints --- + +#[derive(Deserialize)] +pub struct SetPreviewRequest { + pub title: Option, + pub description: Option, + pub logo_url: Option, + pub button_text: Option, +} + +// POST /api/v1/urls/:uuid/preview +pub async fn api_set_preview( + State(state): State, + user: ApiUser, + Path(uuid): Path, + Json(payload): Json, +) -> Response { + // Verify URL exists + { + let conn = state.content_db.lock().unwrap(); + if get_url_by_id(&conn, &uuid).unwrap_or(None).is_none() { + return ( + StatusCode::NOT_FOUND, + Json(ApiError { + error: "URL not found".to_string(), + }), + ) + .into_response(); + } + } + + let conn = state.content_db.lock().unwrap(); + match crate::db::preview::upsert_preview( + &conn, + &uuid, + payload.title.as_deref(), + payload.description.as_deref(), + payload.logo_url.as_deref(), + payload.button_text.as_deref(), + ) { + Ok(preview) => { + // Audit Log + { + let system_conn = state.system_db.lock().unwrap(); + let _ = crate::db::audit_events::write_audit_event( + &system_conn, + &user.0.username, + "SET_PREVIEW", + "url", + &uuid, + None, + ); + } + (StatusCode::OK, Json(preview)).into_response() + } + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError { + error: e.to_string(), + }), + ) + .into_response(), + } +} + +// GET /api/v1/urls/:uuid/preview +pub async fn api_get_preview( + State(state): State, + _user: ApiUser, + Path(uuid): Path, +) -> Response { + // Verify URL exists + { + let conn = state.content_db.lock().unwrap(); + if get_url_by_id(&conn, &uuid).unwrap_or(None).is_none() { + return ( + StatusCode::NOT_FOUND, + Json(ApiError { + error: "URL not found".to_string(), + }), + ) + .into_response(); + } + } + + let conn = state.content_db.lock().unwrap(); + match crate::db::preview::get_preview(&conn, &uuid) { + Ok(Some(preview)) => Json(preview).into_response(), + Ok(None) => ( + StatusCode::NOT_FOUND, + Json(ApiError { + error: "Preview not configured for this URL".to_string(), + }), + ) + .into_response(), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError { + error: e.to_string(), + }), + ) + .into_response(), + } +} + +// DELETE /api/v1/urls/:uuid/preview +pub async fn api_delete_preview( + State(state): State, + user: ApiUser, + Path(uuid): Path, +) -> Response { + // Verify URL exists + { + let conn = state.content_db.lock().unwrap(); + if get_url_by_id(&conn, &uuid).unwrap_or(None).is_none() { + return ( + StatusCode::NOT_FOUND, + Json(ApiError { + error: "URL not found".to_string(), + }), + ) + .into_response(); + } + } + + let conn = state.content_db.lock().unwrap(); + match crate::db::preview::delete_preview(&conn, &uuid) { + Ok(true) => { + // Audit Log + { + let system_conn = state.system_db.lock().unwrap(); + let _ = crate::db::audit_events::write_audit_event( + &system_conn, + &user.0.username, + "DELETE_PREVIEW", + "url", + &uuid, + None, + ); + } + StatusCode::NO_CONTENT.into_response() + } + Ok(false) => ( + StatusCode::NOT_FOUND, + Json(ApiError { + error: "Preview not configured".to_string(), + }), + ) + .into_response(), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError { + error: e.to_string(), + }), + ) + .into_response(), + } +} + +// --- Password Endpoints --- + +#[derive(Deserialize)] +pub struct SetPasswordRequest { + pub password: String, +} + +// POST /api/v1/urls/:uuid/password +pub async fn api_set_password( + State(state): State, + user: ApiUser, + Path(uuid): Path, + Json(payload): Json, +) -> Response { + // Verify URL exists + { + let conn = state.content_db.lock().unwrap(); + if get_url_by_id(&conn, &uuid).unwrap_or(None).is_none() { + return ( + StatusCode::NOT_FOUND, + Json(ApiError { + error: "URL not found".to_string(), + }), + ) + .into_response(); + } + } + + let hash = match hash_password(&payload.password) { + Ok(h) => h, + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError { + error: format!("Password hashing error: {}", e), + }), + ) + .into_response() + } + }; + + let conn = state.content_db.lock().unwrap(); + match crate::db::content::set_url_password(&conn, &uuid, &hash) { + Ok(true) => { + // Audit Log + { + let system_conn = state.system_db.lock().unwrap(); + let _ = crate::db::audit_events::write_audit_event( + &system_conn, + &user.0.username, + "SET_PASSWORD", + "url", + &uuid, + None, + ); + } + ( + StatusCode::OK, + Json(serde_json::json!({ "status": "success" })), + ) + .into_response() + } + Ok(false) => ( + StatusCode::NOT_FOUND, + Json(ApiError { + error: "URL not found".to_string(), + }), + ) + .into_response(), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError { + error: e.to_string(), + }), + ) + .into_response(), + } +} + +// DELETE /api/v1/urls/:uuid/password +pub async fn api_remove_password( + State(state): State, + user: ApiUser, + Path(uuid): Path, +) -> Response { + // Verify URL exists + { + let conn = state.content_db.lock().unwrap(); + if get_url_by_id(&conn, &uuid).unwrap_or(None).is_none() { + return ( + StatusCode::NOT_FOUND, + Json(ApiError { + error: "URL not found".to_string(), + }), + ) + .into_response(); + } + } + + let conn = state.content_db.lock().unwrap(); + match crate::db::content::remove_url_password(&conn, &uuid) { + Ok(true) => { + // Audit Log + { + let system_conn = state.system_db.lock().unwrap(); + let _ = crate::db::audit_events::write_audit_event( + &system_conn, + &user.0.username, + "REMOVE_PASSWORD", + "url", + &uuid, + None, + ); + } + ( + StatusCode::OK, + Json(serde_json::json!({ "status": "success" })), + ) + .into_response() + } + Ok(false) => ( + StatusCode::NOT_FOUND, + Json(ApiError { + error: "URL not found".to_string(), + }), + ) + .into_response(), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError { + error: e.to_string(), + }), + ) + .into_response(), + } +} + +#[derive(Deserialize)] +pub struct CreateQrRequest { + pub url_id: String, + pub style: Option, +} + +// POST /api/qr or /api/v1/qr +pub async fn api_create_qr( + State(state): State, + user: ApiUser, + Json(payload): Json, +) -> Response { + // Verify URL exists in content.db + { + let conn = state.content_db.lock().unwrap(); + if get_url_by_id(&conn, &payload.url_id) + .unwrap_or(None) + .is_none() + { + return ( + StatusCode::NOT_FOUND, + Json(ApiError { + error: "URL not found".to_string(), + }), + ) + .into_response(); + } + } + + let style = payload.style.unwrap_or_else(|| "default".to_string()); + let conn = state.content_db.lock().unwrap(); + match crate::db::qr::upsert_qr_code(&conn, &payload.url_id, &style) { + Ok(_) => { + // Write Audit Event + { + let system_conn = state.system_db.lock().unwrap(); + let _ = crate::db::audit_events::write_audit_event( + &system_conn, + &user.0.username, + "CREATE_QR", + "qr_code", + &payload.url_id, + Some(&format!("Style: {}", style)), + ); + } + (StatusCode::OK, Json(serde_json::json!({ "status": "success", "url_id": payload.url_id, "style": style }))).into_response() + } + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError { + error: e.to_string(), + }), + ) + .into_response(), + } } diff --git a/src/web/bulk.rs b/src/web/bulk.rs new file mode 100644 index 0000000..b2d721f --- /dev/null +++ b/src/web/bulk.rs @@ -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, + pub format: Option, +} + +#[derive(Deserialize)] +pub struct BulkUrlItem { + pub destination: String, + pub code: Option, + pub title: Option, + pub description: Option, + pub tags: Option>, + pub expires_at: Option, + pub password: Option, + pub max_access_count: Option, +} + +#[derive(Serialize)] +pub struct BulkErrorResponse { + pub error: String, +} + +// POST /api/v1/bulk/qr +pub async fn api_bulk_qr( + State(state): State, + headers: HeaderMap, + user: ApiUser, + Json(payload): Json, +) -> 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, + headers: HeaderMap, + connect_info: Option>, + user: ApiUser, + Json(payload): Json>, +) -> 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() +} diff --git a/src/web/mod.rs b/src/web/mod.rs index db17ea1..fcf6fd3 100644 --- a/src/web/mod.rs +++ b/src/web/mod.rs @@ -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; diff --git a/src/web/pages.rs b/src/web/pages.rs index de79905..3e4dd1d 100644 --- a/src/web/pages.rs +++ b/src/web/pages.rs @@ -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(); diff --git a/src/web/password_gate.rs b/src/web/password_gate.rs new file mode 100644 index 0000000..9871f2c --- /dev/null +++ b/src/web/password_gate.rs @@ -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) -> impl IntoResponse { + GateTemplate { code, error: None } +} + +// POST /gate/:code +pub async fn gate_post( + State(state): State, + Path(code): Path, + jar: CookieJar, + Form(form): Form, +) -> 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() + } +} diff --git a/src/web/qr.rs b/src/web/qr.rs new file mode 100644 index 0000000..3dc7565 --- /dev/null +++ b/src/web/qr.rs @@ -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, + headers: HeaderMap, + connect_info: Option>, + Path(file): Path, +) -> 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()) +} diff --git a/src/web/redirect.rs b/src/web/redirect.rs index f0530db..f0cb6fe 100644 --- a/src/web/redirect.rs +++ b/src/web/redirect.rs @@ -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, + jar: CookieJar, Path(code): Path, headers: HeaderMap, connect_info: Option>, @@ -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() } } diff --git a/src/web/routes.rs b/src/web/routes.rs index 8ed85fa..fbd6f10 100644 --- a/src/web/routes.rs +++ b/src/web/routes.rs @@ -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) } diff --git a/src/web/system.rs b/src/web/system.rs index 6c9144a..0e20eee 100644 --- a/src/web/system.rs +++ b/src/web/system.rs @@ -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() } diff --git a/templates/gate.html b/templates/gate.html new file mode 100644 index 0000000..bc3cb40 --- /dev/null +++ b/templates/gate.html @@ -0,0 +1,177 @@ + + + + + + Password Protected Link - BZOD + + + + +
+
+ + + +
+

Secure Link

+

This link is password-protected. Please enter the password to proceed.

+ + {% if let Some(err) = error %} +
+ {{ err }} +
+ {% endif %} + +
+
+ + +
+ + +
+
+ + + diff --git a/templates/preview.html b/templates/preview.html new file mode 100644 index 0000000..6c143c0 --- /dev/null +++ b/templates/preview.html @@ -0,0 +1,184 @@ + + + + + + {% if let Some(t) = title %} + {{ t }} + + + {% else %} + Link Preview - BZOD + + + {% endif %} + + {% if let Some(d) = description %} + + + + {% endif %} + + {% if let Some(l) = logo_url %} + + + {% endif %} + + + + + + + + +
+
+ {% if let Some(l) = logo_url %} + Logo + {% else %} + + + + + + {% endif %} +
+ + {% if let Some(t) = title %} +

{{ t }}

+ {% else %} +

You are being redirected

+ {% endif %} + + {% if let Some(d) = description %} +

{{ d }}

+ {% else %} +

Click the button below to proceed to the destination URL.

+ {% endif %} + + {{ button_text }} + + +
+ + + diff --git a/templates/settings.html b/templates/settings.html index 2326624..faa8356 100644 --- a/templates/settings.html +++ b/templates/settings.html @@ -108,6 +108,32 @@ + +
+

+ + Bulk QR Code Export +

+ +
+ + +
+ + +
+ +

+ Generates a ZIP archive containing QR codes for all shortened links in your registry, alongside a manifest.csv file mapping codes to their destinations. +

+ + +
+
+ diff --git a/templates/status_ui.html b/templates/status_ui.html index e4c026b..a689db3 100644 --- a/templates/status_ui.html +++ b/templates/status_ui.html @@ -66,4 +66,97 @@ + + +
+
+

+ + Link Health Registry +

+
+ +
+ + + + + + + + + + + + + {% if urls.is_empty() %} + + + + {% else %} + {% for url in urls %} + + + + + + + + + {% endfor %} + {% endif %} + +
Short LinkDestination URLGeneral StatusDetailed DiagnosticLatencyLast Checked
+ No shortened links registered to monitor. +
+ + /{{ url.code }} + +
+ {{ url.title.as_deref().unwrap_or("") }} +
+
+ {{ url.destination }} + + {% if url.status == "healthy" %} + Healthy + {% else if url.status == "suspect" %} + Suspect + {% else %} + Dead + {% endif %} + + {% if let Some(last_status) = url.last_status %} + {% if last_status == "healthy" %} + HTTP OK + {% else if last_status == "timeout" %} + Timeout + {% else if last_status == "dns_failure" %} + DNS Error + {% else if last_status == "tls_error" %} + TLS Error + {% else if last_status == "connection_refused" %} + Refused + {% else if last_status == "redirect_loop" %} + Redirect Loop + {% else if last_status.starts_with("http_") %} + {{ last_status.replace("http_", "HTTP ") }} + {% else %} + {{ last_status }} + {% endif %} + {% else %} + Pending + {% endif %} + + {% if let Some(latency) = url.last_latency_ms %} + {{ latency }} ms + {% else %} + - + {% endif %} + + {{ url.updated_at[0..10] }} {{ url.updated_at[11..16] }} +
+
+
{% endblock %} + diff --git a/templates/urls.html b/templates/urls.html index 13d25a3..9e6551c 100644 --- a/templates/urls.html +++ b/templates/urls.html @@ -49,6 +49,21 @@ +
+ + +
+ +
+ + +
+ +
+ + +
+ @@ -74,6 +89,7 @@ Short Link Destination + QR Code Health Tags Created @@ -83,7 +99,7 @@ {% if urls.is_empty() %} - + No shortened URLs registered. Create one to get started! @@ -91,9 +107,16 @@ {% for url in urls %} - - bzo.in/{{ url.code }} - +
+ + {{ base_url.replace("https://", "").replace("http://", "") }}/{{ url.code }} + + {% if url.is_password_protected() %} + + + + {% endif %} +
{{ url.title.as_deref().unwrap_or("") }}
@@ -109,6 +132,43 @@ {% endif %} {% endif %} + + {% if let Some(expires_at) = url.expires_at.as_deref() %} + {% if !expires_at.is_empty() %} +
+ + Expires: {{ expires_at[0..10] }} {{ expires_at[11..16] }} + {% if url.expired %} + Expired + {% endif %} +
+ {% endif %} + {% endif %} + + {% if let Some(max) = url.max_access_count %} +
+ + Clicks: {{ url.access_count }} / {{ max }} + {% if url.is_access_exhausted() %} + Limit Reached + {% endif %} +
+ {% else %} + {% if url.access_count > 0 %} +
+ Clicks: {{ url.access_count }} +
+ {% endif %} + {% endif %} + + + + QR + +
+ PNG + SVG +
diff --git a/tests/feature_tests.rs b/tests/feature_tests.rs new file mode 100644 index 0000000..5e53475 --- /dev/null +++ b/tests/feature_tests.rs @@ -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"); +} diff --git a/tests/qr_tests.rs b/tests/qr_tests.rs new file mode 100644 index 0000000..ef6233f --- /dev/null +++ b/tests/qr_tests.rs @@ -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("")); +} + +#[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"); +} diff --git a/tests/security_tests.rs b/tests/security_tests.rs index 374229e..fcea156 100644 --- a/tests/security_tests.rs +++ b/tests/security_tests.rs @@ -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 + ); } }