From a6e8be75534ec6933d3cb07cdf5598c90b7462cf Mon Sep 17 00:00:00 2001 From: cabbage Date: Fri, 10 Jul 2026 04:46:34 +0000 Subject: [PATCH] rewrite URL shortener in Rust --- .dockerignore | 10 + .gitignore | 11 +- Cargo.lock | 890 +++++++++++++++++++++++ Cargo.toml | 24 + DEPLOYMENT.md | 140 ++++ Dockerfile | 29 + README.md | 432 ++++------- TASK-urlshortener.md | 73 -- config.example.toml | 22 + config.json | 16 - deploy/docker-compose.yml | 17 + docker-compose.yml | 19 +- nginx.conf | 53 +- src/lib.rs | 1452 +++++++++++++++++++++++++++++++++++++ src/main.rs | 115 +++ urlshort.py | 705 ------------------ 16 files changed, 2906 insertions(+), 1102 deletions(-) create mode 100644 .dockerignore create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 DEPLOYMENT.md create mode 100644 Dockerfile delete mode 100644 TASK-urlshortener.md create mode 100644 config.example.toml delete mode 100644 config.json create mode 100644 deploy/docker-compose.yml create mode 100644 src/lib.rs create mode 100644 src/main.rs delete mode 100644 urlshort.py diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..3333a7d --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +.git +.gitignore +target +data +config.toml +config.json +*.db +*.db-* +*.key +*.log diff --git a/.gitignore b/.gitignore index b7d511e..dc8fa7c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,9 @@ -data/ -*.log \ No newline at end of file +/target/ +/config.toml +/data/* +!/data/.gitignore +*.db +*.db-* +*.key +*.log +.env diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..4b27759 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,890 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ascii" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16" + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "cc" +version = "1.2.66" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5d6cac793997bd970000024b2934968efe83b382de4fdcf4fcb46b6ee4ad996" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures", + "rand_core", +] + +[[package]] +name = "chunked_transfer" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901" + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "ctrlc" +version = "3.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0b1fab2ae45819af2d0731d60f2afe17227ebb1a1538a236da84c93e9a60162" +dependencies = [ + "dispatch2", + "nix", + "windows-sys", +] + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags", + "block2", + "libc", + "objc2", +] + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "rand_core", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashlink" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32069d97bb81e38fa67eab65e3393bf804bb85969f2bc06bf13f64aef5aba248" +dependencies = [ + "hashbrown 0.17.1", +] + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "include_dir" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "923d117408f1e49d914f1a379a309cffe4f18c05cf4e3d12e613a15fc81bd0dd" +dependencies = [ + "include_dir_macros", +] + +[[package]] +name = "include_dir_macros" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cab85a7ed0bd5f0e76d93846e0147172bed2e2d3f859bcc33a8d9699cad1a75" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libsqlite3-sys" +version = "0.38.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6c19a05435c21ac299d71b6a9c13db3e3f47c520517d58990a462a1397a61db" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rsqlite-vfs" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c" +dependencies = [ + "hashbrown 0.16.1", + "thiserror", +] + +[[package]] +name = "rusqlite" +version = "0.40.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11438310b19e3109b6446c33d1ed5e889428cf2e278407bc7896bc4aaea43323" +dependencies = [ + "bitflags", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", + "sqlite-wasm-rs", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "sqlite-wasm-rs" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75" +dependencies = [ + "cc", + "js-sys", + "rsqlite-vfs", + "wasm-bindgen", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "syn" +version = "2.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tiny_http" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389915df6413a2e74fb181895f933386023c71110878cd0825588928e64cdc82" +dependencies = [ + "ascii", + "chunked_transfer", + "httpdate", + "log", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "toml" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "ushort" +version = "0.1.0" +dependencies = [ + "ctrlc", + "include_dir", + "rand", + "rusqlite", + "serde", + "serde_json", + "tiny_http", + "toml", + "url", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "winnow" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..e133c20 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "ushort" +version = "0.1.0" +edition = "2021" +rust-version = "1.86" +description = "A compact, self-contained URL shortener" + +[dependencies] +ctrlc = { version = "3.5.2", features = ["termination"] } +include_dir = "0.7.4" +rand = "0.10.2" +rusqlite = { version = "0.40.1", features = ["bundled"] } +serde = { version = "1.0.210", features = ["derive"] } +serde_json = "1.0.128" +tiny_http = "0.12.0" +toml = "1.1.2" +url = "2.5.8" + +[profile.release] +codegen-units = 1 +lto = true +opt-level = "z" +panic = "abort" +strip = true diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md new file mode 100644 index 0000000..56b006b --- /dev/null +++ b/DEPLOYMENT.md @@ -0,0 +1,140 @@ +# Production migration and rollback + +This runbook moves the titan deployment from the Python source tree at +`/root/repo/urlshortener` to the published `sodium/ushort` image. The final +server-side application directory is `/root/compose/ushort`; no source checkout +is required on titan. + +Do not cut over until the exact image tag has been built for both `linux/amd64` +and `linux/arm64`, pushed, and recorded by digest. + +## 1. Prepare without affecting production + +Create this layout on titan: + +```text +/root/compose/ushort/ +├── docker-compose.yml +├── config.toml +└── data/ +``` + +Copy `deploy/docker-compose.yml` as the Compose file. Convert the live JSON +settings to TOML using `config.example.toml` as the reference, with these +cutover-specific rules: + +- keep the live `base_url`, limits, retention, and rate-limit values; +- set `db_path = "data/urlshort.db"`; +- set `production = false` so the binary serves its embedded frontend; and +- generate a new API key, because the old key existed in the legacy Git + history. + +Keep `config.toml` owned by `1001:1001` with mode `0400`, matching the +container identity. Do not copy it into Git or a container image. + +Validate the deployment file before stopping anything: + +```bash +cd /root/compose/ushort +chown 1001:1001 config.toml +chmod 0400 config.toml +docker-compose config +docker pull sodium/ushort:0.1.0 +``` + +## 2. Take a consistent database copy + +The final copy must be made while the Python service is stopped so no committed +row is missed and no SQLite journal is in flight: + +```bash +cd /root/repo/urlshortener +docker-compose stop urlshort + +cp -a data/urlshort.db /root/compose/ushort/data/urlshort.db +cp -a data/urlshort.db /root/compose/ushort/data/urlshort.db.pre-rust +chown -R 1001:1001 /root/compose/ushort/data +chmod 0750 /root/compose/ushort/data +chmod 0640 /root/compose/ushort/data/urlshort.db* +``` + +Do not delete or alter the original database during the initial soak period. + +## 3. Start and validate ushort locally + +```bash +cd /root/compose/ushort +docker-compose up -d +docker-compose ps +docker-compose logs --tail=100 ushort + +curl -i http://127.0.0.1:18082/s/api/health +curl -I http://127.0.0.1:18082/s/ +``` + +Expected results are HTTP 200 health JSON and HTTP 200 HTML. Confirm the +container does not restart and the database remains writable. + +## 4. Switch nginx + +Replace the old `include /root/repo/urlshortener/nginx.conf;` in the `xcel.me` +vhost with the location blocks from the new `nginx.conf`. Those blocks proxy +the frontend as well as API/redirect traffic to `127.0.0.1:18082`; they do not +refer to a source or static-file directory. + +Test before reload: + +```bash +docker exec nginx nginx -t +docker exec nginx nginx -s reload +``` + +Then validate through the public endpoint: + +```bash +curl -i https://xcel.me/s/api/health +curl -I https://xcel.me/s/ +curl -I https://xcel.me/s/static/app.js +``` + +Check that `/s/` is `no-store`, static assets are `no-cache`, an existing short +code still returns the original 302 target, and its visit count increments once. + +## 5. Soak and clean up + +During the soak period, monitor: + +```bash +cd /root/compose/ushort +docker-compose ps +docker-compose logs --tail=200 ushort +``` + +After the rollback window closes: + +1. retain a protected database backup; +2. remove the obsolete `/root/repo/urlshortener` source tree; +3. remove its `/srv/urlshortener` bind mount from the global nginx Compose + file; and +4. recreate nginx and re-run `nginx -t`. + +At that point, application source exists only in Gitea, while titan contains +only the Compose file, TOML config, and data under `/root/compose/ushort`. + +## Rollback + +If validation fails before public traffic is enabled: + +```bash +cd /root/compose/ushort +docker-compose down +cd /root/repo/urlshortener +docker-compose start urlshort +``` + +Restore the old nginx include and reload nginx. + +If public writes occurred after cutover, stop ushort first and copy its current +database back to the legacy data path before starting Python; otherwise those +new short URLs would be lost. The schema is deliberately identical, so no +reverse schema migration is required. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..a01e558 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,29 @@ +FROM rust:1.96.1-alpine3.24 AS source + +RUN apk add --no-cache musl-dev +WORKDIR /src + +COPY Cargo.toml Cargo.lock ./ +COPY config.example.toml ./ +COPY src ./src +COPY static ./static + +FROM source AS tester +RUN cargo test --locked --all-targets + +FROM source AS builder +RUN cargo build --locked --release + +FROM scratch + +LABEL org.opencontainers.image.title="ushort" \ + org.opencontainers.image.description="Self-contained URL shortener" \ + org.opencontainers.image.source="https://git.xcel.me/cabbage/ushort" + +WORKDIR /app +COPY --from=builder --chmod=0555 /src/target/release/ushort /ushort + +USER 65532:65532 +EXPOSE 8080 +ENTRYPOINT ["/ushort"] +CMD ["/app/config.toml"] diff --git a/README.md b/README.md index ebfa417..62df511 100644 --- a/README.md +++ b/README.md @@ -1,321 +1,211 @@ -# URL Shortener +# ushort -A minimal URL shortener written in pure Python 3.8+ (zero third-party dependencies) backed by SQLite. +`ushort` is a compact URL shortener written in Rust and backed by SQLite. It is +a behavior-compatible replacement for the original stdlib-only Python service: +the routes, base-path handling, response shapes, database schema, retention, +API-key checks, throttling, redirects, and frontend are preserved. + +The frontend under `static/` is embedded into the executable at compile time. +The final Linux image contains only one statically linked executable and runs +without Python, a shell, a package manager, or external static files. ## Quick start -### Run locally - ```bash -python urlshort.py config.json +cp config.example.toml config.toml +# Edit base_url and api_key, then: +docker compose up --build -d ``` -### Run with Docker Compose +The service listens on `127.0.0.1:18082` on the host. Its SQLite database is +stored at `./data/urlshort.db` and survives container replacement. -Edit `config.json` first (especially `api_key` and `base_url`), then: +The container runs as UID/GID `1001:1001` by default. Override +`USHORT_UID`/`USHORT_GID` if needed, and ensure `./data` is writable by that +identity. + +## Configuration + +TOML is the preferred format; see `config.example.toml`. Existing JSON files +remain supported, including the old `short_length` alias for +`min_short_length`. ```bash -docker compose up --build +ushort config.toml +ushort legacy-config.json ``` -The SQLite database is stored in `./data/urlshort.db` on the host — it survives container restarts. +| Key | Required | Default | Description | +|---|---:|---:|---| +| `base_url` | yes | — | Public URL. Its path becomes the routing prefix. | +| `api_key` | yes | — | Secret used by list and delete operations. | +| `host` | no | `0.0.0.0` | Bind address. | +| `port` | no | `8080` | Bind port. | +| `db_path` | no | `data/urlshort.db` | SQLite database path. | +| `retention_days` | no | `0` | Default lifetime for new URLs; zero never expires. | +| `min_short_length` | no | `6` | Initial generated code length. | +| `max_short_length` | no | `32` | Maximum generated and accepted code length. | +| `max_url_length` | no | `2048` | Maximum original URL length. | +| `max_retention_days` | no | `3650` | Maximum per-URL retention value. | +| `rate_limit_requests` | no | `60` | Requests allowed per client/window. | +| `rate_limit_window` | no | `60` | Sliding-window length in seconds. | +| `production` | no | `false` | When true, disable embedded frontend routes for compatibility with deployments that serve static files separately. | ---- +For example, `base_url = "https://example.com/go/links"` limits routing to +`/go/links` and produces URLs such as +`https://example.com/go/links/aB3xYz`. Requests outside that prefix return 404. -## Configuration (`config.json`) +Real API keys belong only in the deployment's ignored `config.toml`, never in +the repository or container image. -| Key | Required | Default | Description | -|----------------------|----------|----------------------|-------------------------------------------------------------------| -| `base_url` | ✅ | — | Public base URL. The path component (e.g. `/s` in `http://example.com/s`) is automatically used as the server's routing prefix. | -| `api_key` | ✅ | — | Secret key to protect write/read operations | -| `host` | | `"0.0.0.0"` | Bind address | -| `port` | | `8080` | Bind port | -| `db_path` | | `"data/urlshort.db"` | Path to the SQLite database file | -| `retention_days` | | `0` | Default retention period in days for new URLs. `0` means never expire. | -| `min_short_length` | | `6` | Minimum character length for generated short codes | -| `max_short_length` | | `32` | Maximum character length for generated short codes | -| `max_url_length` | | `2048` | Maximum allowed length for original URLs | -| `max_retention_days` | | `3650` | Maximum allowed retention_days value per URL | -| `rate_limit_requests`| | `60` | Max requests per IP per rate-limit window | -| `rate_limit_window` | | `60` | Rate-limit window in seconds | +## API -> **Backward compat:** If an old config contains `short_length`, it is automatically used as `min_short_length`. +All paths below are relative to the path in `base_url`. Trailing slashes are +accepted. Every response includes the legacy CORS and security headers. ---- +### Health and frontend -## Deploying behind a sub-path (e.g. `http://example.com/s`) +- `GET /` serves the byte-identical embedded frontend when `production=false`. +- `GET /api/health` returns HTTP 200: -Just set `base_url` to include the desired path prefix — the server derives its routing prefix automatically from it: +```json +{"status":"ok","service":"url-shortener"} +``` + +When the configured base path is non-empty, a GET of the bare path (for +example `/s`) redirects to `/s/`. + +### Create a short URL + +`POST /api/shorten` and `GET /api/shorten` are both supported and do not +require an API key. Fields may be supplied as query parameters or in a JSON +body; query parameters take precedence. + +| Field | Required | Description | +|---|---:|---| +| `url` | yes | Absolute `http://` or `https://` URL. | +| `retention_days` | no | Per-URL lifetime, or zero to keep forever. | + +Success is HTTP 201 with only the short URL as UTF-8 plain text: + +```text +https://example.com/s/aB3xYz +``` + +Codes begin at `min_short_length`. After ten collisions at one length the +service tries the next length, through `max_short_length`. + +### List URLs + +`GET /api/urls?api_key=` returns all rows newest first. A missing or +invalid key returns HTTP 403 with an empty body. ```json { - "base_url": "http://example.com/s", - ... -} -``` - -The path component `/s` is extracted at startup. The server will only respond to requests whose path starts with `/s`; everything else returns 404. - -| `base_url` | Derived routing prefix | Short URL example | -|---|---|---| -| `http://example.com` | *(none — root)* | `http://example.com/aB3xYz` | -| `http://example.com/s` | `/s` | `http://example.com/s/aB3xYz` | -| `http://example.com/go/links` | `/go/links` | `http://example.com/go/links/aB3xYz` | - -### With Docker Compose - -```bash -# Set base_url in config.json, then: -docker compose up --build -# Service is now available at http://localhost:8080/s/ -``` - -### With an existing nginx vhost - -`nginx.conf` contains **location blocks only** — drop them into an existing `server { }` block. -The app handles the base_path prefix internally; nginx proxies API/redirect requests and serves frontend static files. - -``` -Browser ──► nginx /s/ ──► static/index.html -Browser ──► nginx /s/static/… ──► static files (CSS/JS) -Browser ──► nginx /s/api/… ──► urlshort :8080 (proxy) -Browser ──► nginx /s/ ──► urlshort :8080 (proxy → 302) -``` - -To change the prefix, update `base_url` in `config.json` **and** the `location /s` blocks in `nginx.conf`. - ---- - -## Authentication - -All endpoints marked with 🔒 require the API key. -Pass it as a **query parameter** or in the **JSON request body**: - -``` -?api_key= -# or in JSON body -{"api_key": "", ...} -``` - ---- - -## Retention - -URLs can have a retention period (`retention_days`). When set to a positive integer, the URL will be automatically deleted after that many days. If `0` or not set, the URL never expires. - -- The **default** retention is set in `config.json` (`retention_days` key, default `0`). -- Each URL can override the default at creation time via the `retention_days` field. -- Expired URLs are cleaned up on startup and lazily on each incoming request. - ---- - -## API Reference - -### `GET /` -Serves the frontend page (if `static/index.html` exists), otherwise returns health check JSON. - -### `GET /api/health` -Health check endpoint. - -**Response `200`** -```json -{ "status": "ok", "service": "url-shortener" } -``` - ---- - -### `POST / GET /api/shorten` -Create a new short URL (supports both `POST` and `GET`). No API key required. - -Fields can be passed as **query parameters** (URL-encoded) or in a **JSON request body**. -Query parameters take precedence over body fields. - -| Field | Required | Description | -|------------------|----------|-----------------------------------------------------------| -| `url` | ✅ | The URL to shorten (must start with `http://` or `https://`) | -| `retention_days` | | Override the default retention period for this URL | - -Short code length is determined automatically: the server starts at `min_short_length` and -progressively tries longer codes on collision, up to `max_short_length`. - -**Response `201`** — plain text containing only the short URL: -``` -http://localhost:8080/s/aB3xYz -``` - ---- - -### `GET /api/urls` 🔒 -List all short URLs, newest first. - -**Response `200`** -```json -{ - "count": 2, + "count": 1, "urls": [ { "short_code": "aB3xYz", - "short_url": "http://localhost:8080/s/aB3xYz", - "original_url": "https://example.com", + "short_url": "https://example.com/s/aB3xYz", + "original_url": "https://example.org", "created_at": 1710000000, "visit_count": 5, - "retention_days": 0 + "retention_days": 30 } ] } ``` ---- +### Metadata and lookup -### `GET /api/urls/` -Get metadata for a single short code. No API key required. +- `GET /api/urls/` returns one metadata object without authentication. +- `GET /api/lookup?url=` returns the newest matching metadata + object without authentication. +- A missing record returns HTTP 404 with an empty body. -**Response `200`** -```json -{ - "short_code": "aB3xYz", - "short_url": "http://localhost:8080/s/aB3xYz", - "original_url": "https://example.com", - "created_at": 1710000000, - "visit_count": 5, - "retention_days": 0 -} +### Delete + +`DELETE /api/urls/?api_key=` returns: + +- HTTP 204 with an empty body on success; +- HTTP 404 with an empty body when the code does not exist; or +- HTTP 403 with an empty body when authentication fails. + +### Redirect + +`GET /` atomically increments `visit_count` and responds with HTTP 302 to +the stored original URL. + +## Data compatibility + +The existing database can be mounted directly; no export/import is required. +The schema remains: + +```sql +CREATE TABLE urls ( + short_code TEXT PRIMARY KEY, + original_url TEXT NOT NULL, + created_at INTEGER NOT NULL, + visit_count INTEGER NOT NULL DEFAULT 0, + retention_days INTEGER NOT NULL DEFAULT 0 +); ``` -**Response `404`** — code not found (empty body). +Older databases missing `retention_days` are upgraded in place with a default +of zero. Expired records are removed on startup and before each GET, POST, or +DELETE request, using the original strict expiry boundary. ---- - -### `GET /api/lookup` -Look up a URL by its **original URL**. No API key required. -Used by the frontend to check if a URL has already been shortened. - -| Parameter | Required | Description | -|-----------|----------|-------------------------------| -| `url` | ✅ | The original URL to look up | - -**Response `200`** — same metadata JSON as `GET /api/urls/`. -**Response `404`** — no short URL exists for this original URL. - ---- - -### `DELETE /api/urls/` 🔒 -Delete a short URL entry. - -**Response `204`** — success (empty body). -**Response `404`** — code not found (empty body). -**Response `403`** — not authorized (empty body). - ---- - -### `GET /` -Redirect to the original URL (HTTP 302). -Increments `visit_count` on each hit. - ---- - -## Example with `curl` - -```bash -# Shorten a URL (POST with JSON body) -curl -X POST http://localhost:8080/s/api/shorten \ - -H "Content-Type: application/json" \ - -d '{"url": "https://github.com"}' - -# Shorten a URL (POST with query parameters) -curl -X POST "http://localhost:8080/s/api/shorten?url=https%3A%2F%2Fgithub.com&retention_days=30" - -# Shorten a URL (GET with query parameters) -curl "http://localhost:8080/s/api/shorten?url=https%3A%2F%2Fgithub.com" - -# Follow the redirect -curl -L http://localhost:8080/s/aB3xYz - -# Get metadata for a short URL (no API key needed) -curl http://localhost:8080/s/api/urls/aB3xYz - -# List all URLs -curl "http://localhost:8080/s/api/urls?api_key=change-this-secret-key" - -# Delete a URL -curl -X DELETE "http://localhost:8080/s/api/urls/aB3xYz?api_key=change-this-secret-key" - -# Lookup by original URL -curl "http://localhost:8080/s/api/lookup?url=https%3A%2F%2Fexample.com" -``` - ---- +Before a production cutover, make a filesystem-consistent backup of the +database (or use SQLite's backup command) and test the copy with the new image. ## Frontend -A clean single-page frontend is included in `static/`. It provides: +The existing `index.html`, JavaScript, CSS, locally hosted fonts, sorting, +pagination, theme selection, URL lookup, copy/delete actions, and admin flow +are unchanged. Compile-time embedding makes those files part of the standalone +artifact; no bind mount or separate web root is needed. -- **URL shortening** — paste a URL and press Enter to create a short URL -- **Existing URL lookup** — as you type a URL, the frontend checks if it already exists and shows its metadata -- **Admin table** — enter a valid API key to see all shortened URLs in a sortable, paginated table -- **Copy & Delete** — per-row copy and delete buttons (delete on hover only) -- **Theme switching** — matches system dark/light preference, with a manual toggle +For the existing `/s` deployment, place the location blocks from `nginx.conf` +in the public vhost. They proxy all traffic to the binary while retaining the +current `no-store` HTML and `no-cache` asset policies. -### Accessing the frontend +## Build and test -- **Via nginx** (production): browse to `/s/` -- **Via Python backend** (local dev): browse to `http://localhost:8080/s/` +The repository intentionally does not require a host Rust installation. -### Fonts +```bash +docker build -t ushort:local . +docker compose up -d +``` -The CSS includes a Google Fonts `@import` that works out of the box. For strict local-serve deployments, replace it with locally-hosted font files (use navpage's `fetch_fonts.py` as a reference). +Run the Rust test suite in an isolated build stage: ---- +```bash +docker build --target tester . +``` -## Security & Hardening +The Dockerfile builds against musl and uses `scratch` for the runtime image. +Docker Buildx can publish both required architectures from the same source: -### CORS -All responses include `Access-Control-Allow-Origin: *` headers. `OPTIONS` preflight requests are handled automatically. +```bash +docker buildx build \ + --platform linux/amd64,linux/arm64 \ + -t sodium/ushort:latest \ + --push . +``` -### Rate limiting -A per-IP sliding-window rate limiter protects all endpoints. Default: 60 requests per 60-second window (configurable via `rate_limit_requests` and `rate_limit_window`). Behind a reverse proxy, the real client IP is extracted from `X-Real-IP` / `X-Forwarded-For` headers. Returns `429 Too Many Requests` when exceeded. - -### Field validation -All limits are configurable via `config.json`. - -| Field | Constraint (defaults) | -|------------------|------------------------------------------------| -| `url` | Max `max_url_length` (2048) chars, valid http(s) | -| `retention_days` | 0–`max_retention_days` (3650) | -| `short_code` | Alphanumeric only, max `max_short_length` (32) chars | - -### Security headers -Every response includes: `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, `X-XSS-Protection: 1; mode=block`. - -### SQL injection prevention -All database queries use parameterized statements (`?` placeholders). - ---- - -## Nginx configuration - -`nginx.conf` contains **location blocks only** — drop them into an existing `server { }` block. Adjust the `alias` paths to match your deployment layout. - -When running without nginx (Docker Compose or local dev), the Python backend serves the frontend directly at the base URL. Set `"production": true` in config to disable backend static serving (nginx handles it). - -### Caching strategy - -The nginx config uses a layered caching strategy designed for deployments behind Cloudflare or other CDNs: - -| Resource | `Cache-Control` | Why | -|---|---|---| -| `/s/` (index.html) | `no-store` | Never cached by CDN or browser. This is the HTML entry point (~2KB) that contains `?v=` cache-buster query strings for CSS/JS. Must always be fresh so that version bumps take effect immediately. | -| `/s/static/*.css?v=…` | `no-cache` | Cached but revalidated on each request. The `?v=` query string acts as a cache key — Cloudflare and browsers treat each version as a distinct resource. Bump the `?v=` value in `index.html` whenever CSS/JS files change. | -| `/s/static/*.js?v=…` | `no-cache` | Same as CSS. | -| `/s/api/*`, `/s/` | (proxied) | Not cached by nginx; backend controls caching via response headers. | - -### Updating static files - -When you modify `style.css` or `app.js`: - -1. Deploy the updated files to the server -2. Bump the `?v=` value in `static/index.html` (e.g. `?v=20260318` → `?v=20260319`) -3. Reload nginx (`nginx -s reload`) - -Since `index.html` has `no-store`, browsers and Cloudflare always fetch the latest version, which in turn references the new `?v=` URLs for CSS/JS — busting all downstream caches automatically. No manual CDN purge is needed. +## Security model +- SQL statements use bound parameters. +- Short codes are restricted to ASCII alphanumerics. +- URL, retention, code, and request-body sizes are bounded. +- API-key comparison is constant-time; protected operations return no body on + authentication failure. +- Per-IP sliding-window throttling applies to API calls and redirects; embedded + frontend files do not consume API quota. The app uses `X-Real-IP`, then the + first `X-Forwarded-For` value, then the TCP peer. Keep the container port + bound to loopback and let the trusted reverse proxy overwrite those headers. +- CORS preflight uses `OPTIONS`; it does not consume rate-limit quota. +- The runtime image is read-only, drops Linux capabilities, and runs as a + numeric non-root user through Compose. +- `SIGINT`/`SIGTERM` cleanly unblock the server for prompt container shutdown. diff --git a/TASK-urlshortener.md b/TASK-urlshortener.md deleted file mode 100644 index 498d9a9..0000000 --- a/TASK-urlshortener.md +++ /dev/null @@ -1,73 +0,0 @@ -# Task synopsis - -I'd like to implement a URL shortener, mocking the de-facto `urlshortener` project, but with the following requirements: - -# Task requirements - -* We should implement it with python 3.8+, with minimum dependencies, in a single .py file. Using no 3rd party libraries/frameworks is the best. -* The configs should be passed as a single JSON file, containing these keys: - * `base_url` - the base URL to use for the shortened URLs - * `short_length` - the length of the shortened URLs - * `api_key` - the API key to use this shortener to create new shortened URLs - * ... other configs, if you think are necessary, ask me. -* The API should be RESTful, and simple enough to be used by a human. -* The shortened data should be stored within a sqlite database. -* Other than the .py file, provide a docker compose file to run it. Remember to keep the data in a mount dir so it won't be lost. - -# Additional requirements -* API authentication should be purely done based on each API's api_key field, as described below. No session management or token management is needed. The API key can be passed as a query parameter or in the json request body, and it should be checked for every API call that requires authentication. -* The base path should be configurable, the same as both in current implementation and in README. -* Implement retention days feature where old URLs are deleted after a certain number of days. If 0 or not set for a shortened URL, it will never be deleted. -* The API design should be like this, remember to deal with trailing slashes: - 1. GET `` - health check, same as current implementation in README. - 2. GET `/api/urls&api_key=` - returns all shortened URLs in json, newest first. The API key is required. The return json body should be same as described in current README. - 3. POST `/api/shorten`, create new short URL. The fields needed can either be passed as query parameters (remember to deal with URL escaping/unescaping) or in the json request body. - The response should be only the short URL in plain text. The fields needed are listed as below: - a) `api_key` (required) - the API key is required. - b) `url` (required) - the URL to shorten - c) `short_length` (optional) - the length of the shortened URLs, default to config value. - d) `retention_days` (optional) - the number of days to keep current shortened URLs, default to config value. - - 4. GET `/api/urls/` - no API key required, returns metadata json for a shortened URL, includes: - ```json - { - "short_code": "aB3xYz", - "short_url": "/aB3xYz", - "original_url": "https://example.com", - "created_at": 1710000000, - "visit_count": 5 - } - ``` - 5. DELETE `/api/urls/&api_key=` - deletes a shortened URL. API key is required. - a) Returns 204 on success without body, - b) 404 if not found, without body, - c) 403 if not authorized, without body. - 6. GET `/` - redirects to the original URL (302). Increments the visit count on each hit. - -# Simple Frontend -* Implement a clean and simple frontend, follow the same design and style requirements for the single page HTML located in the `navpage` folder, as described in its TASK md file. -* Add an optional one-line API key input field to top right corner, so that admin can access to the restricted APIs. -* There is a long search input bar on top center of the page, where user can input a URL to shorten once enter is hit. if shortening is successful, show all its metadata below in a table at center of the page below the search bar; -* If the input URL is existing (even without hitting enter), also show all its metadata in the table. -* If the input URL is malformed on hitting enter, display nothing below the search bar. -* If the API key provided is not valid, show nothing and continue using the page as non admin. -* Once the provided API key is valid, list the existing shortened URLs in a table below the search bar (if the search bar is empty), with the following columns, ordered by created_at descending: - 1. shortened URL - 2. original URL - 3. visit count - 4. created at - 5. retention days -* For this admin table, each row should have a delete button on end, only displayed on mouse hover, which will call the delete API to delete the shortened URL. -* Each row should have a copy button on end, copying the shortened URL to clipboard. -* The table should be sortable by any column. -* The table should be paginated, with 20 rows per page by default, controlled by a dropdown of `20, 50, 100, 200, 500, all`, and the pagination controls should be displayed at the bottom of the table. -* The search bar should also function as the same as for admin (hide the listing table once start typing) - display metadata for existing URLs, create new one for valid URL on hitting enter. -* all static resources should be put under the `urlshort/static` folder. -* the strict "local-serve" requirement is the same as `navpage`, reuse the script in `navpage` if possible. -* Once the frontend implementation is done, rewrite the nginx config to serve it correctly. If possible, only write the locations config, for that I'm planning to deploy both frontend and backend in an existing vhost. Write the path mapping carefully to avoid possible conflicts with the existing vhost. - -# Extra requirements on backend -* Also, implement simple but proper CORS to allow seamless redirection to the target URL. -* In hindsight, the API backend should at least implement simple rate limiting/throttling, by IP address. -* Field validation is also a must, checking and limiting ALL fields to reasonable values to prevent hacking, especially SQL injections and buffer overflows. -* If needed, implement other necessary guard features on the backend to prevent XSS, CSRF, and other common web attacks. diff --git a/config.example.toml b/config.example.toml new file mode 100644 index 0000000..7c85ffc --- /dev/null +++ b/config.example.toml @@ -0,0 +1,22 @@ +# Public URL, including the optional routing prefix. +base_url = "http://localhost:8080/s" + +# Replace this in the deployment-only config.toml. Do not commit real secrets. +api_key = "change-this-secret-key" + +host = "0.0.0.0" +port = 8080 +db_path = "data/urlshort.db" + +# Zero keeps new URLs forever by default. +retention_days = 0 +min_short_length = 6 +max_short_length = 32 +max_url_length = 2048 +max_retention_days = 3650 +rate_limit_requests = 60 +rate_limit_window = 60 + +# Keep false when the embedded frontend should be served by ushort. +# Set true only when a separate web server serves the frontend files. +production = false diff --git a/config.json b/config.json deleted file mode 100644 index e2c897c..0000000 --- a/config.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "base_url": "https://xcel.me/s", - "api_key": "change-this-secret-key", - "host": "0.0.0.0", - "port": 8080, - "db_path": "data/urlshort.db", - "retention_days": 0, - "min_short_length": 6, - "max_short_length": 32, - "max_url_length": 131072, - "max_retention_days": 3650, - "rate_limit_requests": 10, - "rate_limit_window": 60, - "production": true -} - diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml new file mode 100644 index 0000000..f107e5b --- /dev/null +++ b/deploy/docker-compose.yml @@ -0,0 +1,17 @@ +services: + ushort: + image: sodium/ushort:0.1.0 + container_name: ushort + command: ["/app/config.toml"] + ports: + - "127.0.0.1:18082:8080" + volumes: + - ./config.toml:/app/config.toml:ro + - ./data:/app/data + user: "1001:1001" + read_only: true + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + restart: unless-stopped diff --git a/docker-compose.yml b/docker-compose.yml index e2ca5f0..9ea4029 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,14 +1,19 @@ services: - urlshort: - image: python:3.11-slim - working_dir: /app - command: ["python", "-u", "urlshort.py", "config.json"] + ushort: + image: sodium/ushort:latest + build: + context: . + command: ["/app/config.toml"] ports: - "127.0.0.1:18082:8080" volumes: - - ./urlshort.py:/app/urlshort.py:ro - - ./config.json:/app/config.json:ro - - ./static:/app/static:ro + - "${USHORT_CONFIG:-./config.toml}:/app/config.toml:ro" - ./data:/app/data + user: "${USHORT_UID:-1001}:${USHORT_GID:-1001}" + read_only: true + security_opt: + - no-new-privileges:true + cap_drop: + - ALL restart: unless-stopped diff --git a/nginx.conf b/nginx.conf index 50dc6c2..a348c42 100644 --- a/nginx.conf +++ b/nginx.conf @@ -1,43 +1,40 @@ -# =========================================================================== # URL Shortener — nginx location blocks -# Drop these into an existing server { } block. -# -# Assumptions: -# - The Python backend is reachable at http://urlshort:8080 -# (docker service name; swap for 127.0.0.1:8080 or upstream as needed) -# - Static frontend files live at /app/static/ -# (adjust the alias paths to match your deployment layout) -# -# Path mapping (base_url = http:///s): -# /s/ -> frontend index.html -# /s/static/... -> frontend CSS / JS -# /s/api/... -> Python backend (proxy) -# /s/ -> Python backend (redirect, proxy) -# =========================================================================== +# Drop these into the existing server {} block. The Rust binary serves both +# the API and its embedded, byte-identical frontend on 127.0.0.1:18082. -# --- Redirect bare /s to /s/ for clean UX --- location = /s { return 301 /s/; } -# --- Serve frontend index.html at /s/ --- -# no-store prevents Cloudflare and browsers from caching the HTML entry point, -# so updated ?v= cache-busters on CSS/JS are always picked up immediately. +# Preserve the existing no-store policy for the HTML entry point. location = /s/ { - root /root/repo/urlshortener/static; - try_files /index.html =404; - add_header Cache-Control "no-store"; + proxy_pass http://127.0.0.1:18082; + proxy_http_version 1.1; + + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + proxy_hide_header Cache-Control; + add_header Cache-Control "no-store"; } -# --- Serve frontend static assets (CSS, JS, etc.) --- -# ^~ ensures this takes priority over the general /s/ proxy below. +# Preserve the existing revalidation policy for CSS, JavaScript, and fonts. location ^~ /s/static/ { - alias /root/repo/urlshortener/static/; - add_header Cache-Control "no-cache"; + proxy_pass http://127.0.0.1:18082; + proxy_http_version 1.1; + + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + proxy_hide_header Cache-Control; + add_header Cache-Control "no-cache"; } -# --- Proxy everything else under /s/ to the Python backend --- -# Covers: /s/api/shorten, /s/api/urls, /s/api/lookup, /s/ redirects +# API calls and short-code redirects. location /s/ { proxy_pass http://127.0.0.1:18082; proxy_http_version 1.1; diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..7c93e5c --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,1452 @@ +use include_dir::{include_dir, Dir}; +use rand::RngExt; +use rusqlite::{params, Connection, OptionalExtension}; +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; +use std::collections::{HashMap, VecDeque}; +use std::fs; +use std::io; +use std::path::{Component, Path, PathBuf}; +use std::sync::Mutex; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; +use url::{form_urlencoded, Url}; + +static STATIC_DIR: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/static"); +const CODE_CHARS: &[u8] = b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + +#[derive(Clone, Debug, Deserialize)] +#[serde(default)] +struct RawConfig { + base_url: Option, + api_key: Option, + host: String, + port: u16, + db_path: PathBuf, + retention_days: i64, + short_length: Option, + min_short_length: Option, + max_short_length: usize, + max_url_length: usize, + max_retention_days: i64, + rate_limit_requests: usize, + rate_limit_window: u64, + production: bool, +} + +impl Default for RawConfig { + fn default() -> Self { + Self { + base_url: None, + api_key: None, + host: "0.0.0.0".into(), + port: 8080, + db_path: PathBuf::from("data/urlshort.db"), + retention_days: 0, + short_length: None, + min_short_length: None, + max_short_length: 32, + max_url_length: 2048, + max_retention_days: 3650, + rate_limit_requests: 60, + rate_limit_window: 60, + production: false, + } + } +} + +#[derive(Clone, Debug)] +pub struct Config { + pub base_url: String, + pub api_key: String, + pub host: String, + pub port: u16, + pub db_path: PathBuf, + pub retention_days: i64, + pub min_short_length: usize, + pub max_short_length: usize, + pub max_url_length: usize, + pub max_retention_days: i64, + pub rate_limit_requests: usize, + pub rate_limit_window: u64, + pub production: bool, + pub base_path: String, +} + +impl Config { + pub fn load(path: impl AsRef) -> Result { + let path = path.as_ref(); + let text = fs::read_to_string(path) + .map_err(|error| format!("failed to read config {}: {error}", path.display()))?; + let extension = path.extension().and_then(|value| value.to_str()); + let raw = match extension { + Some("json") => serde_json::from_str(&text) + .map_err(|error| format!("invalid JSON config: {error}"))?, + Some("toml") => { + toml::from_str(&text).map_err(|error| format!("invalid TOML config: {error}"))? + } + _ => toml::from_str(&text).or_else(|toml_error| { + serde_json::from_str(&text).map_err(|json_error| { + format!("config is neither valid TOML ({toml_error}) nor JSON ({json_error})") + }) + })?, + }; + Self::from_raw(raw) + } + + fn from_raw(raw: RawConfig) -> Result { + let base_url = raw + .base_url + .ok_or_else(|| "missing required config key: 'base_url'".to_string())?; + let api_key = raw + .api_key + .ok_or_else(|| "missing required config key: 'api_key'".to_string())?; + let parsed_base = Url::parse(&base_url) + .map_err(|error| format!("invalid base_url '{base_url}': {error}"))?; + if !matches!(parsed_base.scheme(), "http" | "https") || parsed_base.host().is_none() { + return Err("base_url must be an absolute http:// or https:// URL".into()); + } + + let min_short_length = raw.min_short_length.or(raw.short_length).unwrap_or(6); + if min_short_length == 0 { + return Err("min_short_length must be at least 1".into()); + } + if raw.max_short_length < min_short_length { + return Err( + "max_short_length must be greater than or equal to min_short_length".into(), + ); + } + if raw.max_url_length == 0 { + return Err("max_url_length must be at least 1".into()); + } + if raw.retention_days < 0 || raw.retention_days > raw.max_retention_days { + return Err("retention_days must be within 0..=max_retention_days".into()); + } + if raw.max_retention_days < 0 { + return Err("max_retention_days cannot be negative".into()); + } + if raw.rate_limit_requests == 0 || raw.rate_limit_window == 0 { + return Err("rate limit values must be greater than zero".into()); + } + if raw.db_path.as_os_str().is_empty() { + return Err("db_path cannot be empty".into()); + } + + let trimmed_path = parsed_base.path().trim_matches('/'); + let base_path = if trimmed_path.is_empty() { + String::new() + } else { + format!("/{trimmed_path}") + }; + + Ok(Self { + base_url, + api_key, + host: raw.host, + port: raw.port, + db_path: raw.db_path, + retention_days: raw.retention_days, + min_short_length, + max_short_length: raw.max_short_length, + max_url_length: raw.max_url_length, + max_retention_days: raw.max_retention_days, + rate_limit_requests: raw.rate_limit_requests, + rate_limit_window: raw.rate_limit_window, + production: raw.production, + base_path, + }) + } +} + +#[derive(Clone, Debug)] +pub struct RequestData { + pub method: String, + pub target: String, + pub headers: Vec<(String, String)>, + pub body: Vec, + pub remote_ip: String, +} + +impl RequestData { + pub fn new(method: impl Into, target: impl Into) -> Self { + Self { + method: method.into(), + target: target.into(), + headers: Vec::new(), + body: Vec::new(), + remote_ip: "127.0.0.1".into(), + } + } +} + +#[derive(Clone, Debug)] +pub struct ResponseData { + pub status: u16, + pub headers: Vec<(String, String)>, + pub body: Vec, +} + +impl ResponseData { + fn new(status: u16, body: Vec, content_type: Option<&str>) -> Self { + let mut headers = Vec::with_capacity(9); + if let Some(value) = content_type { + headers.push(("Content-Type".into(), value.into())); + } + headers.extend(security_headers()); + Self { + status, + headers, + body, + } + } + + fn empty(status: u16) -> Self { + Self::new(status, Vec::new(), None) + } + + fn json(status: u16, value: &T) -> Self { + let mut body = Vec::new(); + let mut serializer = serde_json::Serializer::with_formatter(&mut body, PythonJsonFormatter); + if value.serialize(&mut serializer).is_err() { + body = b"{}".to_vec(); + } + Self::new(status, body, Some("application/json; charset=utf-8")) + } + + fn error(status: u16, message: &str) -> Self { + Self::json(status, &ErrorResponse { error: message }) + } + + fn plain(status: u16, value: String) -> Self { + Self::new( + status, + value.into_bytes(), + Some("text/plain; charset=utf-8"), + ) + } + + fn redirect(location: String) -> Self { + let mut response = Self::empty(302); + response.headers.insert(0, ("Location".into(), location)); + response + } + + fn with_header(mut self, name: &str, value: &str) -> Self { + self.headers.push((name.into(), value.into())); + self + } + + pub fn payload_too_large() -> Self { + Self::error(413, "Request body too large") + } +} + +struct PythonJsonFormatter; + +impl serde_json::ser::Formatter for PythonJsonFormatter { + fn begin_array_value(&mut self, writer: &mut W, first: bool) -> io::Result<()> + where + W: ?Sized + io::Write, + { + if first { + Ok(()) + } else { + writer.write_all(b", ") + } + } + + fn begin_object_key(&mut self, writer: &mut W, first: bool) -> io::Result<()> + where + W: ?Sized + io::Write, + { + if first { + Ok(()) + } else { + writer.write_all(b", ") + } + } + + fn begin_object_value(&mut self, writer: &mut W) -> io::Result<()> + where + W: ?Sized + io::Write, + { + writer.write_all(b": ") + } +} + +#[derive(Serialize)] +struct ErrorResponse<'a> { + error: &'a str, +} + +fn security_headers() -> Vec<(String, String)> { + [ + ("Access-Control-Allow-Origin", "*"), + ("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS"), + ("Access-Control-Allow-Headers", "Content-Type"), + ("Access-Control-Max-Age", "86400"), + ("X-Content-Type-Options", "nosniff"), + ("X-Frame-Options", "DENY"), + ("X-XSS-Protection", "1; mode=block"), + ] + .into_iter() + .map(|(name, value)| (name.into(), value.into())) + .collect() +} + +#[derive(Debug, Serialize)] +struct UrlRecord { + short_code: String, + short_url: String, + original_url: String, + created_at: i64, + visit_count: i64, + retention_days: i64, +} + +#[derive(Serialize)] +struct UrlListResponse { + count: usize, + urls: Vec, +} + +#[derive(Serialize)] +struct HealthResponse { + status: &'static str, + service: &'static str, +} + +struct RateLimiter { + max_requests: usize, + window: Duration, + hits: Mutex>>, +} + +impl RateLimiter { + fn new(max_requests: usize, window_seconds: u64) -> Self { + Self { + max_requests, + window: Duration::from_secs(window_seconds), + hits: Mutex::new(HashMap::new()), + } + } + + fn is_allowed(&self, ip: &str) -> bool { + let now = Instant::now(); + let mut all_hits = self.hits.lock().unwrap_or_else(|error| error.into_inner()); + let hits = all_hits.entry(ip.to_string()).or_default(); + while hits + .front() + .is_some_and(|hit| now.duration_since(*hit) >= self.window) + { + hits.pop_front(); + } + if hits.len() >= self.max_requests { + return false; + } + hits.push_back(now); + true + } +} + +pub struct App { + pub config: Config, + rate_limiter: RateLimiter, +} + +impl App { + pub fn new(config: Config) -> Result { + init_db(&config.db_path)?; + cleanup_expired(&config.db_path, unix_timestamp())?; + let rate_limiter = RateLimiter::new(config.rate_limit_requests, config.rate_limit_window); + Ok(Self { + config, + rate_limiter, + }) + } + + pub fn handle(&self, request: RequestData) -> ResponseData { + if request.method == "OPTIONS" { + return ResponseData::empty(200).with_header("Allow", "GET, POST, DELETE, OPTIONS"); + } + + let raw_path = target_path(&request.target); + let bare_base = !self.config.base_path.is_empty() && raw_path == self.config.base_path; + if matches!(request.method.as_str(), "GET" | "HEAD") + && !self.config.production + && !bare_base + { + if let Some(path) = local_path(raw_path, &self.config.base_path) { + match path.as_str() { + "/" | "/static" => return self.serve_static("index.html"), + _ if path.starts_with("/static/") => { + return self.serve_static(&path["/static/".len()..]) + } + _ => {} + } + } + } + + if !matches!(request.method.as_str(), "GET" | "POST" | "DELETE") { + return ResponseData::error(501, "Unsupported method"); + } + + if cleanup_expired(&self.config.db_path, unix_timestamp()).is_err() { + return ResponseData::error(500, "Internal server error"); + } + + let client_ip = client_ip(&request); + if !self.rate_limiter.is_allowed(&client_ip) { + return ResponseData::error(429, "Too many requests"); + } + + if request.method == "GET" + && !self.config.base_path.is_empty() + && raw_path == self.config.base_path + { + return ResponseData::redirect(format!("{}/", self.config.base_path)); + } + + let Some(path) = local_path(raw_path, &self.config.base_path) else { + return ResponseData::empty(404); + }; + + match request.method.as_str() { + "GET" => self.handle_get(&path, &request), + "POST" if path == "/api/shorten" => self.handle_shorten(&request), + "POST" => ResponseData::empty(404), + "DELETE" if path.starts_with("/api/urls/") => { + self.handle_delete_url(&path["/api/urls/".len()..], &request) + } + "DELETE" => ResponseData::empty(404), + _ => ResponseData::error(501, "Unsupported method"), + } + } + + pub fn max_request_body_bytes(&self) -> usize { + self.config + .max_url_length + .saturating_mul(4) + .saturating_add(64 * 1024) + } + + fn handle_get(&self, path: &str, request: &RequestData) -> ResponseData { + match path { + "/" => { + if self.config.production { + health_response() + } else { + self.serve_static("index.html") + } + } + "/api/health" => health_response(), + "/api/shorten" => self.handle_shorten(request), + "/api/urls" => self.handle_list_urls(request), + "/api/lookup" => self.handle_lookup(request), + "/static" => { + if self.config.production { + ResponseData::empty(404) + } else { + self.serve_static("index.html") + } + } + _ if path.starts_with("/api/urls/") => self.handle_get_url(&path["/api/urls/".len()..]), + _ if path.starts_with("/static/") => { + if self.config.production { + ResponseData::empty(404) + } else { + self.serve_static(&path["/static/".len()..]) + } + } + _ => self.handle_redirect(path.trim_start_matches('/')), + } + } + + fn handle_shorten(&self, request: &RequestData) -> ResponseData { + let query = parse_query(&request.target); + let body = if request.body.is_empty() { + Map::new() + } else { + match serde_json::from_slice::(&request.body) { + Ok(Value::Object(body)) => body, + _ => return ResponseData::error(400, "Invalid JSON body"), + } + }; + + let original_url = first_query_value(&query, "url") + .map(str::to_string) + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| json_string(body.get("url")).trim().to_string()); + if original_url.is_empty() { + return ResponseData::error(400, "Missing required field: url"); + } + if !is_valid_url(&original_url) { + return ResponseData::error(400, "Invalid URL — must start with http:// or https://"); + } + if original_url.chars().count() > self.config.max_url_length { + return ResponseData::error( + 400, + &format!( + "URL too long (max {} characters)", + self.config.max_url_length + ), + ); + } + + let retention_days = if let Some(value) = first_query_value(&query, "retention_days") { + match python_int(value) { + Some(value) => value, + None => return ResponseData::error(400, "Invalid retention_days"), + } + } else if let Some(value) = body.get("retention_days") { + match python_int_value(value) { + Some(value) => value, + None => return ResponseData::error(400, "Invalid retention_days"), + } + } else { + self.config.retention_days + }; + if !(0..=self.config.max_retention_days).contains(&retention_days) { + return ResponseData::error( + 400, + &format!( + "retention_days must be 0–{}", + self.config.max_retention_days + ), + ); + } + + let mut connection = match db_connect(&self.config.db_path) { + Ok(connection) => connection, + Err(_) => return ResponseData::error(500, "Internal server error"), + }; + let transaction = match connection.transaction() { + Ok(transaction) => transaction, + Err(_) => return ResponseData::error(500, "Internal server error"), + }; + let short_code = match unique_code( + &transaction, + self.config.min_short_length, + self.config.max_short_length, + ) { + Ok(Some(code)) => code, + Ok(None) => { + return ResponseData::error( + 500, + "Could not generate a unique short code — try again", + ) + } + Err(_) => return ResponseData::error(500, "Internal server error"), + }; + if transaction + .execute( + "INSERT INTO urls (short_code, original_url, created_at, retention_days) \ + VALUES (?1, ?2, ?3, ?4)", + params![short_code, original_url, unix_timestamp(), retention_days], + ) + .is_err() + || transaction.commit().is_err() + { + return ResponseData::error(500, "Internal server error"); + } + + ResponseData::plain( + 201, + format!( + "{}/{}", + self.config.base_url.trim_end_matches('/'), + short_code + ), + ) + } + + fn handle_list_urls(&self, request: &RequestData) -> ResponseData { + if !self.check_api_key(request) { + return ResponseData::empty(403); + } + let connection = match db_connect(&self.config.db_path) { + Ok(connection) => connection, + Err(_) => return ResponseData::error(500, "Internal server error"), + }; + let mut statement = match connection.prepare("SELECT * FROM urls ORDER BY created_at DESC") + { + Ok(statement) => statement, + Err(_) => return ResponseData::error(500, "Internal server error"), + }; + let rows = match statement.query_map([], |row| self.row_to_record(row)) { + Ok(rows) => rows, + Err(_) => return ResponseData::error(500, "Internal server error"), + }; + let records = match rows.collect::>>() { + Ok(records) => records, + Err(_) => return ResponseData::error(500, "Internal server error"), + }; + ResponseData::json( + 200, + &UrlListResponse { + count: records.len(), + urls: records, + }, + ) + } + + fn handle_get_url(&self, code: &str) -> ResponseData { + if !is_valid_code(code, self.config.max_short_length) { + return ResponseData::empty(404); + } + match self.find_by_code(code) { + Ok(Some(record)) => ResponseData::json(200, &record), + Ok(None) => ResponseData::empty(404), + Err(_) => ResponseData::error(500, "Internal server error"), + } + } + + fn handle_delete_url(&self, code: &str, request: &RequestData) -> ResponseData { + if !self.check_api_key(request) { + return ResponseData::empty(403); + } + if !is_valid_code(code, self.config.max_short_length) { + return ResponseData::empty(404); + } + let connection = match db_connect(&self.config.db_path) { + Ok(connection) => connection, + Err(_) => return ResponseData::error(500, "Internal server error"), + }; + match connection.execute("DELETE FROM urls WHERE short_code = ?1", [code]) { + Ok(0) => ResponseData::empty(404), + Ok(_) => ResponseData::empty(204), + Err(_) => ResponseData::error(500, "Internal server error"), + } + } + + fn handle_redirect(&self, code: &str) -> ResponseData { + if !is_valid_code(code, self.config.max_short_length) { + return ResponseData::empty(404); + } + let mut connection = match db_connect(&self.config.db_path) { + Ok(connection) => connection, + Err(_) => return ResponseData::error(500, "Internal server error"), + }; + let transaction = match connection.transaction() { + Ok(transaction) => transaction, + Err(_) => return ResponseData::error(500, "Internal server error"), + }; + let original_url = match transaction + .query_row( + "SELECT original_url FROM urls WHERE short_code = ?1", + [code], + |row| row.get::<_, String>(0), + ) + .optional() + { + Ok(Some(url)) => url, + Ok(None) => return ResponseData::error(404, "Short code not found"), + Err(_) => return ResponseData::error(500, "Internal server error"), + }; + if transaction + .execute( + "UPDATE urls SET visit_count = visit_count + 1 WHERE short_code = ?1", + [code], + ) + .is_err() + || transaction.commit().is_err() + { + return ResponseData::error(500, "Internal server error"); + } + ResponseData::redirect(original_url) + } + + fn handle_lookup(&self, request: &RequestData) -> ResponseData { + let query = parse_query(&request.target); + let url = first_query_value(&query, "url").unwrap_or("").trim(); + if url.is_empty() { + return ResponseData::error(400, "Missing required parameter: url"); + } + if url.chars().count() > self.config.max_url_length { + return ResponseData::error( + 400, + &format!( + "URL too long (max {} characters)", + self.config.max_url_length + ), + ); + } + let connection = match db_connect(&self.config.db_path) { + Ok(connection) => connection, + Err(_) => return ResponseData::error(500, "Internal server error"), + }; + let record = connection + .query_row( + "SELECT * FROM urls WHERE original_url = ?1 ORDER BY created_at DESC LIMIT 1", + [url], + |row| self.row_to_record(row), + ) + .optional(); + match record { + Ok(Some(record)) => ResponseData::json(200, &record), + Ok(None) => ResponseData::empty(404), + Err(_) => ResponseData::error(500, "Internal server error"), + } + } + + fn serve_static(&self, relative_path: &str) -> ResponseData { + let Some(path) = normalize_static_path(relative_path) else { + return ResponseData::empty(403); + }; + let Some(file) = STATIC_DIR.get_file(&path) else { + return ResponseData::empty(404); + }; + ResponseData::new(200, file.contents().to_vec(), Some(mime_type(&path))) + .with_header("Cache-Control", "public, max-age=3600") + } + + fn check_api_key(&self, request: &RequestData) -> bool { + let query = parse_query(&request.target); + first_query_value(&query, "api_key") + .is_some_and(|provided| constant_time_eq(provided, &self.config.api_key)) + } + + fn find_by_code(&self, code: &str) -> rusqlite::Result> { + let connection = + db_connect(&self.config.db_path).map_err(|_| rusqlite::Error::InvalidQuery)?; + connection + .query_row("SELECT * FROM urls WHERE short_code = ?1", [code], |row| { + self.row_to_record(row) + }) + .optional() + } + + fn row_to_record(&self, row: &rusqlite::Row<'_>) -> rusqlite::Result { + let short_code: String = row.get("short_code")?; + Ok(UrlRecord { + short_url: format!( + "{}/{}", + self.config.base_url.trim_end_matches('/'), + short_code + ), + short_code, + original_url: row.get("original_url")?, + created_at: row.get("created_at")?, + visit_count: row.get("visit_count")?, + retention_days: row.get("retention_days")?, + }) + } +} + +fn health_response() -> ResponseData { + ResponseData::json( + 200, + &HealthResponse { + status: "ok", + service: "url-shortener", + }, + ) +} + +fn init_db(path: &Path) -> Result<(), String> { + if let Some(parent) = path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + { + fs::create_dir_all(parent) + .map_err(|error| format!("failed to create database directory: {error}"))?; + } + let connection = db_connect(path)?; + connection + .execute_batch( + "CREATE TABLE IF NOT EXISTS urls ( + short_code TEXT PRIMARY KEY, + original_url TEXT NOT NULL, + created_at INTEGER NOT NULL, + visit_count INTEGER NOT NULL DEFAULT 0, + retention_days INTEGER NOT NULL DEFAULT 0 + );", + ) + .map_err(|error| format!("failed to initialize database: {error}"))?; + let has_retention = { + let mut statement = connection + .prepare("PRAGMA table_info(urls)") + .map_err(|error| format!("failed to inspect database schema: {error}"))?; + let columns = statement + .query_map([], |row| row.get::<_, String>(1)) + .map_err(|error| format!("failed to inspect database schema: {error}"))?; + let columns = columns + .collect::>>() + .map_err(|error| format!("failed to inspect database schema: {error}"))?; + columns.iter().any(|column| column == "retention_days") + }; + if !has_retention { + connection + .execute_batch("ALTER TABLE urls ADD COLUMN retention_days INTEGER NOT NULL DEFAULT 0;") + .map_err(|error| format!("failed to migrate database schema: {error}"))?; + } + Ok(()) +} + +fn db_connect(path: &Path) -> Result { + let connection = Connection::open(path) + .map_err(|error| format!("failed to open database {}: {error}", path.display()))?; + connection + .busy_timeout(Duration::from_secs(5)) + .map_err(|error| format!("failed to configure database: {error}"))?; + Ok(connection) +} + +fn cleanup_expired(path: &Path, now: i64) -> Result<(), String> { + let connection = db_connect(path)?; + connection + .execute( + "DELETE FROM urls WHERE retention_days > 0 \ + AND (created_at + retention_days * 86400) < ?1", + [now], + ) + .map_err(|error| format!("failed to clean expired URLs: {error}"))?; + Ok(()) +} + +fn unique_code( + connection: &Connection, + minimum: usize, + maximum: usize, +) -> rusqlite::Result> { + let mut rng = rand::rng(); + for length in minimum..=maximum { + for _ in 0..10 { + let code: String = (0..length) + .map(|_| CODE_CHARS[rng.random_range(0..CODE_CHARS.len())] as char) + .collect(); + let exists = connection + .query_row("SELECT 1 FROM urls WHERE short_code = ?1", [&code], |_| { + Ok(()) + }) + .optional()? + .is_some(); + if !exists { + return Ok(Some(code)); + } + } + } + Ok(None) +} + +fn target_path(target: &str) -> &str { + target.split_once('?').map_or(target, |(path, _)| path) +} + +fn local_path(raw_path: &str, base_path: &str) -> Option { + if !base_path.is_empty() { + if raw_path == base_path || raw_path == format!("{base_path}/") { + return Some("/".into()); + } + let prefix = format!("{base_path}/"); + if let Some(path) = raw_path.strip_prefix(&prefix) { + let path = format!("/{path}"); + return Some(trim_trailing_slashes(&path)); + } + return None; + } + Some(trim_trailing_slashes(raw_path)) +} + +fn trim_trailing_slashes(path: &str) -> String { + let trimmed = path.trim_end_matches('/'); + if trimmed.is_empty() { + "/".into() + } else { + trimmed.into() + } +} + +fn parse_query(target: &str) -> Vec<(String, String)> { + let Some((_, query)) = target.split_once('?') else { + return Vec::new(); + }; + form_urlencoded::parse(query.as_bytes()) + .filter(|(_, value)| !value.is_empty()) + .map(|(key, value)| (key.into_owned(), value.into_owned())) + .collect() +} + +fn first_query_value<'a>(query: &'a [(String, String)], name: &str) -> Option<&'a str> { + query + .iter() + .find_map(|(key, value)| (key == name).then_some(value.as_str())) +} + +fn json_string(value: Option<&Value>) -> String { + match value { + None => String::new(), + Some(Value::String(value)) => value.clone(), + Some(Value::Null) => "None".into(), + Some(Value::Bool(true)) => "True".into(), + Some(Value::Bool(false)) => "False".into(), + Some(value) => value.to_string(), + } +} + +fn python_int(value: &str) -> Option { + value.trim().parse().ok() +} + +fn python_int_value(value: &Value) -> Option { + match value { + Value::String(value) => python_int(value), + Value::Bool(value) => Some(i64::from(*value)), + Value::Number(value) => value + .as_i64() + .or_else(|| value.as_u64().and_then(|value| value.try_into().ok())) + .or_else(|| { + value.as_f64().and_then(|value| { + (value.is_finite() && value >= i64::MIN as f64 && value <= i64::MAX as f64) + .then_some(value.trunc() as i64) + }) + }), + _ => None, + } +} + +fn is_valid_url(value: &str) -> bool { + Url::parse(value) + .is_ok_and(|url| matches!(url.scheme(), "http" | "https") && url.host().is_some()) +} + +fn is_valid_code(code: &str, maximum: usize) -> bool { + !code.is_empty() + && code.len() <= maximum + && code.bytes().all(|byte| byte.is_ascii_alphanumeric()) +} + +fn normalize_static_path(value: &str) -> Option { + let mut parts = Vec::new(); + for component in Path::new(value).components() { + match component { + Component::Normal(part) => parts.push(part.to_str()?.to_string()), + Component::CurDir => {} + Component::ParentDir => { + parts.pop()?; + } + Component::RootDir | Component::Prefix(_) => return None, + } + } + if parts.is_empty() { + Some("index.html".into()) + } else { + Some(parts.join("/")) + } +} + +fn mime_type(path: &str) -> &'static str { + match Path::new(path).extension().and_then(|value| value.to_str()) { + Some("html") => "text/html", + Some("css") => "text/css", + Some("js") => "application/javascript", + Some("woff2") => "font/woff2", + _ => "application/octet-stream", + } +} + +fn client_ip(request: &RequestData) -> String { + if let Some(value) = header_value(&request.headers, "X-Real-IP") { + let value = value.trim(); + if !value.is_empty() { + return value.into(); + } + } + if let Some(value) = header_value(&request.headers, "X-Forwarded-For") { + if let Some(value) = value + .split(',') + .next() + .map(str::trim) + .filter(|v| !v.is_empty()) + { + return value.into(); + } + } + request.remote_ip.clone() +} + +fn header_value<'a>(headers: &'a [(String, String)], name: &str) -> Option<&'a str> { + headers + .iter() + .find_map(|(key, value)| key.eq_ignore_ascii_case(name).then_some(value.as_str())) +} + +fn constant_time_eq(left: &str, right: &str) -> bool { + let left = left.as_bytes(); + let right = right.as_bytes(); + let mut difference = left.len() ^ right.len(); + for index in 0..left.len().max(right.len()) { + difference |= left.get(index).copied().unwrap_or(0) as usize + ^ right.get(index).copied().unwrap_or(0) as usize; + } + difference == 0 +} + +fn unix_timestamp() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as i64 +} + +#[cfg(test)] +mod tests { + use super::*; + + fn temp_path(label: &str) -> PathBuf { + std::env::temp_dir().join(format!( + "ushort-{label}-{}-{}.db", + std::process::id(), + rand::random::() + )) + } + + fn config(db_path: PathBuf) -> Config { + Config { + base_url: "https://example.test/s".into(), + api_key: "secret".into(), + host: "127.0.0.1".into(), + port: 8080, + db_path, + retention_days: 0, + min_short_length: 6, + max_short_length: 32, + max_url_length: 2048, + max_retention_days: 3650, + rate_limit_requests: 1_000, + rate_limit_window: 60, + production: false, + base_path: "/s".into(), + } + } + + fn json(response: &ResponseData) -> Value { + serde_json::from_slice(&response.body).unwrap() + } + + #[test] + fn path_prefix_and_trailing_slashes_match_legacy_routes() { + assert_eq!(local_path("/s", "/s"), Some("/".into())); + assert_eq!(local_path("/s/", "/s"), Some("/".into())); + assert_eq!(local_path("/s/api/urls///", "/s"), Some("/api/urls".into())); + assert_eq!(local_path("/something", "/s"), None); + assert_eq!(local_path("/api/health/", ""), Some("/api/health".into())); + } + + #[test] + fn existing_database_without_retention_column_is_migrated() { + let path = temp_path("schema"); + let connection = Connection::open(&path).unwrap(); + connection + .execute_batch( + "CREATE TABLE urls ( + short_code TEXT PRIMARY KEY, + original_url TEXT NOT NULL, + created_at INTEGER NOT NULL, + visit_count INTEGER NOT NULL DEFAULT 0 + ); + INSERT INTO urls VALUES ('abc123', 'https://example.com', 1, 2);", + ) + .unwrap(); + drop(connection); + + init_db(&path).unwrap(); + let connection = Connection::open(&path).unwrap(); + let retention: i64 = connection + .query_row( + "SELECT retention_days FROM urls WHERE short_code = 'abc123'", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(retention, 0); + let _ = fs::remove_file(path); + } + + #[test] + fn full_create_lookup_metadata_redirect_list_delete_flow() { + let path = temp_path("flow"); + let app = App::new(config(path.clone())).unwrap(); + + let mut create = RequestData::new("POST", "/s/api/shorten"); + create.body = br#"{"url":"https://example.com/a?b=1","retention_days":30}"#.to_vec(); + let created = app.handle(create); + assert_eq!(created.status, 201); + let short_url = String::from_utf8(created.body).unwrap(); + let code = short_url.rsplit('/').next().unwrap(); + assert_eq!(code.len(), 6); + + let lookup = app.handle(RequestData::new( + "GET", + "/s/api/lookup?url=https%3A%2F%2Fexample.com%2Fa%3Fb%3D1", + )); + assert_eq!(lookup.status, 200); + assert_eq!(json(&lookup)["short_code"], code); + assert_eq!(json(&lookup)["retention_days"], 30); + + let metadata = app.handle(RequestData::new("GET", format!("/s/api/urls/{code}/"))); + assert_eq!(metadata.status, 200); + assert_eq!(json(&metadata)["visit_count"], 0); + + let redirect = app.handle(RequestData::new("GET", format!("/s/{code}"))); + assert_eq!(redirect.status, 302); + assert!(redirect + .headers + .contains(&("Location".into(), "https://example.com/a?b=1".into()))); + + let forbidden = app.handle(RequestData::new("GET", "/s/api/urls")); + assert_eq!(forbidden.status, 403); + assert!(forbidden.body.is_empty()); + + let listing = app.handle(RequestData::new("GET", "/s/api/urls?api_key=secret")); + assert_eq!(listing.status, 200); + assert_eq!(json(&listing)["count"], 1); + assert_eq!(json(&listing)["urls"][0]["visit_count"], 1); + + let deleted = app.handle(RequestData::new( + "DELETE", + format!("/s/api/urls/{code}?api_key=secret"), + )); + assert_eq!(deleted.status, 204); + assert!(deleted.body.is_empty()); + + let missing = app.handle(RequestData::new("GET", format!("/s/api/urls/{code}"))); + assert_eq!(missing.status, 404); + assert!(missing.body.is_empty()); + let _ = fs::remove_file(path); + } + + #[test] + fn query_parameters_override_json_body() { + let path = temp_path("precedence"); + let app = App::new(config(path.clone())).unwrap(); + let mut request = RequestData::new( + "POST", + "/s/api/shorten?url=https%3A%2F%2Fquery.example&retention_days=12", + ); + request.body = br#"{"url":"https://body.example","retention_days":99}"#.to_vec(); + let response = app.handle(request); + assert_eq!(response.status, 201); + let lookup = app.handle(RequestData::new( + "GET", + "/s/api/lookup?url=https%3A%2F%2Fquery.example", + )); + assert_eq!(json(&lookup)["retention_days"], 12); + let absent = app.handle(RequestData::new( + "GET", + "/s/api/lookup?url=https%3A%2F%2Fbody.example", + )); + assert_eq!(absent.status, 404); + let _ = fs::remove_file(path); + } + + #[test] + fn expired_rows_are_removed_lazily_using_strict_boundary() { + let path = temp_path("expiry"); + init_db(&path).unwrap(); + let connection = Connection::open(&path).unwrap(); + connection + .execute( + "INSERT INTO urls VALUES ('expired', 'https://old.example', 100, 0, 1)", + [], + ) + .unwrap(); + connection + .execute( + "INSERT INTO urls VALUES ('boundary', 'https://edge.example', 101, 0, 1)", + [], + ) + .unwrap(); + drop(connection); + cleanup_expired(&path, 86_501).unwrap(); + let connection = Connection::open(&path).unwrap(); + let expired: i64 = connection + .query_row( + "SELECT count(*) FROM urls WHERE short_code='expired'", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(expired, 0); + let boundary: i64 = connection + .query_row( + "SELECT count(*) FROM urls WHERE short_code='boundary'", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(boundary, 1); + drop(connection); + let _ = fs::remove_file(path); + } + + #[test] + fn bare_base_redirects_and_outside_paths_are_not_routed() { + let path = temp_path("prefix"); + let app = App::new(config(path.clone())).unwrap(); + let redirect = app.handle(RequestData::new("GET", "/s")); + assert_eq!(redirect.status, 302); + assert!(redirect + .headers + .contains(&("Location".into(), "/s/".into()))); + let outside = app.handle(RequestData::new("GET", "/api/health")); + assert_eq!(outside.status, 404); + assert!(outside.body.is_empty()); + let _ = fs::remove_file(path); + } + + #[test] + fn get_shorten_and_legacy_error_shapes_are_preserved() { + let path = temp_path("get-shorten"); + let app = App::new(config(path.clone())).unwrap(); + let created = app.handle(RequestData::new( + "GET", + "/s/api/shorten?url=https%3A%2F%2Fexample.org", + )); + assert_eq!(created.status, 201); + let metadata_missing = app.handle(RequestData::new("GET", "/s/api/urls/NotFound")); + assert_eq!(metadata_missing.status, 404); + assert!(metadata_missing.body.is_empty()); + let redirect_missing = app.handle(RequestData::new("GET", "/s/NotFound")); + assert_eq!(redirect_missing.status, 404); + assert_eq!(json(&redirect_missing)["error"], "Short code not found"); + let invalid_code = app.handle(RequestData::new("GET", "/s/not-valid")); + assert_eq!(invalid_code.status, 404); + assert!(invalid_code.body.is_empty()); + let _ = fs::remove_file(path); + } + + #[test] + fn invalid_fields_return_legacy_validation_messages() { + let path = temp_path("validation"); + let app = App::new(config(path.clone())).unwrap(); + let missing = app.handle(RequestData::new("POST", "/s/api/shorten")); + assert_eq!(json(&missing)["error"], "Missing required field: url"); + let mut invalid_json = RequestData::new("POST", "/s/api/shorten"); + invalid_json.body = b"{".to_vec(); + assert_eq!( + json(&app.handle(invalid_json))["error"], + "Invalid JSON body" + ); + let invalid_url = app.handle(RequestData::new( + "GET", + "/s/api/shorten?url=javascript%3Aalert%281%29", + )); + assert_eq!( + json(&invalid_url)["error"], + "Invalid URL — must start with http:// or https://" + ); + let invalid_retention = app.handle(RequestData::new( + "GET", + "/s/api/shorten?url=https%3A%2F%2Fexample.com&retention_days=nope", + )); + assert_eq!(json(&invalid_retention)["error"], "Invalid retention_days"); + let _ = fs::remove_file(path); + } + + #[test] + fn options_bypasses_routing_and_rate_limiting() { + let path = temp_path("options"); + let mut cfg = config(path.clone()); + cfg.rate_limit_requests = 1; + let app = App::new(cfg).unwrap(); + for _ in 0..3 { + let response = app.handle(RequestData::new("OPTIONS", "/outside")); + assert_eq!(response.status, 200); + assert!(response + .headers + .contains(&("Allow".into(), "GET, POST, DELETE, OPTIONS".into()))); + } + assert_eq!( + app.handle(RequestData::new("GET", "/s/api/health")).status, + 200 + ); + assert_eq!( + app.handle(RequestData::new("GET", "/s/api/health")).status, + 429 + ); + let _ = fs::remove_file(path); + } + + #[test] + fn embedded_frontend_does_not_consume_api_rate_limit() { + let path = temp_path("static-rate-limit"); + let mut cfg = config(path.clone()); + cfg.rate_limit_requests = 1; + let app = App::new(cfg).unwrap(); + + for target in [ + "/s/", + "/s/static/app.js", + "/s/static/style.css", + "/s/static/fonts.css", + ] { + assert_eq!(app.handle(RequestData::new("GET", target)).status, 200); + } + + let head = app.handle(RequestData::new("HEAD", "/s/static/app.js")); + assert_eq!(head.status, 200); + assert_eq!(head.body, include_bytes!("../static/app.js")); + assert!(head + .headers + .contains(&("Content-Type".into(), "application/javascript".into()))); + + assert_eq!( + app.handle(RequestData::new("GET", "/s/api/health")).status, + 200 + ); + assert_eq!( + app.handle(RequestData::new("GET", "/s/api/health")).status, + 429 + ); + let _ = fs::remove_file(path); + } + + #[test] + fn proxy_headers_select_the_rate_limit_identity() { + let path = temp_path("proxy-ip"); + let mut cfg = config(path.clone()); + cfg.rate_limit_requests = 1; + let app = App::new(cfg).unwrap(); + let mut first = RequestData::new("GET", "/s/api/health"); + first.headers.push(("X-Real-IP".into(), "192.0.2.1".into())); + assert_eq!(app.handle(first).status, 200); + let mut second = RequestData::new("GET", "/s/api/health"); + second + .headers + .push(("X-Forwarded-For".into(), "192.0.2.2, 10.0.0.1".into())); + assert_eq!(app.handle(second).status, 200); + let _ = fs::remove_file(path); + } + + #[test] + fn deprecated_short_length_config_remains_supported() { + let config = Config::from_raw(RawConfig { + base_url: Some("https://example.test/go/".into()), + api_key: Some("secret".into()), + short_length: Some(9), + ..RawConfig::default() + }) + .unwrap(); + assert_eq!(config.min_short_length, 9); + assert_eq!(config.base_path, "/go"); + } + + #[test] + fn example_toml_and_legacy_json_files_load() { + let example = Config::load(concat!(env!("CARGO_MANIFEST_DIR"), "/config.example.toml")) + .expect("example TOML must remain valid"); + assert_eq!(example.min_short_length, 6); + assert_eq!(example.base_path, "/s"); + + let path = std::env::temp_dir().join(format!( + "ushort-config-{}-{}.json", + std::process::id(), + rand::random::() + )); + fs::write( + &path, + r#"{ + "base_url": "https://example.test/legacy", + "api_key": "secret", + "short_length": 8 + }"#, + ) + .unwrap(); + let legacy = Config::load(&path).expect("legacy JSON must remain supported"); + assert_eq!(legacy.min_short_length, 8); + assert_eq!(legacy.base_path, "/legacy"); + let _ = fs::remove_file(path); + } + + #[test] + fn every_frontend_source_file_is_embedded_byte_for_byte() { + fn verify(directory: &Path, root: &Path, count: &mut usize) { + for entry in fs::read_dir(directory).unwrap() { + let entry = entry.unwrap(); + let path = entry.path(); + if path.is_dir() { + verify(&path, root, count); + continue; + } + let relative = path.strip_prefix(root).unwrap().to_str().unwrap(); + let embedded = STATIC_DIR + .get_file(relative) + .unwrap_or_else(|| panic!("missing embedded asset: {relative}")); + assert_eq!( + embedded.contents(), + fs::read(&path).unwrap(), + "embedded asset differs: {relative}" + ); + *count += 1; + } + } + + let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("static"); + let mut count = 0; + verify(&root, &root, &mut count); + assert!(count > 100, "expected the complete local font set"); + } + + #[test] + fn production_flag_keeps_legacy_backend_static_behavior() { + let path = temp_path("production"); + let mut cfg = config(path.clone()); + cfg.production = true; + let app = App::new(cfg).unwrap(); + let root = app.handle(RequestData::new("GET", "/s/")); + assert_eq!( + root.body, + br#"{"status": "ok", "service": "url-shortener"}"# + ); + let asset = app.handle(RequestData::new("GET", "/s/static/app.js")); + assert_eq!(asset.status, 404); + assert!(asset.body.is_empty()); + let _ = fs::remove_file(path); + } + + #[test] + fn embedded_frontend_is_served_unchanged() { + let path = temp_path("static"); + let app = App::new(config(path.clone())).unwrap(); + let index = app.handle(RequestData::new("GET", "/s/")); + assert_eq!(index.status, 200); + assert_eq!(index.body, include_bytes!("../static/index.html")); + let javascript = app.handle(RequestData::new("GET", "/s/static/app.js")); + assert_eq!(javascript.status, 200); + assert_eq!(javascript.body, include_bytes!("../static/app.js")); + let _ = fs::remove_file(path); + } + + #[test] + fn cors_and_security_headers_are_on_success_and_errors() { + let path = temp_path("headers"); + let app = App::new(config(path.clone())).unwrap(); + for response in [ + app.handle(RequestData::new("GET", "/s/api/health")), + app.handle(RequestData::new("GET", "/outside")), + app.handle(RequestData::new("OPTIONS", "/anything")), + ] { + assert!(response + .headers + .contains(&("Access-Control-Allow-Origin".into(), "*".into()))); + assert!(response + .headers + .contains(&("X-Frame-Options".into(), "DENY".into()))); + } + let _ = fs::remove_file(path); + } + + #[test] + fn json_bytes_match_python_spacing_and_field_order() { + let health = health_response(); + assert_eq!( + health.body, + br#"{"status": "ok", "service": "url-shortener"}"# + ); + let error = ResponseData::error(400, "bad"); + assert_eq!(error.body, br#"{"error": "bad"}"#); + let too_large = ResponseData::payload_too_large(); + assert_eq!(too_large.status, 413); + assert_eq!(too_large.body, br#"{"error": "Request body too large"}"#); + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..22698c7 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,115 @@ +use std::io::Read; +use std::sync::Arc; +use tiny_http::{Header, Response, Server, StatusCode}; +use ushort::{App, Config, RequestData}; + +fn main() { + let arguments: Vec = std::env::args().collect(); + if arguments.len() != 2 { + eprintln!("Usage: {} ", arguments[0]); + std::process::exit(1); + } + + let config = Config::load(&arguments[1]).unwrap_or_else(|error| { + eprintln!("Configuration error: {error}"); + std::process::exit(1); + }); + let address = format!("{}:{}", config.host, config.port); + let app = App::new(config).unwrap_or_else(|error| { + eprintln!("Startup error: {error}"); + std::process::exit(1); + }); + let server = Arc::new(Server::http(&address).unwrap_or_else(|error| { + eprintln!("Failed to listen on {address}: {error}"); + std::process::exit(1); + })); + let shutdown_server = Arc::clone(&server); + ctrlc::set_handler(move || shutdown_server.unblock()).unwrap_or_else(|error| { + eprintln!("Failed to install signal handler: {error}"); + std::process::exit(1); + }); + + eprintln!("URL Shortener listening on http://{address}"); + eprintln!("Base URL : {}", app.config.base_url); + eprintln!( + "Base path : {}", + if app.config.base_path.is_empty() { + "/" + } else { + &app.config.base_path + } + ); + eprintln!("DB path : {}", app.config.db_path.display()); + eprintln!( + "Short codes : {}–{} chars", + app.config.min_short_length, app.config.max_short_length + ); + eprintln!("Retention days : {}", app.config.retention_days); + eprintln!( + "Rate limit : {} req/{}s per IP", + app.config.rate_limit_requests, app.config.rate_limit_window + ); + eprintln!("Production : {}", app.config.production); + + for mut request in server.incoming_requests() { + let maximum_body = app.max_request_body_bytes(); + let declared_too_large = request + .body_length() + .is_some_and(|length| length > maximum_body); + let mut body = Vec::new(); + if !declared_too_large { + let mut reader = request + .as_reader() + .take(maximum_body.saturating_add(1) as u64); + if let Err(error) = reader.read_to_end(&mut body) { + eprintln!("Failed to read request body: {error}"); + continue; + } + } + let body_too_large = declared_too_large || body.len() > maximum_body; + let data = RequestData { + method: request.method().as_str().to_string(), + target: request.url().to_string(), + headers: request + .headers() + .iter() + .map(|header| { + ( + header.field.as_str().to_string(), + header.value.as_str().into(), + ) + }) + .collect(), + body, + remote_ip: request + .remote_addr() + .map(|address| address.ip().to_string()) + .unwrap_or_default(), + }; + let client = data.remote_ip.clone(); + let method = data.method.clone(); + let target = data + .target + .split_once('?') + .map_or_else(|| data.target.clone(), |(path, _)| path.to_string()); + let result = if body_too_large { + ushort::ResponseData::payload_too_large() + } else { + app.handle(data) + }; + let status = result.status; + let mut response = Response::from_data(result.body).with_status_code(StatusCode(status)); + for (name, value) in result.headers { + match Header::from_bytes(name.as_bytes(), value.as_bytes()) { + Ok(header) => response.add_header(header), + Err(()) => eprintln!("Skipped invalid response header {name}"), + } + } + if let Err(error) = request.respond(response) { + eprintln!("Failed to respond to {client}: {error}"); + } else { + eprintln!("{client} - \"{method} {target}\" {status}"); + } + } + eprintln!("Shutting down."); +} diff --git a/urlshort.py b/urlshort.py deleted file mode 100644 index 6b9782c..0000000 --- a/urlshort.py +++ /dev/null @@ -1,705 +0,0 @@ -#!/usr/bin/env python3 -""" -URL Shortener — stdlib-only, Python 3.8+ -Usage: python urlshort.py - -API (all routes are prefixed with base_path, e.g. /s): - GET / Frontend (or health check if no static/) - GET /api/health Health check - POST/GET /api/shorten Create a short URL (no API key required) - GET /api/urls List all short URLs (API key required) - GET /api/urls/ Get info for a code (no API key required) - GET /api/lookup?url= Look up by original URL (no API key required) - DELETE /api/urls/ Delete a short URL (API key required) - GET / Redirect to original URL -""" - -import json -import logging -import os -import random -import sqlite3 -import string -import sys -import time -import collections -import mimetypes -import re -import threading -from http.server import BaseHTTPRequestHandler, HTTPServer -from urllib.parse import parse_qs, urlparse - -# --------------------------------------------------------------------------- -# Logging — outputs to console (stdout); Docker captures it automatically. -# --------------------------------------------------------------------------- - -logging.basicConfig( - level=logging.INFO, - format="%(asctime)s %(levelname)s %(message)s", - datefmt="%Y-%m-%d %H:%M:%S", - stream=sys.stdout, -) -log = logging.getLogger("urlshort") - - -# --------------------------------------------------------------------------- -# Config -# --------------------------------------------------------------------------- - -def load_config(path: str) -> dict: - with open(path, "r", encoding="utf-8") as fh: - cfg = json.load(fh) - - for key in ("base_url", "api_key"): - if key not in cfg: - raise ValueError(f"Missing required config key: '{key}'") - - # Backward compat: old 'short_length' → 'min_short_length' - if "short_length" in cfg and "min_short_length" not in cfg: - cfg["min_short_length"] = cfg["short_length"] - - cfg.setdefault("host", "0.0.0.0") - cfg.setdefault("port", 8080) - cfg.setdefault("db_path", "data/urlshort.db") - cfg.setdefault("retention_days", 0) - cfg.setdefault("min_short_length", 6) - cfg.setdefault("max_short_length", 32) - cfg.setdefault("max_url_length", 2048) - cfg.setdefault("max_retention_days", 3650) - cfg.setdefault("rate_limit_requests", 60) - cfg.setdefault("rate_limit_window", 60) - cfg.setdefault("production", False) - - # Derive base_path from the path component of base_url. - raw = urlparse(cfg["base_url"]).path.strip("/") - cfg["base_path"] = f"/{raw}" if raw else "" - - return cfg - - -# --------------------------------------------------------------------------- -# Database -# --------------------------------------------------------------------------- - -def init_db(db_path: str) -> None: - parent = os.path.dirname(db_path) - if parent: - os.makedirs(parent, exist_ok=True) - - with sqlite3.connect(db_path) as conn: - conn.execute(""" - CREATE TABLE IF NOT EXISTS urls ( - short_code TEXT PRIMARY KEY, - original_url TEXT NOT NULL, - created_at INTEGER NOT NULL, - visit_count INTEGER NOT NULL DEFAULT 0, - retention_days INTEGER NOT NULL DEFAULT 0 - ) - """) - conn.commit() - - # Migrate: add retention_days column if missing (existing DB). - try: - conn.execute( - "ALTER TABLE urls ADD COLUMN retention_days INTEGER NOT NULL DEFAULT 0" - ) - conn.commit() - except sqlite3.OperationalError: - pass # column already exists - - -def db_connect(db_path: str) -> sqlite3.Connection: - conn = sqlite3.connect(db_path) - conn.row_factory = sqlite3.Row - return conn - - -def cleanup_expired(db_path: str) -> None: - """Delete URLs whose retention period has elapsed.""" - now = int(time.time()) - with db_connect(db_path) as conn: - conn.execute( - "DELETE FROM urls WHERE retention_days > 0 " - "AND (created_at + retention_days * 86400) < ?", - (now,), - ) - conn.commit() - - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - -_CHARS = string.ascii_letters + string.digits - - -def generate_code(length: int) -> str: - return "".join(random.choices(_CHARS, k=length)) - - -def is_valid_url(url: str) -> bool: - try: - p = urlparse(url) - return p.scheme in ("http", "https") and bool(p.netloc) - except Exception: - return False - - -# --------------------------------------------------------------------------- -# Validation helpers -# --------------------------------------------------------------------------- - -_CODE_RE = re.compile(r'^[A-Za-z0-9]+$') - - -def is_valid_code(code: str, max_length: int = 32) -> bool: - """Short codes must be alphanumeric and within length limits.""" - return bool(code) and len(code) <= max_length and bool(_CODE_RE.match(code)) - - -# --------------------------------------------------------------------------- -# Static file serving -# --------------------------------------------------------------------------- - -STATIC_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "static") - - -# --------------------------------------------------------------------------- -# Rate limiter (sliding window, per IP) -# --------------------------------------------------------------------------- - -class RateLimiter: - """Simple in-memory sliding-window rate limiter.""" - - def __init__(self, max_requests: int = 60, window: int = 60): - self.max_requests = max_requests - self.window = window - self._hits: dict = collections.defaultdict(list) - self._lock = threading.Lock() - - def is_allowed(self, ip: str) -> bool: - now = time.time() - cutoff = now - self.window - with self._lock: - hits = self._hits[ip] - self._hits[ip] = hits = [t for t in hits if t > cutoff] - if len(hits) >= self.max_requests: - return False - hits.append(now) - return True - - -_rate_limiter = RateLimiter() - - -# --------------------------------------------------------------------------- -# HTTP Handler -# --------------------------------------------------------------------------- - -class Handler(BaseHTTPRequestHandler): - """Single handler that serves the whole URL shortener API.""" - - # Injected by main() before the server starts. - cfg: dict = {} - - # ------------------------------------------------------------------ - # Routing helpers - # ------------------------------------------------------------------ - - def _client_ip(self) -> str: - """Get the real client IP, checking reverse-proxy headers first. - - nginx adds X-Real-IP and X-Forwarded-For via proxy_set_header. - Without these headers, falls back to the TCP connection source. - """ - ip = self.headers.get("X-Real-IP", "").strip() - if ip: - return ip - xff = self.headers.get("X-Forwarded-For", "").strip() - if xff: - return xff.split(",")[0].strip() - return self.client_address[0] - - def _local_path(self): - """Return the request path with base_path prefix stripped. - - Returns None (→ 404) when the request path does not start with - the configured base_path at all. - """ - raw = urlparse(self.path).path - base = self.cfg.get("base_path", "") - if base: - if raw == base or raw == base + "/": - # exact match on the prefix itself → treat as root - return "/" - if raw.startswith(base + "/"): - return raw[len(base):].rstrip("/") or "/" - # path is outside our prefix entirely - return None - return raw.rstrip("/") or "/" - - # ------------------------------------------------------------------ - # Logging - # ------------------------------------------------------------------ - - def log_message(self, fmt, *args): # noqa: N802 – stdlib override - log.info("%s - %s", self._client_ip(), fmt % args) - - # ------------------------------------------------------------------ - # Low-level response helpers - # ------------------------------------------------------------------ - - def _send_json(self, status: int, payload: object) -> None: - body = json.dumps(payload, ensure_ascii=False).encode("utf-8") - self.send_response(status) - self.send_header("Content-Type", "application/json; charset=utf-8") - self.send_header("Content-Length", str(len(body))) - self.end_headers() - self.wfile.write(body) - - def _send_plain(self, status: int, text: str) -> None: - body = text.encode("utf-8") - self.send_response(status) - self.send_header("Content-Type", "text/plain; charset=utf-8") - self.send_header("Content-Length", str(len(body))) - self.end_headers() - self.wfile.write(body) - - def _send_empty(self, status: int) -> None: - self.send_response(status) - self.send_header("Content-Length", "0") - self.end_headers() - - def _error(self, status: int, message: str) -> None: - self._send_json(status, {"error": message}) - - def _redirect(self, location: str) -> None: - self.send_response(302) - self.send_header("Location", location) - self.send_header("Content-Length", "0") - self.end_headers() - - # ------------------------------------------------------------------ - # CORS & Security headers (injected into every response) - # ------------------------------------------------------------------ - - def end_headers(self): - self.send_header("Access-Control-Allow-Origin", "*") - self.send_header("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS") - self.send_header("Access-Control-Allow-Headers", "Content-Type") - self.send_header("Access-Control-Max-Age", "86400") - self.send_header("X-Content-Type-Options", "nosniff") - self.send_header("X-Frame-Options", "DENY") - self.send_header("X-XSS-Protection", "1; mode=block") - super().end_headers() - - # ------------------------------------------------------------------ - # Auth — purely api_key based (query param or JSON body) - # ------------------------------------------------------------------ - - def _check_api_key(self, body: dict = None) -> bool: - """Check api_key from query parameter or JSON body.""" - qs = parse_qs(urlparse(self.path).query) - key = qs.get("api_key", [""])[0] - if key: - return key == self.cfg["api_key"] - if body and isinstance(body, dict): - key = str(body.get("api_key", "")) - if key: - return key == self.cfg["api_key"] - return False - - # ------------------------------------------------------------------ - # Body - # ------------------------------------------------------------------ - - def _read_json(self): - length = int(self.headers.get("Content-Length", 0)) - if length == 0: - return {} - raw = self.rfile.read(length) - try: - return json.loads(raw) - except json.JSONDecodeError: - return None # signals parse failure to caller - - # ------------------------------------------------------------------ - # Route dispatch - # ------------------------------------------------------------------ - - def do_GET(self): # noqa: N802 - cleanup_expired(self.cfg["db_path"]) - - if not _rate_limiter.is_allowed(self._client_ip()): - self._error(429, "Too many requests") - return - - # Redirect bare base path to base path + / for correct relative URLs - raw = urlparse(self.path).path - base = self.cfg.get("base_path", "") - if base and raw == base: - self._redirect(base + "/") - return - - path = self._local_path() - if path is None: - self._send_empty(404) - return - - if path == "/": - # In production, nginx serves static files; backend only serves health check. - index_path = os.path.join(STATIC_DIR, "index.html") - if not self.cfg.get("production") and os.path.isfile(index_path): - self._serve_static("index.html") - else: - self._send_json(200, {"status": "ok", "service": "url-shortener"}) - - elif path == "/api/health": - self._send_json(200, {"status": "ok", "service": "url-shortener"}) - - elif path == "/api/shorten": - self._handle_shorten() - - elif path == "/api/urls": - self._handle_list_urls() - - elif path.startswith("/api/urls/"): - code = path[len("/api/urls/"):] - self._handle_get_url(code) - - elif path == "/api/lookup": - self._handle_lookup() - - elif path == "/static" or path.startswith("/static/"): - if self.cfg.get("production"): - self._send_empty(404) - return - rel = path[len("/static"):].lstrip("/") or "index.html" - self._serve_static(rel) - - else: - code = path.lstrip("/") - self._handle_redirect(code) - - def do_POST(self): # noqa: N802 - cleanup_expired(self.cfg["db_path"]) - - if not _rate_limiter.is_allowed(self._client_ip()): - self._error(429, "Too many requests") - return - - path = self._local_path() - if path is None: - self._send_empty(404) - return - - if path == "/api/shorten": - self._handle_shorten() - else: - self._send_empty(404) - - def do_DELETE(self): # noqa: N802 - cleanup_expired(self.cfg["db_path"]) - - if not _rate_limiter.is_allowed(self._client_ip()): - self._error(429, "Too many requests") - return - - path = self._local_path() - if path is None: - self._send_empty(404) - return - - if path.startswith("/api/urls/"): - code = path[len("/api/urls/"):] - self._handle_delete_url(code) - else: - self._send_empty(404) - - def do_OPTIONS(self): # noqa: N802 - """Handle CORS preflight requests.""" - self.send_response(200) - self.send_header("Content-Length", "0") - self.send_header("Allow", "GET, POST, DELETE, OPTIONS") - self.end_headers() - self.wfile.flush() - - # ------------------------------------------------------------------ - # Handlers - # ------------------------------------------------------------------ - - def _handle_shorten(self) -> None: - # Parse both query parameters and JSON body - qs = parse_qs(urlparse(self.path).query) - body = self._read_json() - if body is None: - self._error(400, "Invalid JSON body") - return - - # url: query param takes precedence, then body - original_url = qs.get("url", [""])[0] or str(body.get("url", "")).strip() - if not original_url: - self._error(400, "Missing required field: url") - return - if not is_valid_url(original_url): - self._error(400, "Invalid URL — must start with http:// or https://") - return - max_url = self.cfg["max_url_length"] - if len(original_url) > max_url: - self._error(400, f"URL too long (max {max_url} characters)") - return - - # retention_days: query param, then body, then config default - raw_rd = qs.get("retention_days", [None])[0] - if raw_rd is None: - raw_rd = body.get("retention_days") - if raw_rd is not None: - try: - retention_days = int(raw_rd) - except (ValueError, TypeError): - self._error(400, "Invalid retention_days") - return - else: - retention_days = self.cfg.get("retention_days", 0) - - max_ret = self.cfg["max_retention_days"] - if not (0 <= retention_days <= max_ret): - self._error(400, f"retention_days must be 0–{max_ret}") - return - - with db_connect(self.cfg["db_path"]) as conn: - short_code = self._unique_code(conn) - if short_code is None: - self._error(500, "Could not generate a unique short code — try again") - return - - created_at = int(time.time()) - conn.execute( - "INSERT INTO urls (short_code, original_url, created_at, retention_days) " - "VALUES (?, ?, ?, ?)", - (short_code, original_url, created_at, retention_days), - ) - conn.commit() - - short_url = f"{self.cfg['base_url'].rstrip('/')}/{short_code}" - # Response: only the short URL in plain text - self._send_plain(201, short_url) - - def _handle_list_urls(self) -> None: - if not self._check_api_key(): - self._send_empty(403) - return - - with db_connect(self.cfg["db_path"]) as conn: - rows = conn.execute( - "SELECT * FROM urls ORDER BY created_at DESC" - ).fetchall() - - base = self.cfg["base_url"].rstrip("/") - urls = [] - for r in rows: - urls.append({ - "short_code": r["short_code"], - "short_url": f"{base}/{r['short_code']}", - "original_url": r["original_url"], - "created_at": r["created_at"], - "visit_count": r["visit_count"], - "retention_days": r["retention_days"], - }) - self._send_json(200, {"count": len(urls), "urls": urls}) - - def _handle_get_url(self, code: str) -> None: - # No API key required - if not is_valid_code(code, self.cfg["max_short_length"]): - self._send_empty(404) - return - - with db_connect(self.cfg["db_path"]) as conn: - row = conn.execute( - "SELECT * FROM urls WHERE short_code = ?", (code,) - ).fetchone() - - if row is None: - self._send_empty(404) - return - - base = self.cfg["base_url"].rstrip("/") - self._send_json(200, { - "short_code": row["short_code"], - "short_url": f"{base}/{row['short_code']}", - "original_url": row["original_url"], - "created_at": row["created_at"], - "visit_count": row["visit_count"], - "retention_days": row["retention_days"], - }) - - def _handle_delete_url(self, code: str) -> None: - # API key required — 403 if not authorized (no body) - if not self._check_api_key(): - self._send_empty(403) - return - if not is_valid_code(code, self.cfg["max_short_length"]): - self._send_empty(404) - return - - with db_connect(self.cfg["db_path"]) as conn: - row = conn.execute( - "SELECT short_code FROM urls WHERE short_code = ?", (code,) - ).fetchone() - if row is None: - # 404 if not found (no body) - self._send_empty(404) - return - conn.execute("DELETE FROM urls WHERE short_code = ?", (code,)) - conn.commit() - - # 204 on success (no body) - self._send_empty(204) - - def _handle_redirect(self, code: str) -> None: - if not is_valid_code(code, self.cfg["max_short_length"]): - self._send_empty(404) - return - - with db_connect(self.cfg["db_path"]) as conn: - row = conn.execute( - "SELECT original_url FROM urls WHERE short_code = ?", (code,) - ).fetchone() - if row is None: - self._error(404, "Short code not found") - return - conn.execute( - "UPDATE urls SET visit_count = visit_count + 1 WHERE short_code = ?", - (code,), - ) - conn.commit() - - self._redirect(row["original_url"]) - - def _handle_lookup(self) -> None: - """Look up a URL by its original URL. No API key required.""" - qs = parse_qs(urlparse(self.path).query) - url = qs.get("url", [""])[0].strip() - if not url: - self._error(400, "Missing required parameter: url") - return - max_url = self.cfg["max_url_length"] - if len(url) > max_url: - self._error(400, f"URL too long (max {max_url} characters)") - return - - with db_connect(self.cfg["db_path"]) as conn: - row = conn.execute( - "SELECT * FROM urls WHERE original_url = ? ORDER BY created_at DESC LIMIT 1", - (url,), - ).fetchone() - - if row is None: - self._send_empty(404) - return - - base = self.cfg["base_url"].rstrip("/") - self._send_json(200, { - "short_code": row["short_code"], - "short_url": f"{base}/{row['short_code']}", - "original_url": row["original_url"], - "created_at": row["created_at"], - "visit_count": row["visit_count"], - "retention_days": row["retention_days"], - }) - - def _serve_static(self, rel_path: str) -> None: - """Serve a static file from STATIC_DIR (for local dev; nginx in prod).""" - if not rel_path: - rel_path = "index.html" - # Prevent directory traversal - safe = os.path.normpath(rel_path) - if safe.startswith("..") or os.path.isabs(safe): - self._send_empty(403) - return - fpath = os.path.join(STATIC_DIR, safe) - if not os.path.isfile(fpath): - self._send_empty(404) - return - mime, _ = mimetypes.guess_type(fpath) - if not mime: - mime = "application/octet-stream" - with open(fpath, "rb") as f: - data = f.read() - self.send_response(200) - self.send_header("Content-Type", mime) - self.send_header("Content-Length", str(len(data))) - self.send_header("Cache-Control", "public, max-age=3600") - self.end_headers() - self.wfile.write(data) - - # ------------------------------------------------------------------ - # Internal utils - # ------------------------------------------------------------------ - - def _unique_code(self, conn: sqlite3.Connection, attempts_per_length: int = 10): - """Generate a unique short code with progressive length increment. - - Starts at min_short_length and tries `attempts_per_length` random - codes at each length. On exhaustion, increments the length by 1 - and repeats, up to max_short_length. Returns None only when the - entire range is exhausted (extremely unlikely). - """ - min_len = self.cfg["min_short_length"] - max_len = self.cfg["max_short_length"] - for length in range(min_len, max_len + 1): - for _ in range(attempts_per_length): - code = generate_code(length) - exists = conn.execute( - "SELECT 1 FROM urls WHERE short_code = ?", (code,) - ).fetchone() - if not exists: - return code - return None - - -# --------------------------------------------------------------------------- -# Entry point -# --------------------------------------------------------------------------- - -def main() -> None: - global _rate_limiter - - if len(sys.argv) != 2: - log.error("Usage: %s ", sys.argv[0]) - sys.exit(1) - - cfg = load_config(sys.argv[1]) - init_db(cfg["db_path"]) - cleanup_expired(cfg["db_path"]) - - # Re-initialize rate limiter with config values - _rate_limiter = RateLimiter( - max_requests=cfg["rate_limit_requests"], - window=cfg["rate_limit_window"], - ) - - Handler.cfg = cfg - - host, port = cfg["host"], int(cfg["port"]) - server = HTTPServer((host, port), Handler) - - log.info("URL Shortener listening on http://%s:%s", host, port) - log.info("Base URL : %s", cfg["base_url"]) - log.info("Base path : %s", cfg["base_path"] or "/") - log.info("DB path : %s", cfg["db_path"]) - log.info("Short codes : %s–%s chars", cfg["min_short_length"], cfg["max_short_length"]) - log.info("Retention days : %s", cfg["retention_days"]) - log.info("Rate limit : %s req/%ss per IP", cfg["rate_limit_requests"], cfg["rate_limit_window"]) - log.info("Production : %s", cfg["production"]) - - try: - server.serve_forever() - except KeyboardInterrupt: - log.info("Shutting down.") - server.shutdown() - - -if __name__ == "__main__": - main() -