rewrite URL shortener in Rust

This commit is contained in:
2026-07-10 04:46:34 +00:00
parent a250ce4c1d
commit c2906e7431
16 changed files with 2906 additions and 1102 deletions
+10
View File
@@ -0,0 +1,10 @@
.git
.gitignore
target
data
config.toml
config.json
*.db
*.db-*
*.key
*.log
+8 -1
View File
@@ -1,2 +1,9 @@
data/
/target/
/config.toml
/data/*
!/data/.gitignore
*.db
*.db-*
*.key
*.log
.env
Generated
+890
View File
@@ -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"
+24
View File
@@ -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
+140
View File
@@ -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.
+29
View File
@@ -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"]
+147 -257
View File
@@ -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.
---
## Configuration (`config.json`)
| 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 |
|---|---:|---:|---|
| `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. |
> **Backward compat:** If an old config contains `short_length`, it is automatically used as `min_short_length`.
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.
---
Real API keys belong only in the deployment's ignored `config.toml`, never in
the repository or container image.
## Deploying behind a sub-path (e.g. `http://example.com/s`)
## API
Just set `base_url` to include the desired path prefix — the server derives its routing prefix automatically from it:
All paths below are relative to the path in `base_url`. Trailing slashes are
accepted. Every response includes the legacy CORS and security headers.
```json
{
"base_url": "http://example.com/s",
...
}
```
### Health and frontend
The path component `/s` is extracted at startup. The server will only respond to requests whose path starts with `/s`; everything else returns 404.
- `GET /` serves the byte-identical embedded frontend when `production=false`.
- `GET /api/health` returns HTTP 200:
| `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/<code> ──► 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=<api_key>
# or in JSON body
{"api_key": "<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"}
```
---
When the configured base path is non-empty, a GET of the bare path (for
example `/s`) redirects to `/s/`.
### `POST / GET /api/shorten`
Create a new short URL (supports both `POST` and `GET`). No API key required.
### Create a short URL
Fields can be passed as **query parameters** (URL-encoded) or in a **JSON request body**.
Query parameters take precedence over body fields.
`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` | ✅ | The URL to shorten (must start with `http://` or `https://`) |
| `retention_days` | | Override the default retention period for this URL |
|---|---:|---|
| `url` | yes | Absolute `http://` or `https://` URL. |
| `retention_days` | no | Per-URL lifetime, or zero to keep forever. |
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`.
Success is HTTP 201 with only the short URL as UTF-8 plain text:
**Response `201`** — plain text containing only the short URL:
```
http://localhost:8080/s/aB3xYz
```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`.
### `GET /api/urls` 🔒
List all short URLs, newest first.
### List URLs
`GET /api/urls?api_key=<key>` returns all rows newest first. A missing or
invalid key returns HTTP 403 with an empty body.
**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/<code>`
Get metadata for a single short code. No API key required.
- `GET /api/urls/<code>` returns one metadata object without authentication.
- `GET /api/lookup?url=<encoded-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/<code>?api_key=<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 /<code>` 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/<code>`.
**Response `404`** — no short URL exists for this original URL.
---
### `DELETE /api/urls/<code>` 🔒
Delete a short URL entry.
**Response `204`** — success (empty body).
**Response `404`** — code not found (empty body).
**Response `403`** — not authorized (empty body).
---
### `GET /<code>`
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/<code>` | (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.
-73
View File
@@ -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 `<base_url>` - health check, same as current implementation in README.
2. GET `<base_url>/api/urls&api_key=<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 `<base_url>/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 `<base_url>/api/urls/<short_id>` - no API key required, returns metadata json for a shortened URL, includes:
```json
{
"short_code": "aB3xYz",
"short_url": "<base_url>/aB3xYz",
"original_url": "https://example.com",
"created_at": 1710000000,
"visit_count": 5
}
```
5. DELETE `<base_url>/api/urls/<short_id>&api_key=<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 `<base_url>/<short_id>` - 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.
+22
View File
@@ -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
-16
View File
@@ -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
}
+17
View File
@@ -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
+12 -7
View File
@@ -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
+23 -26
View File
@@ -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://<host>/s):
# /s/ -> frontend index.html
# /s/static/... -> frontend CSS / JS
# /s/api/... -> Python backend (proxy)
# /s/<code> -> 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;
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/;
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/<code> redirects
# API calls and short-code redirects.
location /s/ {
proxy_pass http://127.0.0.1:18082;
proxy_http_version 1.1;
+1452
View File
File diff suppressed because it is too large Load Diff
+115
View File
@@ -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<String> = std::env::args().collect();
if arguments.len() != 2 {
eprintln!("Usage: {} <config.toml|config.json>", 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.");
}
-705
View File
@@ -1,705 +0,0 @@
#!/usr/bin/env python3
"""
URL Shortener — stdlib-only, Python 3.8+
Usage: python urlshort.py <config.json>
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/<code> Get info for a code (no API key required)
GET /api/lookup?url=<url> Look up by original URL (no API key required)
DELETE /api/urls/<code> Delete a short URL (API key required)
GET /<code> 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 <config.json>", 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()