14 Commits
28 changed files with 3199 additions and 1148 deletions
+10
View File
@@ -0,0 +1,10 @@
.git
.gitignore
target
data
config.toml
config.json
*.db
*.db-*
*.key
*.log
+9 -2
View File
@@ -1,2 +1,9 @@
data/ /target/
*.log /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.2"
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.2"
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
+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"]
+42 -279
View File
@@ -1,298 +1,61 @@
# URL Shortener # ushort
A minimal URL shortener written in pure Python 3.8+ (zero third-party dependencies) backed by SQLite. `ushort` is a compact, self-contained URL shortener written in Rust and backed
by SQLite. It serves an embedded web interface and JSON API from a single
statically linked executable.
It supports deployment at either a dedicated host such as `s.example.com` or
under a path such as `example.com/s`. Images are available for Linux AMD64 and
ARM64.
## Quick start ## Quick start
### Run locally Requirements: Docker with the Compose plugin.
```bash ```bash
python urlshort.py config.json cp config.example.toml config.toml
# Set a private api_key in config.toml.
mkdir -p data
USHORT_CONFIG="$PWD/config.toml" \
USHORT_DATA="$PWD/data" \
USHORT_UID="$(id -u)" \
USHORT_GID="$(id -g)" \
docker compose -f deploy/docker-compose.prod.yml up -d
``` ```
### Run with Docker Compose The example starts ushort at <http://localhost:18082/s/> and stores its SQLite
database in `./data/urlshort.db`.
Edit `config.json` first (especially `api_key` and `base_url`), then: Check it and stop it with:
```bash ```bash
docker compose up --build curl http://localhost:18082/s/api/health
docker compose -f deploy/docker-compose.prod.yml down
``` ```
The SQLite database is stored in `./data/urlshort.db` on the host — it survives container restarts. This pulls the published image; it does not compile ushort or require a Rust
toolchain.
--- ## Build from source instead
## Configuration (`config.json`) To build the current checkout locally, use the alternative build definition:
| 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 |
> **Backward compat:** If an old config contains `short_length`, it is automatically used as `min_short_length`.
---
## Deploying behind a sub-path (e.g. `http://example.com/s`)
Just set `base_url` to include the desired path prefix — the server derives its routing prefix automatically from it:
```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 ```bash
# Set base_url in config.json, then: mkdir -p data
docker compose up --build USHORT_UID="$(id -u)" USHORT_GID="$(id -g)" \
# Service is now available at http://localhost:8080/s/ docker compose -f deploy/docker-compose.build.yml up --build -d
``` ```
### With an existing nginx vhost It uses the same root-level `config.toml` and `data/` directory as the quick
start.
`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. ## Documentation
``` - [Configuration](docs/configuration.md)
Browser ──► nginx /s/ ──► static/index.html - [API reference](docs/api.md)
Browser ──► nginx /s/static/… ──► static files (CSS/JS) - [Embedded frontend](docs/frontend.md)
Browser ──► nginx /s/api/… ──► urlshort :8080 (proxy) - [Data and database compatibility](docs/data.md)
Browser ──► nginx /s/<code> ──► urlshort :8080 (proxy → 302) - [Production deployment and rollback](docs/deployment.md)
``` - [Development, testing, and releases](docs/development.md)
- [Security model](docs/security.md)
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" }
```
---
### `POST /api/shorten` 🔒
Create a new short URL.
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 |
|------------------|----------|-----------------------------------------------------------|
| `api_key` | ✅ | API key for authentication |
| `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,
"urls": [
{
"short_code": "aB3xYz",
"short_url": "http://localhost:8080/s/aB3xYz",
"original_url": "https://example.com",
"created_at": 1710000000,
"visit_count": 5,
"retention_days": 0
}
]
}
```
---
### `GET /api/urls/<code>`
Get metadata for a single short code. No API key required.
**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
}
```
**Response `404`** — code not found (empty body).
---
### `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 (JSON body)
curl -X POST http://localhost:8080/s/api/shorten \
-H "Content-Type: application/json" \
-d '{"api_key": "change-this-secret-key", "url": "https://github.com"}'
# Shorten a URL (query parameters)
curl -X POST "http://localhost:8080/s/api/shorten?api_key=change-this-secret-key&url=https%3A%2F%2Fgithub.com&retention_days=30"
# 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"
```
---
## Frontend
A clean single-page frontend is included in `static/`. It provides:
- **URL shortening** — paste a URL and press Enter to create a short URL (requires API key)
- **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
### Accessing the frontend
- **Via nginx** (production): browse to `/s/`
- **Via Python backend** (local dev): browse to `http://localhost:8080/s/`
### Fonts
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).
---
## Security & Hardening
### CORS
All responses include `Access-Control-Allow-Origin: *` headers. `OPTIONS` preflight requests are handled automatically.
### 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.
-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.
+18
View File
@@ -0,0 +1,18 @@
# Public URL, including the optional routing prefix.
base_url = "http://localhost:18082/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
-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
}
+18
View File
@@ -0,0 +1,18 @@
services:
ushort:
image: ushort:local
build:
context: ..
command: ["/app/config.toml"]
ports:
- "127.0.0.1:18082:8080"
volumes:
- "${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
+17
View File
@@ -0,0 +1,17 @@
services:
ushort:
image: sodium/ushort:0.1.2
container_name: ushort
command: ["/app/config.toml"]
ports:
- "127.0.0.1:18082:8080"
volumes:
- "${USHORT_CONFIG:-./config.toml}:/app/config.toml:ro"
- "${USHORT_DATA:-./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
+45
View File
@@ -0,0 +1,45 @@
# URL Shortener — path-prefix deployment
# Use inside a server {} block with base_url configured for /s.
location = /s {
return 301 /s/;
}
# HTML entry point.
location = /s/ {
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";
}
# CSS, JavaScript, and fonts.
location ^~ /s/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";
}
# API calls and short-code redirects.
location /s/ {
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;
}
+41
View File
@@ -0,0 +1,41 @@
# URL Shortener — dedicated-subdomain deployment
# Use inside a server {} block with base_url configured for that host.
# HTML entry point.
location = / {
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";
}
# CSS, JavaScript, and fonts.
location ^~ /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";
}
# API calls and short-code redirects.
location / {
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;
}
-14
View File
@@ -1,14 +0,0 @@
services:
urlshort:
image: python:3.11-slim
working_dir: /app
command: ["python", "-u", "urlshort.py", "config.json"]
ports:
- "127.0.0.1:18082:8080"
volumes:
- ./urlshort.py:/app/urlshort.py:ro
- ./config.json:/app/config.json:ro
- ./static:/app/static:ro
- ./data:/app/data
restart: unless-stopped
+80
View File
@@ -0,0 +1,80 @@
# API reference
All paths are relative to the path in `base_url`. Every frontend, API,
metadata, deletion, and short-code route accepts trailing slashes, including
when a query string follows the slash.
## Health and frontend
- `GET /` serves the embedded frontend.
- `GET /api/health` returns HTTP 200:
```json
{"status": "ok", "service": "url-shortener"}
```
With a non-empty base path, requesting the bare path redirects to its trailing
slash form; for example, `/s` redirects to `/s/`.
## Create a short URL
`POST /api/shorten` and `GET /api/shorten` are supported without 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, ushort
tries the next length through `max_short_length`.
## 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.
```json
{
"count": 1,
"urls": [
{
"short_code": "aB3xYz",
"short_url": "https://example.com/s/aB3xYz",
"original_url": "https://example.org",
"created_at": 1710000000,
"visit_count": 5,
"retention_days": 30
}
]
}
```
## Metadata and lookup
- `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.
## 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.
Every response includes the compatibility CORS and security headers.
+45
View File
@@ -0,0 +1,45 @@
# Configuration
ushort accepts TOML and legacy JSON configuration files. TOML is preferred;
[`config.example.toml`](../config.example.toml) is the maintained reference.
```bash
ushort config.toml
ushort legacy-config.json
```
## Options
| 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. |
The legacy `short_length` option remains an alias for `min_short_length`.
The removed legacy `production` key is ignored when present; ushort always
serves its embedded frontend and static assets.
## Public URL layouts
The same binary supports both common reverse-proxy layouts:
- `base_url = "https://s.example.com"` serves everything at the subdomain
root and produces URLs such as `https://s.example.com/aB3xYz`.
- `base_url = "https://example.com/go/links"` routes only under `/go/links`
and produces `https://example.com/go/links/aB3xYz`.
A trailing slash on `base_url` is accepted and normalized. Requests outside a
configured non-empty path prefix return 404.
Keep real API keys only in the ignored deployment `config.toml`. Never commit
them or include them in a container image.
+31
View File
@@ -0,0 +1,31 @@
# Data and database compatibility
ushort stores data in SQLite. The database can be mounted directly across
upgrades; no export or import is required.
```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
);
```
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 a strict expiry boundary.
## Backups
Take a consistent backup before every production update. SQLite's online
backup command avoids copying a database while a WAL transaction is active:
```bash
sqlite3 data/urlshort.db ".backup data/urlshort.db.pre-update"
sqlite3 data/urlshort.db.pre-update "PRAGMA integrity_check;"
```
Retain the backup until the updated release has passed health, redirect,
write, and database-integrity checks.
+89
View File
@@ -0,0 +1,89 @@
# Production deployment and rollback
The production host needs only a Compose file, `config.toml`, and persistent
data. It does not need a source checkout.
## Files
Create an application directory like this:
```text
/opt/ushort/
├── docker-compose.yml
├── config.toml
└── data/
```
Copy [`deploy/docker-compose.prod.yml`](../deploy/docker-compose.prod.yml) to
the server as `docker-compose.yml`. For repeatable releases, replace its image
tag with the verified multi-architecture digest:
```yaml
image: docker.io/sodium/ushort:0.1.2@sha256:<verified-index-digest>
```
Set `db_path = "data/urlshort.db"`. The executable always serves its embedded
frontend. Keep `config.toml` owned by the container identity (`1001:1001`) with
mode `0400`; the `data` directory must be writable by the same identity.
## Start and validate
```bash
cd /opt/ushort
docker compose config
docker compose pull
docker compose up -d
docker compose ps
docker compose logs --tail=100 ushort
curl -i http://127.0.0.1:18082/s/api/health
```
Adjust the health path to match `base_url`.
## Reverse proxy
Choose one nginx snippet and include it inside the public `server {}` block:
- [`deploy/nginx.path.conf`](../deploy/nginx.path.conf) for a path such as
`https://example.com/s`;
- [`deploy/nginx.subdomain.conf`](../deploy/nginx.subdomain.conf) for a host
such as `https://s.example.com`.
Both snippets proxy the frontend, API, and redirects to `127.0.0.1:18082`.
They apply `no-store` to the HTML entry point and `no-cache` to static assets.
The path snippet is written for `/s`; replace each `/s` location when using a
different configured prefix.
Test nginx before reloading it:
```bash
nginx -t
nginx -s reload
```
Then verify the public health endpoint, frontend, static assets, and an
existing short-code redirect. [`tests/smoke.sh`](../tests/smoke.sh) exercises
the complete trailing-slash route set against a disposable record.
## Update
1. Verify the published image contains both AMD64 and ARM64 manifests.
2. Pull the exact version and digest before changing the running service.
3. Create and integrity-check an online SQLite backup as described in
[Data and database compatibility](data.md).
4. Update the image reference and run `docker compose up -d`.
5. Check health, logs, restart count, database integrity, and public routes.
## Rollback
Restore the previous image reference and run:
```bash
docker compose up -d
```
The schema is release-compatible. If the failed release changed or damaged
data, stop the service and restore the pre-update SQLite backup before bringing
the previous image back up. Preserve any legitimate writes made after the
backup before replacing the database.
+59
View File
@@ -0,0 +1,59 @@
# Development, testing, and releases
The repository does not require a host Rust installation. The Dockerfile uses
musl and produces a `scratch` runtime image containing only the executable.
## Local source build
[`deploy/docker-compose.build.yml`](../deploy/docker-compose.build.yml) builds
the checked-out source. Its build context and bind mounts point back to the
repository root.
```bash
cp config.example.toml config.toml
mkdir -p data
USHORT_UID="$(id -u)" USHORT_GID="$(id -g)" \
docker compose -f deploy/docker-compose.build.yml up --build -d
```
The production definition,
[`deploy/docker-compose.prod.yml`](../deploy/docker-compose.prod.yml), never
builds source. It pulls the published version instead and is therefore the
recommended quick-start path in the root README.
## Automated tests
Run all Rust targets in the isolated tester stage:
```bash
docker build --target tester .
```
Run the real HTTP trailing-slash suite against a disposable deployment:
```bash
sh tests/smoke.sh https://s.example.com "$API_KEY"
sh tests/smoke.sh https://example.com/s "$API_KEY"
```
The smoke test creates and removes one temporary short URL.
## Multi-architecture release
Buildx can publish AMD64 and ARM64 from the same source:
```bash
docker buildx build \
--platform linux/amd64,linux/arm64 \
-t sodium/ushort:latest \
--push .
```
Inspect and record the OCI index digest before deployment:
```bash
docker buildx imagetools inspect sodium/ushort:latest
```
The embedded frontend test verifies every file under `static/` byte-for-byte,
so no separate web-root packaging step is required.
+21
View File
@@ -0,0 +1,21 @@
# Embedded frontend
The frontend under `static/` is embedded into the executable at compile time.
No separate web root or static-file bind mount is required.
It includes URL creation and lookup, short-link metadata, copy and delete
actions, API-key administration, sorting, pagination, theme selection, and
locally hosted fonts. Relative asset URLs and the browser-derived API prefix
allow the same files to work at either a subdomain root or a nested path.
The frontend and static assets are always available. The former `production`
toggle was removed because disabling embedded assets conflicts with ushort's
self-contained deployment model. Old configuration files containing that key
remain loadable; its value is ignored.
The nginx examples apply `no-store` to the HTML entry point and `no-cache` to
static assets. Embedded static requests do not consume the API rate-limit
quota.
Automated tests compare every embedded asset with its source file byte for
byte. See [Development, testing, and releases](development.md).
+22
View File
@@ -0,0 +1,22 @@
# 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.
- Client identity uses `X-Real-IP`, then the first `X-Forwarded-For` value,
then the TCP peer. Bind the container port to loopback and let only a trusted
reverse proxy overwrite those headers.
- CORS preflight uses `OPTIONS` and does not consume rate-limit quota.
- The runtime Compose definitions use a read-only root filesystem, drop Linux
capabilities, enable `no-new-privileges`, and run as a numeric non-root user.
- The runtime image contains no shell, package manager, Python runtime, or
external static files.
- `SIGINT` and `SIGTERM` cleanly unblock the server for prompt shutdown.
Keep `api_key` out of Git, image layers, command output, and monitoring labels.
The supplied nginx configurations forward trusted proxy headers and keep the
application port bound to `127.0.0.1`.
-47
View File
@@ -1,47 +0,0 @@
# ===========================================================================
# 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)
# ===========================================================================
# --- Redirect bare /s to /s/ for clean UX ---
location = /s {
return 301 /s/;
}
# --- Serve frontend index.html at /s/ ---
location = /s/ {
alias /root/repo/urlshortener/static/index.html;
}
# --- Serve frontend static assets (CSS, JS, etc.) ---
# ^~ ensures this takes priority over the general /s/ proxy below.
location ^~ /s/static/ {
alias /root/repo/urlshortener/static/;
expires 7d;
add_header Cache-Control "public, immutable";
}
# --- Proxy everything else under /s/ to the Python backend ---
# Covers: /s/api/shorten, /s/api/urls, /s/api/lookup, /s/<code> redirects
location /s/ {
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;
}
+1529
View File
File diff suppressed because it is too large Load Diff
+113
View File
@@ -0,0 +1,113 @@
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
);
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.");
}
+9 -8
View File
@@ -218,24 +218,28 @@ async function lookupUrl(url) {
} }
async function handleEnter() { async function handleEnter() {
clearTimeout(_lookupTimer); // cancel any pending debounced lookup
const input = document.getElementById('url-input'); const input = document.getElementById('url-input');
const url = input.value.trim(); const url = input.value.trim();
if (!url || !isValidUrl(url)) { if (!url || !isValidUrl(url)) {
clearResult(); clearResult();
setStatus('', ''); setStatus('', '');
return; return;
} }
if (!state.apiKey) {
// No API key — just do a lookup // If the debounced lookup already found this URL exists, just show it
await lookupUrl(url); if (state.currentMeta && state.currentMeta.original_url === url) {
setStatus('exists', 'ok');
return; return;
} }
// Create a new short URL
setStatus('creating…', ''); setStatus('creating…', '');
try { try {
const res = await apiPost('/api/shorten', { api_key: state.apiKey, url: url }); const res = await apiPost('/api/shorten', { url: url });
if (res.status === 201) { if (res.status === 201) {
const shortUrl = await res.text(); const shortUrl = await res.text();
// Fetch metadata for the newly created URL
const code = shortUrl.split('/').pop(); const code = shortUrl.split('/').pop();
const metaRes = await apiGet('/api/urls/' + encodeURIComponent(code)); const metaRes = await apiGet('/api/urls/' + encodeURIComponent(code));
if (metaRes.ok) { if (metaRes.ok) {
@@ -243,11 +247,8 @@ async function handleEnter() {
state.currentMeta = data; state.currentMeta = data;
renderMeta(data); renderMeta(data);
setStatus('created ✓', 'ok'); setStatus('created ✓', 'ok');
// Refresh admin list
if (state.isAdmin) fetchAdminUrls(); if (state.isAdmin) fetchAdminUrls();
} }
} else if (res.status === 403) {
setStatus('invalid key', 'err');
} else { } else {
const err = await res.json().catch(() => null); const err = await res.json().catch(() => null);
setStatus(err?.error || 'error', 'err'); setStatus(err?.error || 'error', 'err');
+2 -2
View File
@@ -7,7 +7,7 @@
<meta name="description" content="URL Shortener"> <meta name="description" content="URL Shortener">
<title>Short · URL Shortener</title> <title>Short · URL Shortener</title>
<link rel="stylesheet" href="static/fonts.css"> <link rel="stylesheet" href="static/fonts.css">
<link rel="stylesheet" href="static/style.css"> <link rel="stylesheet" href="static/style.css?v=20260318b">
</head> </head>
<body> <body>
<div id="bg" aria-hidden="true"></div> <div id="bg" aria-hidden="true"></div>
@@ -58,7 +58,7 @@
<div id="toast" class="toast" aria-live="polite"></div> <div id="toast" class="toast" aria-live="polite"></div>
<script src="static/app.js" defer></script> <script src="static/app.js?v=20260318b" defer></script>
</body> </body>
</html> </html>
+56
View File
@@ -0,0 +1,56 @@
#!/bin/sh
set -eu
if [ "$#" -ne 2 ]; then
echo "usage: $0 BASE_URL API_KEY" >&2
exit 2
fi
base=${1%/}
api_key=$2
work=$(mktemp -d)
trap 'rm -rf "$work"' EXIT HUP INT TERM
request() {
expected=$1
method=$2
url=$3
output=$4
shift 4
actual=$(curl --path-as-is -sS -o "$output" -w '%{http_code}' -X "$method" "$url" "$@")
if [ "$actual" != "$expected" ]; then
echo "$method $url: expected HTTP $expected, got $actual" >&2
sed -n '1,20p' "$output" >&2
exit 1
fi
}
request 200 GET "$base/" "$work/root"
request 200 GET "$base///" "$work/root-slashes"
request 200 GET "$base/static/" "$work/static-index"
request 200 GET "$base/static/app.js/" "$work/app"
request 200 GET "$base/api/health/" "$work/health"
grep -q '"service": "url-shortener"' "$work/health"
request 200 OPTIONS "$base/api/health/" "$work/options"
destination="https://destination.example/trailing-slash-smoke"
encoded_destination="https%3A%2F%2Fdestination.example%2Ftrailing-slash-smoke"
request 201 POST "$base/api/shorten/" "$work/created" \
-H 'Content-Type: application/json' \
--data "{\"url\":\"$destination\"}"
short_url=$(sed -n '1p' "$work/created")
code=${short_url##*/}
case "$short_url" in
"$base"/*) ;;
*) echo "unexpected generated short URL: $short_url" >&2; exit 1 ;;
esac
request 200 GET "$base/api/lookup/?url=$encoded_destination" "$work/lookup"
grep -q "\"short_code\": \"$code\"" "$work/lookup"
request 200 GET "$base/api/urls/$code/" "$work/metadata"
request 200 GET "$base/api/urls/?api_key=$api_key" "$work/list"
request 302 GET "$base/$code/" "$work/redirect"
request 204 DELETE "$base/api/urls/$code/?api_key=$api_key" "$work/delete"
request 404 GET "$base/api/urls/$code/" "$work/missing"
echo "trailing-slash smoke test passed: $base"
-707
View File
@@ -1,707 +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 /api/shorten Create a short URL (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/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
# Auth: check api_key from query param or body
if not self._check_api_key(body):
self._send_empty(403)
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()